Files
projectmycelium/src/static/js/dashboard-resource_provider.js

8076 lines
339 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Dashboard ResourceProvider JavaScript
// Handles resource_provider dashboard functionality including automatic slice management, grid integration, and node management
if (window.resourceProviderDashboardInitialized) {
console.debug('ResourceProvider dashboard already initialized; skipping');
} else {
window.resourceProviderDashboardInitialized = true;
document.addEventListener('DOMContentLoaded', function() {
console.log('🚜 ResourceProvider Dashboard JavaScript loaded - Automatic Slice System');
// Initialize dashboard
initializeResourceProviderDashboard();
initializeAutomaticSliceManagement(); // NEW: Initialize automatic slice management
initializeNodeManagement();
initializeStakingManagement();
initializeCharts();
// Format location displays
formatLocationDisplays();
// Ensure delete node button event listener is attached
const confirmDeleteNodeBtn = document.getElementById('confirmDeleteNodeBtn');
if (confirmDeleteNodeBtn && !confirmDeleteNodeBtn.dataset.listenerAttached) {
confirmDeleteNodeBtn.addEventListener('click', confirmNodeDeletion);
confirmDeleteNodeBtn.dataset.listenerAttached = 'true';
console.log('🗑️ Node delete button event listener attached in DOMContentLoaded');
}
});
// Ensure charts initializer exists
function initializeCharts() {
if (typeof Chart === 'undefined') {
return; // Chart.js not loaded; skip
}
try {
Chart.defaults.font.family = "'Poppins', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif";
Chart.defaults.font.size = 12;
} catch (e) {
console.error('Failed to initialize charts:', e);
}
}
/**
* Initialize automatic slice management functionality
*/
function initializeAutomaticSliceManagement() {
console.log('🍰 Initializing automatic slice management system');
// Add global functions for slice management
window.refreshSliceCalculations = refreshSliceCalculations;
window.syncWithGrid = syncWithGrid;
window.viewNodeSlices = viewNodeSlices;
window.validateAndAddNodes = validateAndAddNodes;
// Add global functions for node management actions
window.viewNodeDetails = viewNodeDetails;
window.deleteNodeConfiguration = deleteNodeConfiguration;
// Load initial slice data
loadSliceStatistics();
// Initialize add node form handlers
initializeAddNodeForm();
}
/**
* Initialize add node form with automatic slice management
*/
function initializeAddNodeForm() {
console.log('📝 Initializing add node form');
// Handle validate nodes button (bind once, new grid flow)
const validateNodesBtn = document.getElementById('validateNodesBtn');
if (validateNodesBtn && !validateNodesBtn.dataset.listenerAttached) {
validateNodesBtn.addEventListener('click', validateGridNodes);
validateNodesBtn.dataset.listenerAttached = 'true';
}
// Handle add nodes button (bind once, new grid flow)
const addNodesBtn = document.getElementById('addNodesBtn');
if (addNodesBtn && !addNodesBtn.dataset.listenerAttached) {
addNodesBtn.addEventListener('click', addGridNodes);
addNodesBtn.dataset.listenerAttached = 'true';
}
// Handle node input mode changes
const nodeInputModeRadios = document.querySelectorAll('input[name="nodeInputMode"]');
nodeInputModeRadios.forEach(radio => {
radio.addEventListener('change', handleNodeInputModeChange);
});
}
// Handle base slice price validation
const baseSlicePriceInput = document.getElementById('baseSlicePrice');
if (baseSlicePriceInput) {
baseSlicePriceInput.addEventListener('input', validateSlicePrice);
}
}
/**
* Handle node input mode changes (single vs multiple)
*/
function handleNodeInputModeChange(event) {
const singleNodeInput = document.getElementById('singleNodeInput');
const multipleNodeInput = document.getElementById('multipleNodeInput');
if (event.target.value === 'single') {
singleNodeInput.style.display = 'block';
multipleNodeInput.style.display = 'none';
} else {
singleNodeInput.style.display = 'none';
multipleNodeInput.style.display = 'block';
}
}
/**
* Validate slice price is within allowed range
*/
function validateSlicePrice(event) {
const price = parseFloat(event.target.value);
const min = 0.10;
const max = 2.00;
if (price < min || price > max) {
event.target.setCustomValidity(`Price must be between ${min} and ${max} TFP/hour`);
event.target.classList.add('is-invalid');
} else {
event.target.setCustomValidity('');
event.target.classList.remove('is-invalid');
}
}
/**
* Validate nodes by fetching data from Mycelium Grid
*/
function validateNodes() {
console.log('🔍 Validating nodes from Mycelium Grid');
// Prevent validation if nodes are being added
const addBtn = document.getElementById('addNodesBtn');
if (addBtn && addBtn.disabled) {
showNotification('Please wait for current operation to complete', 'warning');
return;
}
const validateBtn = document.getElementById('validateNodesBtn');
if (!validateBtn) {
console.error('Missing validate nodes button');
return;
}
if (validateBtn.disabled) {
console.log('⚠️ Validation already in progress, ignoring duplicate call');
return;
}
// Additional check: prevent validation if we're in the middle of a page reload
if (document.hidden || document.visibilityState === 'hidden') {
console.log('⚠️ Page is hidden/reloading, skipping validation');
return;
}
const checkedMode = document.querySelector('input[name="nodeInputMode"]:checked');
if (!checkedMode) {
showNotification('Please select node input mode', 'error');
return;
}
const nodeInputMode = checkedMode.value;
let nodeIds = [];
if (nodeInputMode === 'single') {
const singleNodeInputEl = document.getElementById('gridNodeId');
if (!singleNodeInputEl) {
console.error('Missing single node input element');
return;
}
const singleNodeId = singleNodeInputEl.value.trim();
if (!singleNodeId) {
showNotification('Please enter a node ID', 'error');
return;
}
nodeIds = [parseInt(singleNodeId)];
} else {
const multipleNodeInputEl = document.getElementById('gridNodeIds');
if (!multipleNodeInputEl) {
console.error('Missing multiple node input element');
return;
}
const multipleNodeIds = multipleNodeInputEl.value.trim();
if (!multipleNodeIds) {
showNotification('Please enter node IDs', 'error');
return;
}
// Parse comma or newline separated IDs
nodeIds = multipleNodeIds.split(/[,\n]/)
.map(id => parseInt(id.trim()))
.filter(id => !isNaN(id));
}
if (nodeIds.length === 0) {
showNotification('Please enter valid node IDs', 'error');
return;
}
// Show loading state
const originalText = validateBtn.textContent;
validateBtn.disabled = true;
validateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Fetching...';
// Fetch node data from grid
window.apiJson('/api/dashboard/validate-grid-nodes-automatic', {
method: 'POST',
body: { node_ids: nodeIds }
})
.then(data => {
// apiJson returns unwrapped data
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
const errors = Array.isArray(data?.errors) ? data.errors : [];
const totalRequested = (typeof data?.total_requested === 'number') ? data.total_requested : (nodes.length + errors.length);
if (nodes.length > 0) {
displayNodePreview(nodes);
showAddNodesButton();
if (errors.length > 0 || data?.partial_success) {
showNotification(
`Validated ${nodes.length} of ${totalRequested} node(s). ${errors.length} failed validation.`,
'warning'
);
if (errors.length) console.warn('Node validation errors:', errors);
} else {
showNotification(`Successfully validated ${nodes.length} node(s)`, 'success');
}
} else {
// No validated nodes
if (errors.length > 0) {
showNotification(
`Failed to validate any nodes. Errors: ${errors.map(e => `Node ${e.node_id}: ${e.error}`).join('; ')}`,
'error'
);
console.error('Validation errors:', errors);
} else {
showNotification('Failed to validate nodes', 'error');
}
}
})
.catch(err => {
console.error('Node validation request failed:', err);
showNotification('Failed to validate nodes. Please try again.', 'error');
})
.finally(() => {
// Restore button state
validateBtn.disabled = false;
validateBtn.textContent = originalText;
});
}
/**
* Display node preview with automatic slice calculations
*/
function displayNodePreview(nodes) {
const nodePreview = document.getElementById('nodePreview');
const nodePreviewContent = document.getElementById('nodePreviewContent');
if (!nodePreview || !nodePreviewContent) return;
let previewHtml = '';
nodes.forEach(raw => {
// Normalize shapes from different endpoints
const data = raw?.data || raw; // validate_grid_nodes wraps details under data
const gridNodeId = raw?.node_id || raw?.grid_node_id || data?.grid_node_id || raw?.id || 'Unknown';
const cap = raw?.capacity || raw?.total_resources || data?.total_resources || null;
const status = raw?.status || 'online';
const city = raw?.city || data?.city;
const country = raw?.country || data?.country;
const locationStr = raw?.location || (city && country ? `${city}, ${country}` : 'Unknown Location');
const totalBaseSlices = typeof raw?.total_base_slices === 'number' ? raw.total_base_slices : (cap ? Math.max(1, Math.floor(Math.min(
(cap.cpu_cores || 0) / 1,
(cap.memory_gb || cap.ram_gb || 0) / 4,
((cap.storage_gb || 0)) / 200
))) : 'N/A');
const availableCombinations = Array.isArray(raw?.available_combinations) ? raw.available_combinations : [];
previewHtml += `
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">
<i class="bi bi-server me-2"></i>
Node ${gridNodeId} - ${locationStr}
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Node Specifications</h6>
<ul class="list-unstyled">
<li><strong>CPU:</strong> ${cap ? cap.cpu_cores : 'Unknown'} cores</li>
<li><strong>Memory:</strong> ${cap ? (cap.memory_gb ?? cap.ram_gb ?? 'Unknown') : 'Unknown'} GB</li>
<li><strong>Storage:</strong> ${cap ? (cap.storage_gb ?? ((cap.ssd_storage_gb||0) + (cap.hdd_storage_gb||0))) : 'Unknown'} GB</li>
<li><strong>Status:</strong> <span class="badge bg-${status === 'online' ? 'success' : 'warning'}">${status}</span></li>
</ul>
</div>
<div class="col-md-6">
<h6>Automatic Slice Calculation</h6>
<ul class="list-unstyled">
<li><strong>Total Base Slices:</strong> ${totalBaseSlices}</li>
<li><strong>Available Combinations:</strong> ${availableCombinations.length}</li>
<li><strong>Max Slice Multiplier:</strong> ${totalBaseSlices}${typeof totalBaseSlices === 'number' ? 'x' : ''}</li>
</ul>
<small class="text-muted">Base slice: 1 vCPU + 4GB RAM + 200GB storage</small>
</div>
</div>
</div>
</div>
`;
});
nodePreviewContent.innerHTML = previewHtml;
nodePreview.style.display = 'block';
}
/**
* Show the add nodes button after successful validation
*/
function showAddNodesButton() {
const addNodesBtn = document.getElementById('addNodesBtn');
if (addNodesBtn) {
addNodesBtn.style.display = 'inline-block';
}
}
/**
* Add validated nodes with automatic slice configuration
*/
function addValidatedNodes() {
console.log(' Adding validated nodes with automatic slice management');
// Prevent multiple simultaneous calls
const addBtn = document.getElementById('addNodesBtn');
if (addBtn.disabled) {
console.log('⚠️ Node addition already in progress, ignoring duplicate call');
return;
}
// Collect form data
const formData = {
node_ids: getValidatedNodeIds(),
node_group: document.getElementById('nodeGroup').value,
base_slice_price: parseFloat(document.getElementById('baseSlicePrice').value) || 0.50,
node_uptime_sla: parseFloat(document.getElementById('nodeUptimeSLA').value) || 99.8,
node_bandwidth_sla: parseInt(document.getElementById('nodeBandwidthSLA').value) || 100,
enable_staking: document.getElementById('enableStaking').checked
};
// Validate required fields
if (!formData.node_ids || formData.node_ids.length === 0) {
showNotification('Please validate nodes first', 'error');
return;
}
if (formData.base_slice_price < 0.10 || formData.base_slice_price > 2.00) {
showNotification('Base slice price must be between 0.10 and 2.00 TFP/hour', 'error');
return;
}
// Show loading state and prevent duplicate calls
const originalText = addBtn.textContent;
addBtn.disabled = true;
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding Nodes...';
// Also disable the validate button to prevent conflicts
const validateBtn = document.getElementById('validateNodesBtn');
if (validateBtn) {
validateBtn.disabled = true;
}
// Use new grid-nodes flow (delegates to addGridNodes which calls /api/dashboard/grid-nodes/add)
addGridNodes();
return;
}
/**
* Get validated node IDs from the preview
*/
function getValidatedNodeIds() {
const nodeInputMode = document.querySelector('input[name="nodeInputMode"]:checked').value;
let nodeIds = [];
if (nodeInputMode === 'single') {
const singleNodeId = document.getElementById('gridNodeId').value.trim();
if (singleNodeId) {
nodeIds = [parseInt(singleNodeId)];
}
} else {
const multipleNodeIds = document.getElementById('gridNodeIds').value.trim();
if (multipleNodeIds) {
nodeIds = multipleNodeIds.split(/[,\n]/)
.map(id => parseInt(id.trim()))
.filter(id => !isNaN(id));
}
}
return nodeIds;
}
/**
* Update the nodes table with newly added nodes (optimistic UI update)
*/
function updateNodesTableWithNewNodes(newNodes) {
console.log('🔄 Updating nodes table with new nodes:', newNodes);
if (!newNodes || newNodes.length === 0) {
console.log('⚠️ No new nodes to add to table');
return;
}
const nodesTableBody = document.querySelector('#nodesTable tbody');
if (!nodesTableBody) {
console.warn('⚠️ Nodes table body not found, skipping optimistic update');
return;
}
// Add each new node to the table
newNodes.forEach(node => {
const newRow = createNodeTableRow(node);
if (newRow) {
nodesTableBody.appendChild(newRow);
console.log(`✅ Added node ${node.id} to table optimistically`);
}
});
// Update any summary statistics if they exist
updateNodeCountDisplay();
}
/**
* Create a table row for a new node (optimistic UI update)
*/
function createNodeTableRow(node) {
try {
const row = document.createElement('tr');
row.id = `node-row-${node.id}`;
// Get the status badge class
const statusBadgeClass = getStatusBadgeClass(node.status);
// Format the location
const location = node.location || 'Unknown Location';
// Format the capacity
const capacity = `${node.capacity.cpu_cores}c/${node.capacity.memory_gb}GB/${node.capacity.storage_gb}GB`;
// Format earnings
const earnings = node.earnings_today || '0.00';
// Create the row HTML
row.innerHTML = `
<td>
<div class="d-flex align-items-center">
<i class="bi bi-server me-2 text-primary"></i>
<div>
<div class="fw-medium">${node.name}</div>
<small class="text-muted">${node.id}</small>
</div>
</div>
</td>
<td>${location}</td>
<td>
<span class="badge ${statusBadgeClass}">${node.status}</span>
</td>
<td>
<small class="text-muted d-block">${capacity}</small>
<small class="text-success">Uptime: ${node.uptime_percentage}%</small>
</td>
<td>
<span class="text-success fw-medium">${earnings} TFP</span>
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="viewNodeDetails('${node.id}')" title="View Details">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteNodeConfiguration('${node.id}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
return row;
} catch (error) {
console.error('❌ Error creating node table row:', error);
return null;
}
}
/**
* Update node count display in the dashboard
*/
function updateNodeCountDisplay() {
const nodesTable = document.querySelector('#nodesTable tbody');
if (!nodesTable) return;
const nodeCount = nodesTable.children.length;
// Update any node count displays
const nodeCountElements = document.querySelectorAll('[data-node-count]');
nodeCountElements.forEach(element => {
element.textContent = nodeCount;
});
console.log(`📊 Updated node count display: ${nodeCount} nodes`);
}
/**
* Combined function for validating and adding nodes (legacy compatibility)
*/
function validateAndAddNodes() {
console.log('🔄 Legacy validateAndAddNodes called - redirecting to new flow');
// First validate nodes
validateNodes();
// Note: addValidatedNodes will be called separately after validation
// This function exists for backward compatibility
}
/**
* Refresh slice calculations for all nodes
*/
function refreshSliceCalculations() {
console.log('🔄 Refreshing slice calculations');
window.apiJson('/api/dashboard/refresh-slice-calculations', {
method: 'POST'
})
.then(() => {
// Treat resolved response as success; apiJson throws on non-OK
showNotification('Slice calculations refreshed successfully', 'success');
setTimeout(() => { window.location.reload(); }, 1000);
})
.catch(error => {
console.error('Error refreshing slice calculations:', error);
showNotification('Error refreshing slice calculations', 'error');
});
}
/**
* Sync node data with Mycelium Grid
*/
function syncWithGrid() {
console.log('☁️ Syncing with Mycelium Grid');
// Immediate UX feedback while the backend sync runs
try { showNotification('Syncing with Mycelium Grid...', 'info'); } catch (_) {}
window.apiJson('/api/dashboard/sync-with-grid', {
method: 'POST'
})
.then(() => {
// Treat resolved response as success; apiJson throws on non-OK
showNotification('Successfully synced with Mycelium Grid', 'success');
setTimeout(() => { window.location.reload(); }, 1000);
})
.catch(error => {
console.error('Error syncing with grid:', error);
showNotification('Error syncing with Mycelium Grid', 'error');
});
}
/**
* View detailed slice information for a specific node
*/
function viewNodeSlices(nodeId) {
console.log('👁️ Viewing slices for node:', nodeId);
window.apiJson(`/api/dashboard/node-slices/${nodeId}`)
.then(data => {
// apiJson returns unwrapped data; support either {node_slices: {...}} or direct object
const details = (data && data.node_slices) ? data.node_slices : data;
displayNodeSliceDetails(details);
})
.catch(error => {
console.error('Error loading node slice details:', error);
showNotification('Error loading node slice details', 'error');
});
}
/**
* Display node slice details in a modal
*/
function displayNodeSliceDetails(nodeSlices) {
// Create and show modal with slice details
const modalHtml = `
<div class="modal fade" id="nodeSliceDetailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Node Slice Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<h6>Node Information</h6>
<p><strong>Name:</strong> ${nodeSlices.node_name}</p>
<p><strong>Total Capacity:</strong> ${nodeSlices.total_capacity.cpu_cores} vCPU, ${nodeSlices.total_capacity.memory_gb}GB RAM, ${nodeSlices.total_capacity.storage_gb}GB Storage</p>
<p><strong>Used Capacity:</strong> ${nodeSlices.used_capacity.cpu_cores} vCPU, ${nodeSlices.used_capacity.memory_gb}GB RAM, ${nodeSlices.used_capacity.storage_gb}GB Storage</p>
</div>
<div class="col-md-6">
<h6>Slice Statistics</h6>
<p><strong>Total Base Slices:</strong> ${nodeSlices.total_base_slices}</p>
<p><strong>Allocated Slices:</strong> ${nodeSlices.allocated_base_slices}</p>
<p><strong>Available Slices:</strong> ${nodeSlices.total_base_slices - nodeSlices.allocated_base_slices}</p>
</div>
</div>
<h6 class="mt-4">Available Slice Combinations</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Combination</th>
<th>Resources</th>
<th>Available</th>
<th>Price/Hour</th>
</tr>
</thead>
<tbody>
${nodeSlices.available_combinations.map(combo => `
<tr>
<td>${combo.multiplier}x Base Slice</td>
<td>${combo.cpu_cores} vCPU, ${combo.memory_gb}GB RAM, ${combo.storage_gb}GB Storage</td>
<td><span class="badge bg-info">${combo.quantity_available}</span></td>
<td>${combo.price_per_hour} TFP</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('nodeSliceDetailsModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to page and show
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = new bootstrap.Modal(document.getElementById('nodeSliceDetailsModal'));
modal.show();
}
/**
* Show node details in a Bootstrap modal
*/
function showNodeDetailsModal(node) {
try {
const safe = (v, fallback = 'N/A') => (v === undefined || v === null || v === '' ? fallback : v);
const cap = (node && node.capacity) || {};
const sla = (node && node.marketplace_sla) || null;
const slicePricing = (node && node.slice_pricing) || {};
const basePricePerHour = slicePricing.base_price_per_hour ?? slicePricing["base_price_per_hour"];
const title = safe(node && (node.name || (node.grid_node_id && `Node ${node.grid_node_id}`) || 'Node Details'), 'Node Details');
const rawJson = (() => { try { return JSON.stringify(node, null, 2); } catch (_) { return ''; } })();
const modalHtml = `
<div class="modal fade" id="nodeDetailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<h6>General</h6>
<table class="table table-sm mb-0">
<tbody>
<tr><th class="w-50">Grid Node ID</th><td>${safe(node && node.grid_node_id)}</td></tr>
<tr><th>Uptime</th><td>${safe(node && node.uptime_percentage, 'N/A')}%</td></tr>
<tr><th>Status</th><td>${safe(node && (node.status || node.status_text), 'N/A')}</td></tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<h6>Capacity</h6>
<table class="table table-sm mb-0">
<tbody>
<tr><th class="w-50">vCPU</th><td>${safe(cap.cpu_cores)}</td></tr>
<tr><th>Memory</th><td>${safe(cap.memory_gb)} GB</td></tr>
<tr><th>Storage</th><td>${safe(cap.storage_gb)} GB</td></tr>
<tr><th>Bandwidth</th><td>${safe(cap.bandwidth_mbps)} Mbps</td></tr>
</tbody>
</table>
</div>
</div>
<div class="row g-3 mt-3">
<div class="col-md-6">
<h6>Pricing</h6>
<table class="table table-sm mb-0">
<tbody>
<tr><th class="w-50">Base Price / Hour</th><td>${safe(basePricePerHour)}</td></tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<h6>SLA</h6>
${sla ? `
<table class="table table-sm mb-0">
<tbody>
<tr><th class="w-50">Uptime Guarantee</th><td>${safe(sla.uptime_guarantee_percentage)}%</td></tr>
<tr><th>Base Slice Price</th><td>${safe(sla.base_slice_price)}</td></tr>
</tbody>
</table>
` : '<div class="text-muted">No marketplace SLA</div>'}
</div>
</div>
<details class="mt-3">
<summary>Raw data</summary>
<pre class="mt-2 bg-light p-2 rounded small" style="max-height: 300px; overflow:auto;">${rawJson}</pre>
</details>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existing = document.getElementById('nodeDetailsModal');
if (existing) existing.remove();
// Append and show
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = new bootstrap.Modal(document.getElementById('nodeDetailsModal'));
modal.show();
} catch (e) {
console.error('Failed to render node details modal:', e);
showNotification('Failed to render node details', 'error');
}
}
/**
* Load slice statistics for dashboard
*/
function loadSliceStatistics() {
console.log('📊 Loading slice statistics');
window.apiJson('/api/dashboard/resource_provider/slice-statistics')
.then(data => {
// apiJson returns unwrapped data; support either {statistics: {...}} or {...}
const stats = (data && data.statistics) ? data.statistics : (data || {});
updateSliceStatistics(stats);
})
.catch(error => {
console.debug('Slice statistics endpoint not available (this is expected):', error.message);
// Gracefully handle missing endpoint - show default/empty state
updateSliceStatistics({
total_slices: 0,
active_slices: 0,
revenue_today: 0,
revenue_month: 0
});
});
}
/**
* Update slice statistics in the UI
*/
function updateSliceStatistics(stats) {
// Update slice overview cards if they exist
const totalSlicesEl = document.getElementById('total-base-slices');
const allocatedSlicesEl = document.getElementById('allocated-base-slices');
const availableSlicesEl = document.getElementById('available-base-slices');
const utilizationEl = document.getElementById('slice-utilization');
if (totalSlicesEl) totalSlicesEl.textContent = stats.total_base_slices || 0;
if (allocatedSlicesEl) allocatedSlicesEl.textContent = stats.allocated_base_slices || 0;
if (availableSlicesEl) availableSlicesEl.textContent = stats.available_base_slices || 0;
if (utilizationEl) utilizationEl.textContent = (stats.slice_utilization_percentage || 0).toFixed(1) + '%';
}
/**
* Show notification to user
*/
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${type === 'error' ? 'danger' : type} alert-dismissible fade show position-fixed`;
notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
/**
* Simple setup for individual pricing listener
*/
function setupIndividualPricingListener() {
console.log('🏢 Setting up individual pricing listener');
// Check if listener already set up
if (document.body.dataset.individualPricingListenerSetup === 'true') {
console.log('🏢 Individual pricing listener already set up, skipping');
return;
}
// Use event delegation to handle dynamically created elements
document.addEventListener('change', function(event) {
if (event.target && event.target.id === 'individualPricing') {
console.log('🏢 Individual pricing radio button clicked!');
if (event.target.checked) {
console.log('🏢 Individual pricing selected - creating forms');
// Clean up first to prevent duplicates
cleanupIndividualPricingSections();
setTimeout(() => {
createSimpleIndividualPricingForms();
}, 100);
} else {
console.log('🏢 Individual pricing deselected - cleaning up');
cleanupIndividualPricingSections();
}
}
if (event.target && event.target.id === 'sameForAllNodes') {
console.log('🏢 Same for all nodes selected - cleaning up individual forms');
cleanupIndividualPricingSections();
}
});
// Mark as set up
document.body.dataset.individualPricingListenerSetup = 'true';
console.log('🏢 Individual pricing listener set up with event delegation');
}
/**
* Simple version of creating individual pricing table rows
*/
function createSimpleIndividualPricingForms() {
console.log('🏢 Creating individual pricing table rows');
// Check if individual pricing table already exists
const existingContainer = document.getElementById('individualPricingContainer');
if (existingContainer) {
console.log('🏢 Individual pricing table already exists, skipping creation');
return;
}
// Find the existing pricing inputs
const hourlyInput = document.getElementById('fullNodePriceHour') || document.getElementById('fullNodePriceHourNew');
const dailyInput = document.getElementById('fullNodePriceDay') || document.getElementById('fullNodePriceDayNew');
const monthlyInput = document.getElementById('fullNodePriceMonth') || document.getElementById('fullNodePriceMonthNew');
const yearlyInput = document.getElementById('fullNodePriceYear') || document.getElementById('fullNodePriceYearNew');
if (!hourlyInput || !dailyInput || !monthlyInput || !yearlyInput) {
console.warn('🏢 Could not find existing pricing inputs');
return;
}
// Get the validated nodes from the preview
const nodePreviewContent = document.getElementById('nodePreviewContent');
let nodeCount = 0;
let nodeNames = [];
let nodeIds = [];
if (nodePreviewContent) {
const nodeCards = nodePreviewContent.querySelectorAll('.card');
if (nodeCards.length > 0) {
nodeCount = nodeCards.length;
nodeCards.forEach((card, index) => {
// Extract the actual Grid Node ID from the card content
const nodeIdMatch = card.textContent.match(/Grid Node (\d+)/);
if (nodeIdMatch) {
const actualNodeId = nodeIdMatch[1];
nodeNames.push(`Node ${actualNodeId}`);
nodeIds.push(`grid_${actualNodeId}`);
} else {
// Fallback if we can't find the ID
nodeNames.push(`Node ${index + 1}`);
nodeIds.push(`node_${index}`);
}
});
}
}
// If no nodes found in preview, check if we can get them from input fields
if (nodeCount === 0) {
// Try to get node IDs from the multiple node input field
const multipleNodeInput = document.getElementById('gridNodeIds');
if (multipleNodeInput && multipleNodeInput.value.trim()) {
const inputNodeIds = multipleNodeInput.value.split(/[,\n]/)
.map(id => id.trim())
.filter(id => id && !isNaN(id));
if (inputNodeIds.length > 0) {
nodeCount = inputNodeIds.length;
inputNodeIds.forEach(nodeId => {
nodeNames.push(`Node ${nodeId}`);
nodeIds.push(`grid_${nodeId}`);
});
}
}
// Try single node input as fallback
if (nodeCount === 0) {
const singleNodeInput = document.getElementById('gridNodeId');
if (singleNodeInput && singleNodeInput.value.trim()) {
const nodeId = singleNodeInput.value.trim();
if (!isNaN(nodeId)) {
nodeCount = 1;
nodeNames.push(`Node ${nodeId}`);
nodeIds.push(`grid_${nodeId}`);
}
}
}
}
// Final fallback for testing
if (nodeCount === 0) {
console.warn('🏢 No nodes found, using test data');
nodeCount = 2;
nodeNames = ['Node 10', 'Node 11']; // Test with realistic node IDs
nodeIds = ['grid_10', 'grid_11'];
}
console.log(`🏢 Creating pricing rows for ${nodeCount} nodes:`, nodeNames);
console.log(`🏢 Node IDs:`, nodeIds);
// Find the parent container of the pricing inputs
const pricingContainer = hourlyInput.closest('.row') || hourlyInput.closest('.card-body');
if (!pricingContainer) {
console.warn('🏢 Could not find pricing container');
return;
}
// Hide the original single-row pricing inputs
const originalRow = hourlyInput.closest('.row');
if (originalRow) {
originalRow.style.display = 'none';
}
// Hide only the redundant rate inputs (not the discount controls)
if (dailyInput) {
// Hide just the rate input, not the entire column (which contains discount controls)
dailyInput.style.display = 'none';
const dailyLabel = dailyInput.previousElementSibling;
if (dailyLabel && dailyLabel.tagName === 'LABEL') {
dailyLabel.style.display = 'none';
}
}
if (monthlyInput) {
monthlyInput.style.display = 'none';
const monthlyLabel = monthlyInput.previousElementSibling;
if (monthlyLabel && monthlyLabel.tagName === 'LABEL') {
monthlyLabel.style.display = 'none';
}
}
if (yearlyInput) {
yearlyInput.style.display = 'none';
const yearlyLabel = yearlyInput.previousElementSibling;
if (yearlyLabel && yearlyLabel.tagName === 'LABEL') {
yearlyLabel.style.display = 'none';
}
}
// Find and show discount controls with updated labeling for individual pricing
const dailyDiscountEl = document.getElementById('fullNodeDailyDiscount');
const monthlyDiscountEl = document.getElementById('fullNodeMonthlyDiscount');
const yearlyDiscountEl = document.getElementById('fullNodeYearlyDiscount');
if (dailyDiscountEl) {
const discountContainer = dailyDiscountEl.closest('.col-md-4');
if (discountContainer) {
discountContainer.style.display = '';
// Update label to indicate it applies to individual nodes
const label = discountContainer.querySelector('label[for="fullNodeDailyDiscount"]');
if (label) {
label.innerHTML = 'Daily Discount % <small class="text-info">(applies to all nodes)</small>';
}
}
}
if (monthlyDiscountEl) {
const discountContainer = monthlyDiscountEl.closest('.col-md-4');
if (discountContainer) {
discountContainer.style.display = '';
const label = discountContainer.querySelector('label[for="fullNodeMonthlyDiscount"]');
if (label) {
label.innerHTML = 'Monthly Discount % <small class="text-info">(applies to all nodes)</small>';
}
}
}
if (yearlyDiscountEl) {
const discountContainer = yearlyDiscountEl.closest('.col-md-4');
if (discountContainer) {
discountContainer.style.display = '';
const label = discountContainer.querySelector('label[for="fullNodeYearlyDiscount"]');
if (label) {
label.innerHTML = 'Yearly Discount % <small class="text-info">(applies to all nodes)</small>';
}
}
}
// Create individual pricing table
const tableContainer = document.createElement('div');
tableContainer.id = 'individualPricingContainer';
tableContainer.className = 'mt-3';
let tableHTML = `
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Individual Node Pricing:</strong> Set different rates for each node.
</div>
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>Node</th>
<th>Base Hourly Rate (TFP)</th>
<th>Daily Rate (TFP)</th>
<th>Monthly Rate (TFP)</th>
<th>Yearly Rate (TFP)</th>
</tr>
</thead>
<tbody>
`;
// Add a row for each node using actual node IDs
nodeNames.forEach((nodeName, index) => {
const nodeId = nodeIds[index]; // Use the actual node ID
tableHTML += `
<tr>
<td><strong>${nodeName}</strong></td>
<td>
<input type="number" class="form-control individual-hourly-rate"
id="hourly_${nodeId}"
data-node-id="${nodeId}"
step="0.01" min="0" placeholder="0.00">
</td>
<td>
<input type="number" class="form-control individual-daily-rate"
id="daily_${nodeId}"
data-node-id="${nodeId}"
step="0.01" min="0" placeholder="Auto-calculated" readonly>
</td>
<td>
<input type="number" class="form-control individual-monthly-rate"
id="monthly_${nodeId}"
data-node-id="${nodeId}"
step="0.01" min="0" placeholder="Auto-calculated" readonly>
</td>
<td>
<input type="number" class="form-control individual-yearly-rate"
id="yearly_${nodeId}"
data-node-id="${nodeId}"
step="0.01" min="0" placeholder="Auto-calculated" readonly>
</td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
</div>
`;
tableContainer.innerHTML = tableHTML;
// Insert the table after the original pricing row
if (originalRow && originalRow.parentNode) {
originalRow.parentNode.insertBefore(tableContainer, originalRow.nextSibling);
} else {
pricingContainer.appendChild(tableContainer);
}
// Add auto-calculation for each row using actual node IDs
nodeIds.forEach((nodeId, index) => {
const hourlyInput = document.getElementById(`hourly_${nodeId}`);
const dailyInput = document.getElementById(`daily_${nodeId}`);
const monthlyInput = document.getElementById(`monthly_${nodeId}`);
const yearlyInput = document.getElementById(`yearly_${nodeId}`);
if (hourlyInput) {
hourlyInput.addEventListener('input', function() {
const hourlyRate = parseFloat(this.value) || 0;
if (hourlyRate > 0) {
// Get discount values from the original discount controls
const dailyDiscountInput = document.getElementById('fullNodeDailyDiscount');
const monthlyDiscountInput = document.getElementById('fullNodeMonthlyDiscount');
const yearlyDiscountInput = document.getElementById('fullNodeYearlyDiscount');
const dailyDiscount = dailyDiscountInput ? parseFloat(dailyDiscountInput.value) || 0 : 0;
const monthlyDiscount = monthlyDiscountInput ? parseFloat(monthlyDiscountInput.value) || 0 : 0;
const yearlyDiscount = yearlyDiscountInput ? parseFloat(yearlyDiscountInput.value) || 0 : 0;
// Apply discount function
const applyDiscount = (baseRate, discountPercent) => {
return baseRate * (1 - discountPercent / 100);
};
// Calculate rates with discounts applied
const baseDailyRate = hourlyRate * 24;
const baseMonthlyRate = hourlyRate * 24 * 30;
const baseYearlyRate = hourlyRate * 24 * 365;
const dailyRate = applyDiscount(baseDailyRate, dailyDiscount);
const monthlyRate = applyDiscount(baseMonthlyRate, monthlyDiscount);
const yearlyRate = applyDiscount(baseYearlyRate, yearlyDiscount);
if (dailyInput) dailyInput.value = dailyRate.toFixed(2);
if (monthlyInput) monthlyInput.value = monthlyRate.toFixed(0);
if (yearlyInput) yearlyInput.value = yearlyRate.toFixed(0);
} else {
if (dailyInput) dailyInput.value = '';
if (monthlyInput) monthlyInput.value = '';
if (yearlyInput) yearlyInput.value = '';
}
});
}
});
// Add event listeners to discount controls to update all individual node rates when changed
const updateAllNodeRates = () => {
nodeIds.forEach((nodeId) => {
const hourlyInput = document.getElementById(`hourly_${nodeId}`);
if (hourlyInput && hourlyInput.value) {
// Trigger the input event to recalculate rates with new discounts
hourlyInput.dispatchEvent(new Event('input'));
}
});
};
if (dailyDiscountEl) {
dailyDiscountEl.addEventListener('input', updateAllNodeRates);
}
if (monthlyDiscountEl) {
monthlyDiscountEl.addEventListener('input', updateAllNodeRates);
}
if (yearlyDiscountEl) {
yearlyDiscountEl.addEventListener('input', updateAllNodeRates);
}
console.log('🏢 Individual pricing table created successfully!');
}
/**
* Initialize resource_provider dashboard functionality
*/
function initializeResourceProviderDashboard() {
console.log('🚜 Initializing resource_provider dashboard');
// Load resource_provider data
loadResourceProviderData();
// Load slice templates
loadSliceTemplates();
// Load node groups
loadNodeGroups();
// Set up periodic data refresh
setInterval(loadResourceProviderData, 30000); // Refresh every 30 seconds
setInterval(loadSliceTemplates, 60000); // Refresh slice templates every minute
setInterval(loadNodeGroups, 60000); // Refresh node groups every minute
}
/**
* Initialize node management functionality
*/
function initializeNodeManagement() {
console.log('🌐 Initializing node management');
// Node input mode toggle
const singleNodeMode = document.getElementById('singleNodeMode');
const multipleNodeMode = document.getElementById('multipleNodeMode');
const singleNodeInput = document.getElementById('singleNodeInput');
const multipleNodeInput = document.getElementById('multipleNodeInput');
if (singleNodeMode && multipleNodeMode) {
singleNodeMode.addEventListener('change', function() {
if (this.checked) {
singleNodeInput.style.display = 'block';
multipleNodeInput.style.display = 'none';
clearNodePreview();
}
});
multipleNodeMode.addEventListener('change', function() {
if (this.checked) {
singleNodeInput.style.display = 'none';
multipleNodeInput.style.display = 'block';
clearNodePreview();
}
});
}
// Add direct event listeners for individual pricing radio buttons as fallback
// This will be called multiple times but that's okay due to our duplicate prevention
setTimeout(() => {
const sameForAllNodes = document.getElementById('sameForAllNodes');
const individualPricing = document.getElementById('individualPricing');
if (sameForAllNodes && individualPricing) {
console.log('🏢 Adding direct event listeners to pricing radio buttons');
sameForAllNodes.addEventListener('change', function() {
console.log('🏢 Direct event: Same for all nodes selected');
if (this.checked) {
cleanupIndividualPricingSections();
}
});
individualPricing.addEventListener('change', function() {
console.log('🏢 Direct event: Individual pricing selected');
if (this.checked) {
createIndividualNodePricingForms();
}
});
}
}, 1000); // Wait a bit longer to ensure all elements are loaded
// Group configuration toggle
const createGroupToggle = document.getElementById('createGroupToggle');
const groupConfigSection = document.getElementById('groupConfigSection');
const existingGroupSection = document.getElementById('existingGroupSection');
if (createGroupToggle) {
createGroupToggle.addEventListener('change', function() {
if (this.checked) {
groupConfigSection.style.display = 'block';
existingGroupSection.style.display = 'block';
loadExistingGroups();
} else {
groupConfigSection.style.display = 'none';
existingGroupSection.style.display = 'none';
}
});
}
// Validate nodes button
const validateNodesBtn = document.getElementById('validateNodesBtn');
if (validateNodesBtn && !validateNodesBtn.dataset.listenerAttached) {
validateNodesBtn.addEventListener('click', validateGridNodes);
validateNodesBtn.dataset.listenerAttached = 'true';
}
// Add nodes button
const addNodesBtn = document.getElementById('addNodesBtn');
if (addNodesBtn && !addNodesBtn.dataset.listenerAttached) {
addNodesBtn.addEventListener('click', addGridNodes);
addNodesBtn.dataset.listenerAttached = 'true';
}
// Handle form submission for node configuration with full node rental
const nodeEditModal = document.getElementById('nodeEditModal');
if (nodeEditModal) {
nodeEditModal.addEventListener('shown.bs.modal', function() {
// Only initialize if not already done for this modal session
const enableFullNodeRental = document.getElementById('editEnableFullNodeRental');
if (!enableFullNodeRental || enableFullNodeRental.dataset.initialized !== 'true') {
console.log('🏢 Edit modal shown - initializing full node rental pricing');
initializeFullNodeRentalPricing();
}
});
nodeEditModal.addEventListener('hidden.bs.modal', function() {
// Reset full node pricing section when modal closes
const editFullNodePricingSection = document.getElementById('editFullNodePricingSection');
if (editFullNodePricingSection) {
editFullNodePricingSection.style.display = 'none';
}
// Remove any dynamic sections
const dynamicSections = document.querySelectorAll('.dynamic-full-node-pricing');
dynamicSections.forEach(section => section.remove());
// Reset initialization flag and checkbox state for Edit modal
const enableFullNodeRental = document.getElementById('editEnableFullNodeRental');
if (enableFullNodeRental) {
enableFullNodeRental.dataset.initialized = 'false';
enableFullNodeRental.checked = false;
}
// Reset staking section
const enableStaking = document.getElementById('editEnableStaking');
const stakingConfigSection = document.getElementById('editStakingConfigSection');
if (enableStaking && stakingConfigSection) {
enableStaking.checked = false;
stakingConfigSection.style.display = 'none';
}
});
}
// Initialize slice configuration modal
initializeSliceConfiguration();
// Handle Add Node modal events
const addNodeModal = document.getElementById('addNodeModal');
if (addNodeModal) {
// When modal is shown
addNodeModal.addEventListener('shown.bs.modal', function() {
// Load default slice formats and node groups when modal opens
loadDefaultSliceFormats();
loadNodeGroupsForSelection();
// Only initialize if not already done for this modal session
const enableFullNodeRental = document.getElementById('enableFullNodeRental');
if (!enableFullNodeRental || enableFullNodeRental.dataset.initialized !== 'true') {
console.log('🏢 Add modal shown - initializing full node rental pricing');
initializeFullNodeRentalPricing();
}
// Ensure sections are properly hidden based on checkbox states
initializeAddModalSectionVisibility();
});
// When modal is closed
addNodeModal.addEventListener('hidden.bs.modal', function() {
// Reset the initialization flag so it can be re-initialized next time
const enableFullNodeRental = document.getElementById('enableFullNodeRental');
if (enableFullNodeRental) {
enableFullNodeRental.dataset.initialized = 'false';
enableFullNodeRental.checked = false;
console.log('🏢 Reset full node rental initialization flag');
}
// Reset staking section for Add modal
const enableStaking = document.getElementById('enableStaking');
const stakingConfigSection = document.getElementById('stakingConfigSection');
if (enableStaking && stakingConfigSection) {
enableStaking.checked = false;
stakingConfigSection.style.display = 'none';
}
});
}
}
/**
* Load node groups for selection in dropdowns
*/
async function loadNodeGroupsForSelection() {
try {
const groupsResult = await window.apiJson('/api/dashboard/node-groups');
const groups = (groupsResult && Array.isArray(groupsResult.groups)) ? groupsResult.groups : (Array.isArray(groupsResult) ? groupsResult : []);
console.log('📦 Loaded node groups for selection:', groups);
// Update all group selection dropdowns
const groupSelects = document.querySelectorAll('.node-group-select');
groupSelects.forEach(select => {
const currentValue = select.value;
select.innerHTML = '<option value="">Single (No Group)</option>';
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
if (currentValue === group.id) {
option.selected = true;
}
select.appendChild(option);
});
});
} catch (error) {
console.error('📦 Error loading node groups for selection:', error);
}
}
/**
* Load default slice formats for node creation
*/
async function loadDefaultSliceFormats() {
try {
// Load both default formats and custom slice products
const defaultFormats = await window.apiJson('/api/dashboard/default-slice-formats');
const customSliceProducts = await window.apiJson('/api/dashboard/slice-products');
console.log('🍰 Loaded slice formats for Add Nodes:', { defaultFormats, customSliceProducts });
// Update slice format selection in add node modal
const sliceFormatContainer = document.getElementById('sliceFormatSelection');
if (sliceFormatContainer) {
sliceFormatContainer.innerHTML = '';
// Add default formats
defaultFormats.forEach(format => {
const formatCard = createSliceFormatCard(format, 'default');
sliceFormatContainer.appendChild(formatCard);
});
// Add custom slice products
customSliceProducts.forEach(product => {
const sliceConfig = product.attributes?.slice_configuration?.value;
if (sliceConfig) {
const formatCard = createSliceFormatCard({
id: product.id,
name: product.name,
cpu_cores: sliceConfig.cpu_cores,
memory_gb: sliceConfig.memory_gb,
storage_gb: sliceConfig.storage_gb,
bandwidth_mbps: sliceConfig.bandwidth_mbps,
price: product.base_price,
currency: product.base_currency,
pricing: sliceConfig.pricing
}, 'custom');
sliceFormatContainer.appendChild(formatCard);
}
});
}
} catch (error) {
console.error('🍰 Error loading slice formats:', error);
}
}
/**
* Initialize slice configuration functionality
*/
function initializeSliceConfiguration() {
console.log('🍰 Initializing slice configuration');
// Handle slice configuration form submission
const configureSliceModal = document.getElementById('configureSliceModal');
if (configureSliceModal) {
configureSliceModal.addEventListener('hidden.bs.modal', function() {
resetSliceConfigurationForm();
});
}
// Add event listener for slice creation form
const createSliceBtn = document.getElementById('saveSliceConfigBtn');
if (createSliceBtn) {
createSliceBtn.addEventListener('click', createSliceTemplate);
}
// Full node rental checkbox event listener is now handled in initializeFullNodeRentalPricing()
// CRITICAL FIX: Add event listener for slice delete confirmation button
const confirmDeleteSliceBtn = document.getElementById('confirmDeleteSliceBtn');
if (confirmDeleteSliceBtn) {
confirmDeleteSliceBtn.addEventListener('click', confirmSliceDeletion);
console.log('🍰 Slice delete button event listener attached');
} else {
console.warn('🍰 confirmDeleteSliceBtn element not found');
}
// Add event listener for node delete confirmation button
const confirmDeleteNodeBtn = document.getElementById('confirmDeleteNodeBtn');
if (confirmDeleteNodeBtn) {
confirmDeleteNodeBtn.addEventListener('click', confirmNodeDeletion);
console.log('🗑️ Node delete button event listener attached');
}
// Add auto-calculation for pricing fields
initializePricingCalculation();
}
/**
* Initialize pricing auto-calculation with transparent discount controls
*/
function initializePricingCalculation() {
const priceHour = document.getElementById('priceHour');
const priceDay = document.getElementById('priceDay');
const priceMonth = document.getElementById('priceMonth');
const priceYear = document.getElementById('priceYear');
const autoPricingToggle = document.getElementById('autoPricingToggle');
// Discount controls
const dailyDiscount = document.getElementById('dailyDiscount');
const monthlyDiscount = document.getElementById('monthlyDiscount');
const yearlyDiscount = document.getElementById('yearlyDiscount');
if (!priceHour || !priceDay || !priceMonth || !priceYear || !autoPricingToggle) return;
if (!dailyDiscount || !monthlyDiscount || !yearlyDiscount) return;
// Helper function to check if auto-calculation is enabled
const isAutoCalculationEnabled = () => autoPricingToggle.checked;
// Helper function to calculate rate with discount
const applyDiscount = (baseRate, discountPercent) => {
const discount = parseFloat(discountPercent) || 0;
return baseRate * (1 - discount / 100);
};
// Calculate all rates from hourly base rate
const calculateFromHourly = () => {
if (!isAutoCalculationEnabled()) return;
const hourlyRate = parseFloat(priceHour.value) || 0;
if (hourlyRate <= 0) {
priceDay.value = '';
priceMonth.value = '';
priceYear.value = '';
return;
}
// Calculate base rates (no discount)
const baseDailyRate = hourlyRate * 24;
const baseMonthlyRate = hourlyRate * 24 * 30;
const baseYearlyRate = hourlyRate * 24 * 365;
// Apply discounts
const finalDailyRate = applyDiscount(baseDailyRate, dailyDiscount.value);
const finalMonthlyRate = applyDiscount(baseMonthlyRate, monthlyDiscount.value);
const finalYearlyRate = applyDiscount(baseYearlyRate, yearlyDiscount.value);
// Update fields
priceDay.value = finalDailyRate.toFixed(2);
priceMonth.value = finalMonthlyRate.toFixed(0);
priceYear.value = finalYearlyRate.toFixed(0);
};
// Auto-calculate when hourly rate changes
priceHour.addEventListener('input', calculateFromHourly);
// Auto-calculate when discount percentages change
dailyDiscount.addEventListener('input', calculateFromHourly);
monthlyDiscount.addEventListener('input', calculateFromHourly);
yearlyDiscount.addEventListener('input', calculateFromHourly);
// Handle manual input in other fields (when auto-calculation is disabled)
priceDay.addEventListener('input', function() {
if (isAutoCalculationEnabled()) return; // Only allow manual input when auto-calc is off
});
priceMonth.addEventListener('input', function() {
if (isAutoCalculationEnabled()) return; // Only allow manual input when auto-calc is off
});
priceYear.addEventListener('input', function() {
if (isAutoCalculationEnabled()) return; // Only allow manual input when auto-calc is off
});
// Add visual feedback when toggle is changed
autoPricingToggle.addEventListener('change', function() {
const pricingFields = [priceDay, priceMonth, priceYear];
const discountFields = [dailyDiscount, monthlyDiscount, yearlyDiscount];
if (this.checked) {
// Enable auto-calculation
pricingFields.forEach(field => {
field.style.borderColor = '';
field.readOnly = true;
field.placeholder = 'Auto-calculated';
field.title = 'Auto-calculated from hourly rate and discount percentage';
});
discountFields.forEach(field => {
field.disabled = false;
field.style.opacity = '1';
});
// Recalculate immediately
calculateFromHourly();
showNotification('Auto-pricing enabled - set hourly rate and discount percentages', 'info');
} else {
// Disable auto-calculation
pricingFields.forEach(field => {
field.style.borderColor = '#ffc107';
field.readOnly = false;
field.placeholder = 'Set custom rate';
field.title = 'Set custom rate for special deals';
});
discountFields.forEach(field => {
field.disabled = true;
field.style.opacity = '0.5';
});
showNotification('Auto-pricing disabled - set completely custom rates', 'warning');
}
});
// Initialize state without triggering notification
if (autoPricingToggle.checked) {
// Set initial state silently
const pricingFields = [priceDay, priceMonth, priceYear];
const discountFields = [dailyDiscount, monthlyDiscount, yearlyDiscount];
pricingFields.forEach(field => {
field.style.borderColor = '';
field.readOnly = true;
field.placeholder = 'Auto-calculated';
field.title = 'Auto-calculated from hourly rate and discount percentage';
});
discountFields.forEach(field => {
field.disabled = false;
field.style.opacity = '1';
});
// Calculate initial values without notification
calculateFromHourly();
}
}
/**
* Initialize full node rental pricing functionality
*/
function initializeFullNodeRentalPricing() {
console.log('🏢 Initializing full node rental pricing');
// Determine which modal is active
const addNodeModal = document.getElementById('addNodeModal');
const editNodeModal = document.getElementById('nodeEditModal');
const isAddModalActive = addNodeModal && addNodeModal.classList.contains('show');
const isEditModalActive = editNodeModal && editNodeModal.classList.contains('show');
// Get the appropriate checkbox based on active modal
const enableFullNodeRental = isEditModalActive ?
document.getElementById('editEnableFullNodeRental') :
document.getElementById('enableFullNodeRental');
if (!enableFullNodeRental) {
console.log('🏢 Full node rental checkbox not found, skipping initialization');
return;
}
// Check if already initialized
if (enableFullNodeRental.dataset.initialized === 'true') {
console.log('🏢 Full node rental pricing already initialized, skipping');
return;
}
// Also prevent multiple dynamic sections from being created
const existingDynamicSections = document.querySelectorAll('.dynamic-full-node-pricing');
if (existingDynamicSections.length > 0) {
console.log('🏢 Found existing dynamic pricing sections, cleaning up before initialization');
existingDynamicSections.forEach(section => section.remove());
}
// Full node rental checkbox toggle
const fullNodePricingSection = document.getElementById('fullNodePricingSection');
// Remove any existing event listeners to prevent duplicates
const existingListeners = enableFullNodeRental.cloneNode(true);
enableFullNodeRental.parentNode.replaceChild(existingListeners, enableFullNodeRental);
const freshElement = isEditModalActive ?
document.getElementById('editEnableFullNodeRental') :
document.getElementById('enableFullNodeRental');
// Track if notification was already shown to prevent duplicates
let notificationShown = false;
freshElement.addEventListener('change', function() {
console.log('🏢 Full node rental checkbox changed:', this.checked);
// Re-check modal states
const isAddModalActive = addNodeModal && addNodeModal.classList.contains('show');
const isEditModalActive = editNodeModal && editNodeModal.classList.contains('show');
console.log('🏢 Modal states:', { isAddModalActive, isEditModalActive });
if (this.checked) {
if (isEditModalActive) {
// Edit modal - show existing section
const editFullNodePricingSection = document.getElementById('editFullNodePricingSection');
if (editFullNodePricingSection) {
editFullNodePricingSection.style.display = 'block';
cleanupFullNodePricingSection(editFullNodePricingSection);
setTimeout(() => {
initializeFullNodePricingCalculation();
}, 100);
}
} else if (isAddModalActive) {
// Add modal - create dynamic section
createDynamicFullNodePricingSection(this);
}
// Check if we need to show multi-node options and initialize individual pricing handlers
setTimeout(() => {
handleNodeInputModeChange();
}, 150);
// Only show notification once per modal session
if (!notificationShown) {
showNotification('Full node rental enabled - configure pricing below', 'info');
notificationShown = true;
}
} else {
if (isEditModalActive) {
// Hide existing section in edit modal
const editFullNodePricingSection = document.getElementById('editFullNodePricingSection');
if (editFullNodePricingSection) {
editFullNodePricingSection.style.display = 'none';
}
}
if (isAddModalActive) {
// Remove dynamic sections in add modal
const dynamicSections = document.querySelectorAll('.dynamic-full-node-pricing');
dynamicSections.forEach(section => section.remove());
}
// Hide multi-node options when full node rental is disabled
const multiNodeOptions = document.getElementById('multiNodePricingOptions');
if (multiNodeOptions) {
multiNodeOptions.style.display = 'none';
}
cleanupIndividualPricingSections();
// Only show notification once per modal session
if (!notificationShown) {
showNotification('Full node rental disabled', 'warning');
notificationShown = true;
}
}
});
// Mark as initialized
freshElement.dataset.initialized = 'true';
console.log('🏢 Full node rental pricing initialized successfully');
// Initialize staking functionality for the appropriate modal
const isCurrentlyEditModal = editNodeModal && editNodeModal.classList.contains('show');
if (isCurrentlyEditModal) {
initializeEditStakingFunctionality();
} else {
initializeAddStakingFunctionality();
}
}
/**
* Initialize section visibility for Edit modal based on checkbox states
*/
function initializeEditModalSectionVisibility() {
console.log('🔧 Initializing Edit modal section visibility');
// Handle staking section visibility
const enableStaking = document.getElementById('editEnableStaking');
const stakingConfigSection = document.getElementById('editStakingConfigSection');
if (enableStaking && stakingConfigSection) {
// Hide staking section if checkbox is unchecked
if (!enableStaking.checked) {
stakingConfigSection.style.display = 'none';
}
}
// Handle full node rental section visibility
const enableFullNodeRental = document.getElementById('editEnableFullNodeRental');
const editFullNodePricingSection = document.getElementById('editFullNodePricingSection');
if (enableFullNodeRental && editFullNodePricingSection) {
// Hide full node pricing section if checkbox is unchecked
if (!enableFullNodeRental.checked) {
editFullNodePricingSection.style.display = 'none';
}
}
console.log('🔧 Edit modal section visibility initialized');
}
/**
* Initialize section visibility for Add modal based on checkbox states
*/
function initializeAddModalSectionVisibility() {
console.log('🏢 Initializing Add modal section visibility');
// Handle staking section visibility
const enableStaking = document.getElementById('enableStaking');
const stakingConfigSection = document.getElementById('stakingConfigSection');
if (enableStaking && stakingConfigSection) {
// Hide staking section if checkbox is unchecked
if (!enableStaking.checked) {
stakingConfigSection.style.display = 'none';
}
}
console.log('🏢 Add modal section visibility initialized');
}
/**
* Initialize staking functionality for Add Node modal
*/
function initializeAddStakingFunctionality() {
const enableStaking = document.getElementById('enableStaking');
const stakingConfigSection = document.getElementById('stakingConfigSection');
if (enableStaking && stakingConfigSection) {
// Ensure staking section is hidden by default
stakingConfigSection.style.display = 'none';
// Remove any existing event listeners to prevent duplicates
const existingListeners = enableStaking.cloneNode(true);
enableStaking.parentNode.replaceChild(existingListeners, enableStaking);
const freshElement = document.getElementById('enableStaking');
freshElement.addEventListener('change', function() {
stakingConfigSection.style.display = this.checked ? 'block' : 'none';
});
}
}
/**
* Initialize staking functionality for Edit Node modal
*/
function initializeEditStakingFunctionality() {
const enableStaking = document.getElementById('editEnableStaking');
const stakingConfigSection = document.getElementById('editStakingConfigSection');
if (enableStaking && stakingConfigSection) {
// Ensure staking section is hidden by default
stakingConfigSection.style.display = 'none';
// Remove any existing event listeners to prevent duplicates
enableStaking.removeEventListener('change', handleEditStakingToggle);
// Add the proper event listener
enableStaking.addEventListener('change', handleEditStakingToggle);
console.log('🛡️ Edit staking functionality initialized');
}
}
/**
* Clean up the existing full node pricing section styling
*/
function cleanupFullNodePricingSection(section) {
// Remove test colors and apply clean styling
section.style.backgroundColor = '';
section.style.border = '';
section.style.minHeight = '';
section.style.padding = '';
section.style.margin = '';
section.style.position = '';
section.style.zIndex = '';
// Clean up card elements
const cardElements = section.querySelectorAll('.card, .card-body, .card-header');
cardElements.forEach(el => {
el.style.backgroundColor = '';
el.style.border = '';
el.style.minHeight = '';
});
// Apply proper Bootstrap classes
const mainCard = section.querySelector('.card');
if (mainCard) {
mainCard.className = 'card';
const cardHeader = mainCard.querySelector('.card-header');
if (cardHeader) {
cardHeader.className = 'card-header';
}
const cardBody = mainCard.querySelector('.card-body');
if (cardBody) {
cardBody.className = 'card-body';
}
}
}
/**
* Create dynamic full node pricing section for Add Nodes modal
*/
function createDynamicFullNodePricingSection(checkbox) {
// First, remove any existing dynamic pricing sections to prevent duplicates
const existingDynamicSections = document.querySelectorAll('.dynamic-full-node-pricing');
existingDynamicSections.forEach(section => {
console.log('🏢 Removing existing dynamic pricing section to prevent duplication');
section.remove();
});
const pricingSection = document.createElement('div');
pricingSection.className = 'dynamic-full-node-pricing mt-3';
pricingSection.innerHTML = `
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-server me-2"></i>Full Node Rental Pricing (TFP)</h6>
</div>
<div class="card-body">
<!-- Auto-calculate toggle -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<label class="form-label mb-0">Auto-calculate rates</label>
<small class="text-muted d-block">When enabled, entering one rate automatically calculates others. Disable to set completely custom rates.</small>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="fullNodeAutoPricingToggle" checked>
</div>
</div>
<!-- Base hourly rate -->
<div class="row mb-3">
<div class="col-md-6">
<label for="fullNodePriceHour" class="form-label">Base Hourly Rate (TFP)</label>
<input type="number" class="form-control" id="fullNodePriceHour" placeholder="e.g., 2.50" min="0" step="0.01">
</div>
</div>
<!-- Auto-calculated rates with discounts -->
<div class="row">
<div class="col-md-4 mb-3">
<label for="fullNodePriceDay" class="form-label">Daily Rate</label>
<input type="number" class="form-control" id="fullNodePriceDay" placeholder="Auto-calculated" readonly>
<div class="mt-2">
<label for="fullNodeDailyDiscount" class="form-label small">Daily Discount %</label>
<input type="number" class="form-control form-control-sm" id="fullNodeDailyDiscount" value="0" min="0" max="50" step="0.1">
</div>
</div>
<div class="col-md-4 mb-3">
<label for="fullNodePriceMonth" class="form-label">Monthly Rate</label>
<input type="number" class="form-control" id="fullNodePriceMonth" placeholder="Auto-calculated" readonly>
<div class="mt-2">
<label for="fullNodeMonthlyDiscount" class="form-label small">Monthly Discount %</label>
<input type="number" class="form-control form-control-sm" id="fullNodeMonthlyDiscount" value="0" min="0" max="50" step="0.1">
</div>
</div>
<div class="col-md-4 mb-3">
<label for="fullNodePriceYear" class="form-label">Yearly Rate</label>
<input type="number" class="form-control" id="fullNodePriceYear" placeholder="Auto-calculated" readonly>
<div class="mt-2">
<label for="fullNodeYearlyDiscount" class="form-label small">Yearly Discount %</label>
<input type="number" class="form-control form-control-sm" id="fullNodeYearlyDiscount" value="0" min="0" max="50" step="0.1">
</div>
</div>
</div>
<div class="alert alert-info">
<small><i class="bi bi-info-circle me-1"></i>Set discount percentages to offer better rates for longer commitments. 0% = no discount.</small>
</div>
<!-- Minimum rental duration -->
<div class="row">
<div class="col-md-6">
<label for="fullNodeMinRentalDuration" class="form-label">Minimum Rental Duration</label>
<div class="input-group">
<input type="number" class="form-control" id="fullNodeMinRentalDuration" value="30" min="1" max="365">
<span class="input-group-text">days</span>
</div>
<small class="text-muted">Minimum time customers must rent the full node</small>
</div>
</div>
<!-- Multi-node configuration -->
<div id="multiNodePricingOptions" style="display: none;">
<hr class="my-4">
<h6><i class="bi bi-layers me-2"></i>Multi-Node Pricing Configuration</h6>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Multiple Nodes Detected:</strong> Choose how to apply full node rental pricing across your nodes.
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="multiNodePricing" id="sameForAllNodes" value="same" checked>
<label class="form-check-label" for="sameForAllNodes">
<strong>Apply same pricing to all nodes</strong>
<small class="text-muted d-block">All nodes will use the pricing configuration above</small>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="multiNodePricing" id="individualPricing" value="individual">
<label class="form-check-label" for="individualPricing">
<strong>Set individual pricing per node</strong>
<small class="text-muted d-block">Configure different pricing for each node based on specifications</small>
</label>
</div>
</div>
</div>
</div>
`;
// Insert after the checkbox's parent container
const checkboxContainer = checkbox.closest('.mb-3');
if (checkboxContainer) {
checkboxContainer.insertAdjacentElement('afterend', pricingSection);
console.log('🏢 Created dynamic full node pricing section');
// Initialize pricing calculation for the new section
initializeFullNodePricingCalculation();
// Handle multi-node options based on input mode
handleNodeInputModeChange();
}
}
/**
* Handle node input mode changes for multi-node pricing
*/
function handleNodeInputModeChange() {
console.log('🔄 handleNodeInputModeChange called');
const nodeInputMode = document.querySelector('input[name="nodeInputMode"]:checked')?.value;
const multiNodeOptions = document.getElementById('multiNodePricingOptions');
const enableFullNodeRental = document.getElementById('enableFullNodeRental');
console.log('🔄 Node input mode state:', {
nodeInputMode,
multiNodeOptions: !!multiNodeOptions,
enableFullNodeRental: !!enableFullNodeRental,
fullNodeRentalChecked: enableFullNodeRental?.checked
});
if (multiNodeOptions && enableFullNodeRental?.checked) {
if (nodeInputMode === 'multiple') {
console.log('🔄 Showing multi-node options');
multiNodeOptions.style.display = 'block';
// Initialize individual pricing handlers with a small delay to ensure elements are rendered
setTimeout(() => {
initializeIndividualPricingHandlers();
}, 100);
} else {
console.log('🔄 Hiding multi-node options');
multiNodeOptions.style.display = 'none';
// Clean up individual pricing sections
cleanupIndividualPricingSections();
}
} else {
console.log('🔄 Conditions not met for showing multi-node options');
}
}
/**
* Initialize handlers for individual pricing radio buttons
*/
function initializeIndividualPricingHandlers() {
console.log('🏢 Attempting to initialize individual pricing handlers');
const sameForAllNodes = document.getElementById('sameForAllNodes');
const individualPricing = document.getElementById('individualPricing');
console.log('🏢 Found elements:', {
sameForAllNodes: !!sameForAllNodes,
individualPricing: !!individualPricing
});
if (sameForAllNodes && individualPricing) {
// Remove existing listeners to prevent duplicates
sameForAllNodes.removeEventListener('change', handlePricingModeChange);
individualPricing.removeEventListener('change', handlePricingModeChange);
// Add new listeners
sameForAllNodes.addEventListener('change', handlePricingModeChange);
individualPricing.addEventListener('change', handlePricingModeChange);
console.log('🏢 Individual pricing handlers initialized successfully');
// Test if the individual pricing radio button is already selected
if (individualPricing.checked) {
console.log('🏢 Individual pricing is already selected, triggering handler');
handlePricingModeChange();
}
} else {
console.warn('🏢 Could not find pricing radio button elements');
}
}
/**
* Handle pricing mode changes (same for all vs individual)
*/
function handlePricingModeChange() {
console.log('🏢 handlePricingModeChange called');
const individualPricing = document.getElementById('individualPricing');
const sameForAllNodes = document.getElementById('sameForAllNodes');
console.log('🏢 Pricing mode elements:', {
individualPricing: !!individualPricing,
individualChecked: individualPricing?.checked,
sameForAllNodes: !!sameForAllNodes,
sameChecked: sameForAllNodes?.checked
});
if (individualPricing && individualPricing.checked) {
console.log('🏢 Individual pricing selected - creating individual node forms');
createIndividualNodePricingForms();
} else {
console.log('🏢 Same pricing selected - cleaning up individual forms');
cleanupIndividualPricingSections();
}
}
/**
* Create individual pricing forms for each node
*/
function createIndividualNodePricingForms() {
console.log('🏢 createIndividualNodePricingForms called');
// Get the validated nodes from the preview
const nodePreviewContent = document.getElementById('nodePreviewContent');
console.log('🏢 nodePreviewContent found:', !!nodePreviewContent);
if (!nodePreviewContent) {
console.warn('🏢 No node preview content found');
return;
}
// Extract node information from the preview
const nodeCards = nodePreviewContent.querySelectorAll('.node-preview-card');
console.log('🏢 Found node cards:', nodeCards.length);
if (nodeCards.length === 0) {
console.warn('🏢 No node cards found in preview');
// Let's also check for any cards without the specific class
const allCards = nodePreviewContent.querySelectorAll('.card');
console.log('🏢 Found general cards:', allCards.length);
if (allCards.length > 0) {
console.log('🏢 Found cards but they don\'t have .node-preview-card class');
// Let's try to work with general cards
return createIndividualNodePricingFormsFromGeneralCards(allCards);
}
return;
}
// Clean up any existing individual pricing sections
cleanupIndividualPricingSections();
// Create container for individual pricing forms
const multiNodePricingOptions = document.getElementById('multiNodePricingOptions');
console.log('🏢 multiNodePricingOptions found:', !!multiNodePricingOptions);
if (!multiNodePricingOptions) {
console.warn('🏢 Multi-node pricing options container not found');
return;
}
// Create individual pricing container
const individualPricingContainer = document.createElement('div');
individualPricingContainer.id = 'individualPricingContainer';
individualPricingContainer.className = 'mt-4';
const headerHtml = `
<hr class="my-4">
<h6><i class="bi bi-calculator me-2"></i>Individual Node Pricing Configuration</h6>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Individual Pricing:</strong> Set custom pricing for each node based on their specifications and location.
</div>
`;
individualPricingContainer.innerHTML = headerHtml;
// Create pricing form for each node
nodeCards.forEach((nodeCard, index) => {
const nodeId = nodeCard.dataset.nodeId || `node_${index}`;
const nodeName = nodeCard.querySelector('.node-name')?.textContent || `Node ${index + 1}`;
const nodeSpecs = nodeCard.querySelector('.node-specs')?.textContent || 'Unknown specs';
const nodeLocation = nodeCard.querySelector('.node-location')?.textContent || 'Unknown location';
console.log(`🏢 Creating form for node ${index}:`, { nodeId, nodeName, nodeSpecs, nodeLocation });
const nodeForm = createIndividualNodePricingForm(nodeId, nodeName, nodeSpecs, nodeLocation, index);
individualPricingContainer.appendChild(nodeForm);
});
multiNodePricingOptions.appendChild(individualPricingContainer);
console.log(`🏢 Created individual pricing forms for ${nodeCards.length} nodes`);
}
/**
* Fallback function to create pricing forms from general cards
*/
function createIndividualNodePricingFormsFromGeneralCards(cards) {
console.log('🏢 Creating individual pricing forms from general cards');
// Clean up any existing individual pricing sections
cleanupIndividualPricingSections();
// Create container for individual pricing forms
const multiNodePricingOptions = document.getElementById('multiNodePricingOptions');
if (!multiNodePricingOptions) {
console.warn('🏢 Multi-node pricing options container not found');
return;
}
// Create individual pricing container
const individualPricingContainer = document.createElement('div');
individualPricingContainer.id = 'individualPricingContainer';
individualPricingContainer.className = 'mt-4';
const headerHtml = `
<hr class="my-4">
<h6><i class="bi bi-calculator me-2"></i>Individual Node Pricing Configuration</h6>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Individual Pricing:</strong> Set custom pricing for each node based on their specifications and location.
</div>
`;
individualPricingContainer.innerHTML = headerHtml;
// Create pricing form for each card
cards.forEach((card, index) => {
// Try to extract node information from the card
const nodeIdMatch = card.textContent.match(/Grid Node (\d+)/);
const nodeId = nodeIdMatch ? `grid_${nodeIdMatch[1]}` : `node_${index}`;
const nodeName = card.querySelector('h6')?.textContent || `Node ${index + 1}`;
// Try to find location and specs
const cardBody = card.querySelector('.card-body');
const locationMatch = cardBody?.textContent.match(/Location:\s*([^Resources]+)/);
const nodeLocation = locationMatch ? locationMatch[1].trim() : 'Unknown location';
const resourcesMatch = cardBody?.textContent.match(/Resources:\s*([^Certification]+)/);
const nodeSpecs = resourcesMatch ? resourcesMatch[1].trim() : 'Unknown specs';
console.log(`🏢 Creating form for general card ${index}:`, { nodeId, nodeName, nodeSpecs, nodeLocation });
const nodeForm = createIndividualNodePricingForm(nodeId, nodeName, nodeSpecs, nodeLocation, index);
individualPricingContainer.appendChild(nodeForm);
});
multiNodePricingOptions.appendChild(individualPricingContainer);
console.log(`🏢 Created individual pricing forms for ${cards.length} general cards`);
}
/**
* Create individual pricing form for a single node
*/
function createIndividualNodePricingForm(nodeId, nodeName, nodeSpecs, nodeLocation, index) {
const formContainer = document.createElement('div');
formContainer.className = 'card mb-3 individual-node-pricing-form';
formContainer.dataset.nodeId = nodeId;
const formHtml = `
<div class="card-header">
<h6 class="mb-0">
<i class="bi bi-server me-2"></i>
${nodeName}
<small class="text-muted ms-2">${nodeLocation}</small>
</h6>
<small class="text-muted">${nodeSpecs}</small>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<label for="nodeHourlyRate_${nodeId}" class="form-label">Hourly Rate (TFP)</label>
<div class="input-group">
<input type="number" class="form-control node-hourly-rate"
id="nodeHourlyRate_${nodeId}"
name="nodeHourlyRate_${nodeId}"
step="0.01" min="0" placeholder="0.00">
<span class="input-group-text">TFP/hour</span>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input class="form-check-input node-auto-pricing" type="checkbox"
id="nodeAutoPricing_${nodeId}"
name="nodeAutoPricing_${nodeId}" checked>
<label class="form-check-label" for="nodeAutoPricing_${nodeId}">
Auto-calculate other rates
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="nodeDailyRate_${nodeId}" class="form-label">Daily Rate</label>
<div class="input-group">
<input type="number" class="form-control node-daily-rate"
id="nodeDailyRate_${nodeId}"
name="nodeDailyRate_${nodeId}"
step="0.01" min="0" placeholder="Auto-calculated" readonly>
<span class="input-group-text">TFP/day</span>
</div>
<div class="mt-2">
<label for="nodeDailyDiscount_${nodeId}" class="form-label">Daily Discount %</label>
<input type="number" class="form-control form-control-sm node-daily-discount"
id="nodeDailyDiscount_${nodeId}"
name="nodeDailyDiscount_${nodeId}"
step="0.1" min="0" max="50" value="5" placeholder="5">
</div>
</div>
<div class="col-md-4 mb-3">
<label for="nodeMonthlyRate_${nodeId}" class="form-label">Monthly Rate</label>
<div class="input-group">
<input type="number" class="form-control node-monthly-rate"
id="nodeMonthlyRate_${nodeId}"
name="nodeMonthlyRate_${nodeId}"
step="0.01" min="0" placeholder="Auto-calculated" readonly>
<span class="input-group-text">TFP/month</span>
</div>
<div class="mt-2">
<label for="nodeMonthlyDiscount_${nodeId}" class="form-label">Monthly Discount %</label>
<input type="number" class="form-control form-control-sm node-monthly-discount"
id="nodeMonthlyDiscount_${nodeId}"
name="nodeMonthlyDiscount_${nodeId}"
step="0.1" min="0" max="50" value="15" placeholder="15">
</div>
</div>
<div class="col-md-4 mb-3">
<label for="nodeYearlyRate_${nodeId}" class="form-label">Yearly Rate</label>
<div class="input-group">
<input type="number" class="form-control node-yearly-rate"
id="nodeYearlyRate_${nodeId}"
name="nodeYearlyRate_${nodeId}"
step="0.01" min="0" placeholder="Auto-calculated" readonly>
<span class="input-group-text">TFP/year</span>
</div>
<div class="mt-2">
<label for="nodeYearlyDiscount_${nodeId}" class="form-label">Yearly Discount %</label>
<input type="number" class="form-control form-control-sm node-yearly-discount"
id="nodeYearlyDiscount_${nodeId}"
name="nodeYearlyDiscount_${nodeId}"
step="0.1" min="0" max="50" value="25" placeholder="25">
</div>
</div>
</div>
</div>
`;
formContainer.innerHTML = formHtml;
// Initialize pricing calculation for this node
setTimeout(() => {
initializeNodePricingCalculation(nodeId);
}, 100);
return formContainer;
}
/**
* Initialize pricing calculation for a specific node
*/
function initializeNodePricingCalculation(nodeId) {
const hourlyInput = document.getElementById(`nodeHourlyRate_${nodeId}`);
const dailyInput = document.getElementById(`nodeDailyRate_${nodeId}`);
const monthlyInput = document.getElementById(`nodeMonthlyRate_${nodeId}`);
const yearlyInput = document.getElementById(`nodeYearlyRate_${nodeId}`);
const autoToggle = document.getElementById(`nodeAutoPricing_${nodeId}`);
const dailyDiscountInput = document.getElementById(`nodeDailyDiscount_${nodeId}`);
const monthlyDiscountInput = document.getElementById(`nodeMonthlyDiscount_${nodeId}`);
const yearlyDiscountInput = document.getElementById(`nodeYearlyDiscount_${nodeId}`);
if (!hourlyInput || !dailyInput || !monthlyInput || !yearlyInput || !autoToggle) {
console.warn(`🏢 Missing pricing inputs for node ${nodeId}`);
return;
}
// Helper function to apply discount
const applyDiscount = (baseRate, discountPercent) => {
const discount = parseFloat(discountPercent) || 0;
return baseRate * (1 - discount / 100);
};
// Calculate rates from hourly
const calculateFromHourly = () => {
if (!autoToggle.checked) return;
const hourlyRate = parseFloat(hourlyInput.value) || 0;
if (hourlyRate <= 0) {
dailyInput.value = '';
monthlyInput.value = '';
yearlyInput.value = '';
return;
}
// Calculate base rates
const baseDailyRate = hourlyRate * 24;
const baseMonthlyRate = hourlyRate * 24 * 30;
const baseYearlyRate = hourlyRate * 24 * 365;
// Apply discounts
const finalDailyRate = applyDiscount(baseDailyRate, dailyDiscountInput.value);
const finalMonthlyRate = applyDiscount(baseMonthlyRate, monthlyDiscountInput.value);
const finalYearlyRate = applyDiscount(baseYearlyRate, yearlyDiscountInput.value);
// Update fields
dailyInput.value = finalDailyRate.toFixed(2);
monthlyInput.value = finalMonthlyRate.toFixed(0);
yearlyInput.value = finalYearlyRate.toFixed(0);
};
// Add event listeners
hourlyInput.addEventListener('input', calculateFromHourly);
dailyDiscountInput.addEventListener('input', calculateFromHourly);
monthlyDiscountInput.addEventListener('input', calculateFromHourly);
yearlyDiscountInput.addEventListener('input', calculateFromHourly);
// Handle auto-calculation toggle
autoToggle.addEventListener('change', function() {
const pricingFields = [dailyInput, monthlyInput, yearlyInput];
const discountFields = [dailyDiscountInput, monthlyDiscountInput, yearlyDiscountInput];
if (this.checked) {
// Enable auto-calculation
pricingFields.forEach(field => {
field.readOnly = true;
field.placeholder = 'Auto-calculated';
field.style.borderColor = '';
});
discountFields.forEach(field => {
field.disabled = false;
field.style.opacity = '1';
});
calculateFromHourly();
} else {
// Disable auto-calculation
pricingFields.forEach(field => {
field.readOnly = false;
field.placeholder = 'Set custom rate';
field.style.borderColor = '#ffc107';
});
discountFields.forEach(field => {
field.disabled = true;
field.style.opacity = '0.5';
});
}
});
// Initialize state
if (autoToggle.checked) {
calculateFromHourly();
}
console.log(`🏢 Initialized pricing calculation for node ${nodeId}`);
}
/**
* Clean up individual pricing sections
*/
function cleanupIndividualPricingSections() {
const existingContainer = document.getElementById('individualPricingContainer');
if (existingContainer) {
existingContainer.remove();
console.log('🏢 Cleaned up individual pricing sections');
}
// Show the original single-row pricing inputs again
const hourlyInput = document.getElementById('fullNodePriceHour') || document.getElementById('fullNodePriceHourNew');
if (hourlyInput) {
const originalRow = hourlyInput.closest('.row');
if (originalRow) {
originalRow.style.display = '';
console.log('🏢 Restored original pricing row');
}
}
// Restore the daily/monthly/yearly rate inputs and their labels that were hidden
const dailyInput = document.getElementById('fullNodePriceDay') || document.getElementById('fullNodePriceDayNew');
const monthlyInput = document.getElementById('fullNodePriceMonth') || document.getElementById('fullNodePriceMonthNew');
const yearlyInput = document.getElementById('fullNodePriceYear') || document.getElementById('fullNodePriceYearNew');
if (dailyInput) {
dailyInput.style.display = '';
const dailyLabel = dailyInput.previousElementSibling;
if (dailyLabel && dailyLabel.tagName === 'LABEL') {
dailyLabel.style.display = '';
}
}
if (monthlyInput) {
monthlyInput.style.display = '';
const monthlyLabel = monthlyInput.previousElementSibling;
if (monthlyLabel && monthlyLabel.tagName === 'LABEL') {
monthlyLabel.style.display = '';
}
}
if (yearlyInput) {
yearlyInput.style.display = '';
const yearlyLabel = yearlyInput.previousElementSibling;
if (yearlyLabel && yearlyLabel.tagName === 'LABEL') {
yearlyLabel.style.display = '';
}
}
// Restore original discount labels
const dailyDiscountLabel = document.querySelector('label[for="fullNodeDailyDiscount"]');
const monthlyDiscountLabel = document.querySelector('label[for="fullNodeMonthlyDiscount"]');
const yearlyDiscountLabel = document.querySelector('label[for="fullNodeYearlyDiscount"]');
if (dailyDiscountLabel) {
dailyDiscountLabel.innerHTML = 'Daily Discount %';
}
if (monthlyDiscountLabel) {
monthlyDiscountLabel.innerHTML = 'Monthly Discount %';
}
if (yearlyDiscountLabel) {
yearlyDiscountLabel.innerHTML = 'Yearly Discount %';
}
console.log('🏢 Restored daily/monthly/yearly rate controls and discount labels');
}
/**
* Initialize full node pricing auto-calculation
*/
function initializeFullNodePricingCalculation() {
const priceHour = document.getElementById('fullNodePriceHour');
const priceDay = document.getElementById('fullNodePriceDay');
const priceMonth = document.getElementById('fullNodePriceMonth');
const priceYear = document.getElementById('fullNodePriceYear');
const autoPricingToggle = document.getElementById('fullNodeAutoPricingToggle');
// Discount controls
const dailyDiscount = document.getElementById('fullNodeDailyDiscount');
const monthlyDiscount = document.getElementById('fullNodeMonthlyDiscount');
const yearlyDiscount = document.getElementById('fullNodeYearlyDiscount');
console.log('🏢 Initializing full node pricing calculation');
console.log('🏢 Elements found:', {
priceHour: !!priceHour,
priceDay: !!priceDay,
priceMonth: !!priceMonth,
priceYear: !!priceYear,
autoPricingToggle: !!autoPricingToggle,
dailyDiscount: !!dailyDiscount,
monthlyDiscount: !!monthlyDiscount,
yearlyDiscount: !!yearlyDiscount
});
if (!priceHour || !priceDay || !priceMonth || !priceYear) {
console.warn('🏢 Missing required pricing elements, skipping initialization');
return;
}
if (!autoPricingToggle) {
console.warn('🏢 Missing auto pricing toggle, skipping initialization');
return;
}
// Helper function to check if auto-calculation is enabled
const isAutoCalculationEnabled = () => autoPricingToggle.checked;
// Helper function to calculate rate with discount
const applyDiscount = (baseRate, discountPercent) => {
const discount = parseFloat(discountPercent) || 0;
return baseRate * (1 - discount / 100);
};
// Calculate all rates from hourly base rate
const calculateFromHourly = () => {
if (!isAutoCalculationEnabled()) return;
const hourlyRate = parseFloat(priceHour.value) || 0;
if (hourlyRate <= 0) {
priceDay.value = '';
priceMonth.value = '';
priceYear.value = '';
return;
}
// Calculate base rates (no discount)
const baseDailyRate = hourlyRate * 24;
const baseMonthlyRate = hourlyRate * 24 * 30;
const baseYearlyRate = hourlyRate * 24 * 365;
// Apply discounts
const finalDailyRate = applyDiscount(baseDailyRate, dailyDiscount.value);
const finalMonthlyRate = applyDiscount(baseMonthlyRate, monthlyDiscount.value);
const finalYearlyRate = applyDiscount(baseYearlyRate, yearlyDiscount.value);
// Update fields
priceDay.value = finalDailyRate.toFixed(2);
priceMonth.value = finalMonthlyRate.toFixed(0);
priceYear.value = finalYearlyRate.toFixed(0);
};
// Auto-calculate when hourly rate changes
priceHour.addEventListener('input', calculateFromHourly);
// Auto-calculate when discount percentages change
if (dailyDiscount) {
dailyDiscount.addEventListener('input', calculateFromHourly);
}
if (monthlyDiscount) {
monthlyDiscount.addEventListener('input', calculateFromHourly);
}
if (yearlyDiscount) {
yearlyDiscount.addEventListener('input', calculateFromHourly);
}
// Handle manual input in other fields (when auto-calculation is disabled)
priceDay.addEventListener('input', function() {
if (isAutoCalculationEnabled()) return; // Only allow manual input when auto-calc is off
});
priceMonth.addEventListener('input', function() {
if (isAutoCalculationEnabled()) return; // Only allow manual input when auto-calc is off
});
priceYear.addEventListener('input', function() {
if (isAutoCalculationEnabled()) return; // Only allow manual input when auto-calc is off
});
// Add visual feedback when toggle is changed
autoPricingToggle.addEventListener('change', function() {
const pricingFields = [priceDay, priceMonth, priceYear];
const discountFields = [dailyDiscount, monthlyDiscount, yearlyDiscount];
if (this.checked) {
// Enable auto-calculation
pricingFields.forEach(field => {
field.style.borderColor = '';
field.readOnly = true;
field.placeholder = 'Auto-calculated';
field.title = 'Auto-calculated from hourly rate and discount percentage';
});
discountFields.forEach(field => {
field.disabled = false;
field.style.opacity = '1';
});
// Recalculate immediately
calculateFromHourly();
showNotification('Full node auto-pricing enabled - set hourly rate and discount percentages', 'info');
} else {
// Disable auto-calculation
pricingFields.forEach(field => {
field.style.borderColor = '#ffc107';
field.readOnly = false;
field.placeholder = 'Set custom rate';
field.title = 'Set custom rate for special deals';
});
discountFields.forEach(field => {
field.disabled = true;
field.style.opacity = '0.5';
});
showNotification('Full node auto-pricing disabled - set completely custom rates', 'warning');
}
});
// Initialize state without triggering notification
if (autoPricingToggle.checked) {
// Set initial state silently
const pricingFields = [priceDay, priceMonth, priceYear];
const discountFields = [dailyDiscount, monthlyDiscount, yearlyDiscount];
pricingFields.forEach(field => {
field.style.borderColor = '';
field.readOnly = true;
field.placeholder = 'Auto-calculated';
field.title = 'Auto-calculated from hourly rate and discount percentage';
});
discountFields.forEach(field => {
field.disabled = false;
field.style.opacity = '1';
});
// Calculate initial values without notification
calculateFromHourly();
}
}
/**
* Collect full node pricing configuration from form
*/
function collectFullNodePricingData() {
const enableFullNodeRental = document.getElementById('enableFullNodeRental');
if (!enableFullNodeRental || !enableFullNodeRental.checked) {
return {
full_node_rental_enabled: false
};
}
// Check if individual pricing is selected
const individualPricing = document.getElementById('individualPricing');
if (individualPricing && individualPricing.checked) {
console.log('🏢 Collecting individual node pricing data');
return collectIndividualNodePricingData();
}
// Default: collect standard pricing data
const hourly = parseFloat(
document.getElementById('fullNodePriceHour')?.value ||
document.getElementById('fullNodePriceHourNew')?.value
) || 0;
const daily = parseFloat(
document.getElementById('fullNodePriceDay')?.value ||
document.getElementById('fullNodePriceDayNew')?.value
) || 0;
const monthly = parseFloat(
document.getElementById('fullNodePriceMonth')?.value ||
document.getElementById('fullNodePriceMonthNew')?.value
) || 0;
const yearly = parseFloat(
document.getElementById('fullNodePriceYear')?.value ||
document.getElementById('fullNodePriceYearNew')?.value
) || 0;
const autoCalculate = document.getElementById('fullNodeAutoPricingToggle')?.checked || true;
const dailyDiscount = parseFloat(document.getElementById('fullNodeDailyDiscount')?.value) || 0;
const monthlyDiscount = parseFloat(document.getElementById('fullNodeMonthlyDiscount')?.value) || 0;
const yearlyDiscount = parseFloat(document.getElementById('fullNodeYearlyDiscount')?.value) || 0;
const minRentalDuration = parseInt(document.getElementById('fullNodeMinRentalDuration')?.value) || 30;
console.log('🏢 Collecting standard full node pricing data');
return {
full_node_rental_enabled: true,
pricing_mode: 'same_for_all',
full_node_pricing: {
hourly: hourly,
daily: daily,
monthly: monthly,
yearly: yearly,
auto_calculate: autoCalculate,
daily_discount_percent: dailyDiscount,
monthly_discount_percent: monthlyDiscount,
yearly_discount_percent: yearlyDiscount
},
minimum_rental_days: minRentalDuration
};
}
/**
* Collect individual node pricing data
*/
function collectIndividualNodePricingData() {
const individualPricingContainer = document.getElementById('individualPricingContainer');
if (!individualPricingContainer) {
console.warn('🏢 Individual pricing container not found');
return {
full_node_rental_enabled: false
};
}
const nodePricingForms = individualPricingContainer.querySelectorAll('.individual-node-pricing-form');
if (nodePricingForms.length === 0) {
console.warn('🏢 No individual node pricing forms found');
return {
full_node_rental_enabled: false
};
}
const individualPricingData = {};
nodePricingForms.forEach(form => {
const nodeId = form.dataset.nodeId;
if (!nodeId) {
console.warn('🏢 Node form missing nodeId');
return;
}
const hourlyInput = form.querySelector(`#nodeHourlyRate_${nodeId}`);
const dailyInput = form.querySelector(`#nodeDailyRate_${nodeId}`);
const monthlyInput = form.querySelector(`#nodeMonthlyRate_${nodeId}`);
const yearlyInput = form.querySelector(`#nodeYearlyRate_${nodeId}`);
const autoToggle = form.querySelector(`#nodeAutoPricing_${nodeId}`);
const dailyDiscountInput = form.querySelector(`#nodeDailyDiscount_${nodeId}`);
const monthlyDiscountInput = form.querySelector(`#nodeMonthlyDiscount_${nodeId}`);
const yearlyDiscountInput = form.querySelector(`#nodeYearlyDiscount_${nodeId}`);
if (!hourlyInput || !dailyInput || !monthlyInput || !yearlyInput) {
console.warn(`🏢 Missing pricing inputs for node ${nodeId}`);
return;
}
individualPricingData[nodeId] = {
hourly: parseFloat(hourlyInput.value) || 0,
daily: parseFloat(dailyInput.value) || 0,
monthly: parseFloat(monthlyInput.value) || 0,
yearly: parseFloat(yearlyInput.value) || 0,
auto_calculate: autoToggle?.checked || false,
daily_discount_percent: parseFloat(dailyDiscountInput?.value) || 0,
monthly_discount_percent: parseFloat(monthlyDiscountInput?.value) || 0,
yearly_discount_percent: parseFloat(yearlyDiscountInput?.value) || 0,
};
console.log(`🏢 Collected pricing data for node ${nodeId}:`, individualPricingData[nodeId]);
});
return {
full_node_rental_enabled: true,
pricing_mode: 'individual',
individual_node_pricing: individualPricingData,
minimum_rental_days: 30 // Default value for individual pricing
};
}
/**
* Get multi-node pricing configuration
*/
function getMultiNodePricingConfig() {
const multiNodeOptions = document.getElementById('multiNodePricingOptions');
if (!multiNodeOptions || multiNodeOptions.style.display === 'none') {
return 'single'; // Single node or not applicable
}
const selectedOption = document.querySelector('input[name="multiNodePricing"]:checked');
return selectedOption ? selectedOption.value : 'same';
}
/**
* Create slice template
*/
async function createSliceTemplate() {
console.log('🍰 Creating slice template');
// Get form data
const sliceName = document.getElementById('sliceName')?.value?.trim();
const sliceCPU = parseInt(document.getElementById('sliceCPU')?.value) || 0;
const sliceRAM = parseInt(document.getElementById('sliceRAM')?.value) || 0;
const sliceStorage = parseInt(document.getElementById('sliceStorage')?.value) || 0;
const sliceBandwidth = parseInt(document.getElementById('sliceBandwidth')?.value) || 0;
const sliceUptime = parseFloat(document.getElementById('sliceUptime')?.value) || 99.0;
const sliceIPsValue = document.getElementById('sliceIPs')?.value || 'none';
const sliceIPs = sliceIPsValue === 'none' ? 0 : (sliceIPsValue === 'included' ? 1 : 0);
// Get pricing data
const priceHour = parseFloat(document.getElementById('priceHour')?.value) || 0;
const priceDay = parseFloat(document.getElementById('priceDay')?.value) || 0;
const priceMonth = parseFloat(document.getElementById('priceMonth')?.value) || 0;
const priceYear = parseFloat(document.getElementById('priceYear')?.value) || 0;
// Validate form data
if (!sliceName || sliceCPU <= 0 || sliceRAM <= 0 || sliceStorage <= 0) {
showNotification('Please fill in all required fields with valid values', 'warning');
return;
}
// Validate pricing
if (priceHour <= 0) {
showNotification('Please enter a valid hourly price', 'warning');
return;
}
const createBtn = document.getElementById('saveSliceConfigBtn');
if (createBtn) {
createBtn.disabled = true;
createBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
}
try {
const sliceData = {
name: sliceName,
cpu: sliceCPU,
ram: sliceRAM,
storage: sliceStorage,
bandwidth: sliceBandwidth,
uptime: sliceUptime,
public_ips: sliceIPs,
price_hour: priceHour,
price_day: priceDay,
price_month: priceMonth,
price_year: priceYear
};
const result = await window.apiJson('/api/dashboard/slice-products', {
method: 'POST',
body: sliceData
});
console.log('🍰 Slice template created:', result);
showNotification('Slice template created successfully', 'success');
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('configureSliceModal'));
if (modal) {
modal.hide();
}
// Reload slice templates
loadSliceTemplates();
} catch (error) {
console.error('🍰 Error creating slice template:', error);
showNotification('Failed to create slice template', 'error');
} finally {
// Reset button state
if (createBtn) {
createBtn.disabled = false;
createBtn.innerHTML = 'Save Configuration';
}
}
}
/**
* Reset slice configuration form
*/
function resetSliceConfigurationForm() {
const form = document.querySelector('#configureSliceModal form');
if (form) {
form.reset();
}
}
/**
* Load resource_provider data from API
*/
async function loadResourceProviderData() {
try {
const data = await window.apiJson('/api/dashboard/resource_provider-data');
console.log('🚜 Loaded resource_provider data:', data);
// Load node groups as well
const groupsResult = await window.apiJson('/api/dashboard/node-groups');
const nodeGroups = (groupsResult && Array.isArray(groupsResult.groups)) ? groupsResult.groups : (Array.isArray(groupsResult) ? groupsResult : []);
data.nodeGroups = nodeGroups;
console.log('🚜 Node groups loaded:', nodeGroups);
// Store resource_provider data globally for slice status checking
window.resourceProviderData = data;
// Update dashboard stats
updateDashboardStats(data);
// Update nodes table
updateNodesTable(data.nodes || []);
// Update node groups
updateNodeGroups(data.nodeGroups || []);
// Reload slice templates to update status based on new node data
loadSliceTemplates();
} catch (error) {
console.error('🚜 Error loading resource_provider data:', error);
showNotification('Failed to load resource_provider data', 'error');
}
}
/**
* Load slice templates (default formats + custom slice products)
*/
async function loadSliceTemplates() {
try {
console.log('🍰 Loading slice templates');
// Load default slice formats
const defaultFormats = await window.apiJson('/api/dashboard/default-slice-formats');
// Load user's custom slice products
const sliceProducts = await window.apiJson('/api/dashboard/slice-products');
console.log('🍰 Loaded slice templates:', { defaultFormats, sliceProducts });
// Update slice templates table
updateSliceTemplatesTable(defaultFormats, sliceProducts);
} catch (error) {
console.error('🍰 Error loading slice templates:', error);
showSliceTemplatesError();
}
}
/**
* Update slice templates table
*/
function updateSliceTemplatesTable(defaultFormats, sliceProducts) {
const tbody = document.getElementById('slice-templates-table');
if (!tbody) return;
tbody.innerHTML = '';
// Add default slice formats
defaultFormats.forEach(format => {
// Determine status based on whether any nodes are using this slice format
const status = getSliceFormatStatus(format.id);
const statusBadge = status === 'Active' ?
'<span class="badge bg-success">Active</span>' :
'<span class="badge bg-secondary">Available</span>';
const row = document.createElement('tr');
row.innerHTML = `
<td>
<strong>${format.name}</strong>
<br><small class="text-muted">Default Format</small>
</td>
<td>
${format.cpu_cores} vCPU, ${format.memory_gb}GB RAM<br>
${format.storage_gb}GB Storage, ${format.bandwidth_mbps}Mbps
</td>
<td>99.0%</td>
<td>${format.bandwidth_mbps}Mbps</td>
<td>0</td>
<td><strong>${format.price_per_hour} TFP</strong><br><small class="text-muted">per hour</small></td>
<td>${statusBadge}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info" onclick="viewDefaultSliceDetails('${format.id}')" title="View Details">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-outline-primary" onclick="editDefaultSliceConfiguration('${format.id}')" title="Edit">
<i class="bi bi-pencil"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
// Add custom slice products
sliceProducts.forEach(product => {
const sliceConfig = product.attributes?.slice_configuration?.value;
if (!sliceConfig) return;
// Extract real pricing data from slice pricing structure
const slicePricing = product.attributes?.slice_pricing?.value;
let pricingDisplay = `<strong>${product.base_price} ${product.base_currency}</strong><br><small class="text-muted">per hour</small>`;
if (slicePricing) {
// Display comprehensive pricing if available
const hourly = slicePricing.hourly || product.base_price;
const daily = slicePricing.daily;
const monthly = slicePricing.monthly;
const yearly = slicePricing.yearly;
pricingDisplay = `
<div class="pricing-breakdown">
<strong>${hourly} ${product.base_currency}/hr</strong><br>
${daily ? `<small class="text-muted">${daily} ${product.base_currency}/day</small><br>` : ''}
${monthly ? `<small class="text-muted">${monthly} ${product.base_currency}/month</small><br>` : ''}
${yearly ? `<small class="text-muted">${yearly} ${product.base_currency}/year</small>` : ''}
</div>
`;
}
// Extract real resource specifications
const cpuCores = sliceConfig.cpu_cores || 0;
const memoryGb = sliceConfig.memory_gb || 0;
const storageGb = sliceConfig.storage_gb || 0;
const bandwidthMbps = sliceConfig.bandwidth_mbps || 0;
const publicIps = sliceConfig.public_ips || 0;
const minUptimeSla = sliceConfig.min_uptime_sla || 99.0;
// Determine status based on whether any nodes are using this slice format
const status = getSliceFormatStatus(product.id);
const statusBadge = status === 'Active' ?
'<span class="badge bg-success">Active</span>' :
'<span class="badge bg-secondary">Available</span>';
const row = document.createElement('tr');
row.innerHTML = `
<td>
<strong>${product.name}</strong>
<br><small class="text-muted">Custom Slice</small>
</td>
<td>
${cpuCores} vCPU, ${memoryGb}GB RAM<br>
${storageGb}GB Storage, ${bandwidthMbps}Mbps
</td>
<td>${minUptimeSla}%</td>
<td>${bandwidthMbps}Mbps</td>
<td>${publicIps}</td>
<td>${pricingDisplay}</td>
<td>${statusBadge}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info" onclick="viewSliceDetails('${product.id}')" title="View Details">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-outline-primary" onclick="editSliceConfiguration('${product.id}')" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteSliceConfiguration('${product.id}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
// If no templates, show empty state
if (defaultFormats.length === 0 && sliceProducts.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="8" class="text-center text-muted">
<div class="py-4">
<i class="bi bi-layers fs-1 text-muted"></i>
<p class="mt-2">No slice templates configured</p>
<p class="small">Create your first slice template to get started</p>
</div>
</td>
</tr>
`;
}
}
/**
* Show error in slice templates table
*/
function showSliceTemplatesError() {
const tbody = document.getElementById('slice-templates-table');
if (!tbody) return;
tbody.innerHTML = `
<tr>
<td colspan="8" class="text-center text-danger">
<div class="py-4">
<i class="bi bi-exclamation-triangle fs-1 text-danger"></i>
<p class="mt-2">Failed to load slice templates</p>
<button class="btn btn-sm btn-outline-primary" onclick="loadSliceTemplates()">
<i class="bi bi-arrow-clockwise me-1"></i> Retry
</button>
</div>
</td>
</tr>
`;
}
/**
* Get slice format status based on node usage
* Returns 'Active' if at least one node is using this slice format, 'Available' otherwise
*/
function getSliceFormatStatus(sliceFormatId) {
// Check if we have resource_provider data loaded
if (!window.resourceProviderData || !window.resourceProviderData.nodes) {
return 'Available'; // Default to Available if no data
}
// Check if any node has this slice format in their slice_formats array
const isUsedByAnyNode = window.resourceProviderData.nodes.some(node => {
return node.slice_formats &&
Array.isArray(node.slice_formats) &&
node.slice_formats.includes(sliceFormatId);
});
return isUsedByAnyNode ? 'Active' : 'Available';
}
/**
* Use a default slice format
*/
function useSliceFormat(formatId) {
console.log('🍰 Using slice format:', formatId);
showNotification(`Using ${formatId} slice format`, 'info');
// This would open the slice configuration modal with the format pre-selected
}
/**
* View slice configuration details
*/
async function viewSliceDetails(sliceId) {
console.log('🍰 Viewing slice details:', sliceId);
try {
const sliceData = await window.apiJson(`/api/dashboard/slice-details/${sliceId}`);
console.log('🍰 Loaded slice details:', sliceData);
// Show slice details modal
showSliceDetailsModal(sliceData);
} catch (error) {
console.error('🍰 Error loading slice details:', error);
showNotification('Failed to load slice details', 'error');
}
}
/**
* View default slice details
*/
async function viewDefaultSliceDetails(formatId) {
console.log('🍰 Viewing default slice details for:', formatId);
try {
const formatData = await window.apiJson(`/api/dashboard/default-slice-details/${formatId}`);
console.log('🍰 Loaded default slice details:', formatData);
// Convert default slice format to slice data format for reuse
const sliceData = {
id: formatData.id,
name: formatData.name,
description: formatData.description,
provider_name: "Mycelium (Default)",
base_price: formatData.price_per_hour,
slice_configuration: {
cpu_cores: formatData.cpu_cores,
memory_gb: formatData.memory_gb,
storage_gb: formatData.storage_gb,
bandwidth_mbps: formatData.bandwidth_mbps,
public_ips: 0,
min_uptime_sla: 99.0,
pricing: {
hourly: formatData.price_per_hour,
daily: formatData.price_per_hour * 24,
monthly: formatData.price_per_hour * 24 * 30,
yearly: formatData.price_per_hour * 24 * 365
}
},
isDefault: true
};
showSliceDetailsModal(sliceData);
} catch (error) {
console.error('🍰 Error loading default slice details:', error);
showNotification('Failed to load default slice details', 'error');
}
}
/**
* Edit default slice configuration
*/
async function editDefaultSliceConfiguration(formatId) {
console.log('🍰 Editing default slice configuration:', formatId);
try {
const formatData = await window.apiJson(`/api/dashboard/default-slice-details/${formatId}`);
console.log('🍰 Loaded default slice for editing:', formatData);
// Convert default slice format to slice data format for reuse
const sliceData = {
id: formatData.id,
name: formatData.name,
description: formatData.description,
provider_name: "Mycelium (Default)",
base_price: formatData.price_per_hour,
attributes: {
slice_configuration: {
value: {
cpu_cores: formatData.cpu_cores,
memory_gb: formatData.memory_gb,
storage_gb: formatData.storage_gb,
bandwidth_mbps: formatData.bandwidth_mbps,
public_ips: 0,
min_uptime_sla: 99.0,
pricing: {
hourly: formatData.price_per_hour,
daily: formatData.price_per_hour * 24,
monthly: formatData.price_per_hour * 24 * 30,
yearly: formatData.price_per_hour * 24 * 365
}
}
}
},
isDefault: true
};
showSliceEditModal(sliceData);
} catch (error) {
console.error('🍰 Error loading default slice for editing:', error);
showNotification('Failed to load default slice configuration', 'error');
}
}
/**
* Edit slice configuration
*/
async function editSliceConfiguration(sliceId) {
console.log('🍰 Editing slice configuration:', sliceId);
try {
const sliceData = await window.apiJson(`/api/dashboard/slice-details/${sliceId}`);
console.log('🍰 Loaded slice for editing:', sliceData);
// Show slice edit modal
showSliceEditModal(sliceData);
} catch (error) {
console.error('🍰 Error loading slice for editing:', error);
showNotification('Failed to load slice configuration', 'error');
}
}
/**
* Delete slice configuration
*/
async function deleteSliceConfiguration(sliceId) {
console.log('🍰 Deleting slice configuration:', sliceId);
// Store slice ID for confirmation
window.pendingDeleteSliceId = sliceId;
// Get slice name for display
try {
const sliceData = await window.apiJson(`/api/dashboard/slice-details/${sliceId}`);
document.getElementById('deleteSliceName').textContent = sliceData.name || 'Unknown Slice';
} catch (error) {
document.getElementById('deleteSliceName').textContent = 'Unknown Slice';
}
// Show custom confirmation modal
const deleteModal = new bootstrap.Modal(document.getElementById('deleteSliceModal'));
deleteModal.show();
}
/**
* Confirm slice deletion (called from modal)
*/
async function confirmSliceDeletion() {
const sliceId = window.pendingDeleteSliceId;
console.log('🍰 confirmSliceDeletion called with sliceId:', sliceId);
if (!sliceId) {
console.error('🍰 No pending delete slice ID found');
showNotification('Error: No slice selected for deletion', 'error');
return;
}
const confirmBtn = document.getElementById('confirmDeleteSliceBtn');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Deleting...';
}
try {
console.log('🍰 Sending DELETE request to:', `/api/dashboard/slice-products/${sliceId}`);
const result = await window.apiJson(`/api/dashboard/slice-products/${sliceId}`, {
method: 'DELETE'
});
console.log('🍰 Slice configuration deleted successfully:', result);
showNotification('Slice configuration deleted successfully', 'success');
// Hide modal
const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deleteSliceModal'));
if (deleteModal) {
deleteModal.hide();
}
// Reload slice templates with small delay for backend processing
setTimeout(() => {
loadSliceTemplates();
}, 100);
} catch (error) {
console.error('🍰 Error deleting slice configuration:', error);
showNotification(`Failed to delete slice configuration: ${error.message}`, 'error');
} finally {
// Reset button state
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="bi bi-trash me-2"></i>Delete Slice Configuration';
}
// Clear pending ID
window.pendingDeleteSliceId = null;
}
}
/**
* Edit slice product (legacy function for compatibility)
*/
function editSliceProduct(productId) {
editSliceConfiguration(productId);
}
/**
* Delete slice product (legacy function for compatibility)
*/
async function deleteSliceProduct(productId) {
await deleteSliceConfiguration(productId);
}
/**
* Update dashboard statistics
*/
function updateDashboardStats(data) {
// Update active nodes count
const activeNodesCount = document.getElementById('active-nodes-count');
if (activeNodesCount && data.nodes) {
const activeNodes = data.nodes.filter(node => node.status === 'Online').length;
activeNodesCount.textContent = activeNodes;
}
// Update active slices count
const activeSlicesCount = document.getElementById('active-slices-count');
if (activeSlicesCount && data.slice_stats) {
activeSlicesCount.textContent = data.slice_stats.active_slices || 0;
}
// Update uptime percentage
const uptimePercentage = document.getElementById('uptime-percentage');
if (uptimePercentage && data.uptime_stats) {
uptimePercentage.textContent = `${data.uptime_stats.average_uptime || 99.0}%`;
}
// Update monthly earnings
const monthlyEarnings = document.getElementById('monthly-earnings');
if (monthlyEarnings && data.earnings_stats) {
monthlyEarnings.textContent = data.earnings_stats.monthly_earnings || 0;
}
}
/**
* Format node specifications for display
*/
function formatNodeSpecifications(resources) {
// Add debugging and null checks
console.log('🔍 formatNodeSpecifications called with:', resources);
if (!resources) {
console.error('❌ Resources is null or undefined');
return 'No resource data available';
}
const vcores = resources.cpu_cores || 0;
const memoryGB = resources.memory_gb || 0;
const ssdTB = ((resources.ssd_storage_gb || 0) / 1000).toFixed(1);
const hddTB = ((resources.hdd_storage_gb || 0) / 1000).toFixed(1);
console.log('🔍 Parsed values:', { vcores, memoryGB, ssdTB, hddTB });
let specs = `${vcores} vCores, ${memoryGB} GB RAM`;
if (resources.ssd_storage_gb > 0) {
specs += `, ${ssdTB} TB SSD`;
}
if (resources.hdd_storage_gb > 0) {
specs += `, ${hddTB} TB HDD`;
}
console.log('🔍 Final specs:', specs);
return specs;
}
/**
* Format legacy node specifications for backward compatibility
*/
function formatLegacyNodeSpecifications(node) {
const vcores = node.capacity?.cpu_cores || node.cpu_cores || 0;
const memoryGB = node.capacity?.memory_gb || node.ram_gb || node.memory_gb || 0;
const storageGB = node.capacity?.storage_gb || node.storage_gb || 0;
const storageTB = (storageGB / 1000).toFixed(1);
return `${vcores} vCores, ${memoryGB} GB RAM, ${storageTB} TB Storage`;
}
/**
* Format GridProxy raw resources for preview display
*/
function formatGridProxyResources(resources) {
const vcores = resources.cru;
const memoryGB = Math.round(resources.mru / (1024 * 1024 * 1024));
const ssdTB = (resources.sru / (1024 * 1024 * 1024 * 1024)).toFixed(1);
const hddTB = (resources.hru / (1024 * 1024 * 1024 * 1024)).toFixed(1);
let specs = `${vcores} vCores, ${memoryGB} GB RAM`;
if (resources.sru > 0) {
specs += `, ${ssdTB} TB SSD`;
}
if (resources.hru > 0) {
specs += `, ${hddTB} TB HDD`;
}
return specs;
}
/**
* Update nodes table
*/
function updateNodesTable(nodes) {
const tbody = document.getElementById('nodes-table-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (nodes.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="text-center text-muted">
<div class="py-4">
<i class="bi bi-server fs-1 text-muted"></i>
<p class="mt-2">No nodes added yet</p>
<p class="small">Click "Add Nodes" to get started</p>
</div>
</td>
</tr>
`;
return;
}
nodes.forEach(node => {
const row = document.createElement('tr');
// Enhanced status handling with real grid data
let nodeStatus = node.status || 'Unknown';
let statusBadge = getStatusBadge(nodeStatus);
// If we have grid data, use the grid status
if (node.grid_data && node.grid_data.status) {
nodeStatus = node.grid_data.status;
statusBadge = getStatusBadge(nodeStatus);
}
// Group info with enhanced display - get actual group name from loaded groups
let groupInfo = '<span class="badge bg-secondary">Single</span>';
if (node.node_group_id && window.resourceProviderData && window.resourceProviderData.nodeGroups) {
const group = window.resourceProviderData.nodeGroups.find(g => g.id === node.node_group_id);
if (group) {
groupInfo = `<span class="badge bg-info">${group.name}</span>`;
}
}
// Enhanced specifications with real grid proxy data
let specs = 'No data available';
if (node.grid_data && node.grid_data.total_resources) {
// Use real grid proxy data
specs = formatNodeSpecifications(node.grid_data.total_resources);
} else if (node.capacity) {
// Use stored capacity data
specs = formatLegacyNodeSpecifications(node);
} else {
// Fallback to basic node properties
const vcores = node.cpu_cores || 0;
const memoryGB = node.memory_gb || node.ram_gb || 0;
const storageGB = node.storage_gb || 0;
specs = `${vcores} vCores, ${memoryGB} GB RAM, ${(storageGB/1000).toFixed(1)} TB Storage`;
}
// Enhanced farm and location with real grid data
let farmLocation = 'Unknown Location';
// Debug logging to understand the node structure
console.log('🔍 Node data for location formatting:', {
node_id: node.id,
grid_data: node.grid_data,
location: node.location,
country: node.country
});
if (node.grid_data) {
const farmName = node.grid_data.farm_name || 'Unknown Farm';
const city = node.grid_data.city || node.grid_data.location?.city || 'Unknown';
const country = node.grid_data.country || node.grid_data.location?.country || 'Unknown';
// Use the correct logic: if city is "Unknown", show only country
const locationText = (city === 'Unknown') ? country : `${city}, ${country}`;
farmLocation = `${farmName}<br><small class="text-muted">${locationText}</small>`;
console.log('📍 Using grid_data:', {
farmName,
city,
country,
locationText,
raw_grid_data: {
farm_name: node.grid_data.farm_name,
city: node.grid_data.city,
country: node.grid_data.country,
location: node.grid_data.location
}
});
} else if (node.location || node.country) {
// Parse the location string to extract city and country
let city = 'Unknown';
let country = node.country || 'Unknown Country';
let farmName = 'Unknown Farm';
if (node.location && node.location.includes(',')) {
const parts = node.location.split(',').map(part => part.trim());
if (parts.length >= 2) {
city = parts[0];
country = parts[1];
}
const locationText = (city === 'Unknown') ? country : `${city}, ${country}`;
farmLocation = `${farmName}<br><small class="text-muted">${locationText}</small>`;
console.log('📍 Parsed location with comma:', { city, country, locationText });
} else if (node.location) {
// If location doesn't contain comma, it might be the farm name or full location
farmName = node.location;
const locationText = (city === 'Unknown') ? country : `${city}, ${country}`;
farmLocation = `${farmName}<br><small class="text-muted">${locationText}</small>`;
console.log('📍 Using location as farm name:', { farmName, city, country, locationText });
} else {
const locationText = (city === 'Unknown') ? country : `${city}, ${country}`;
farmLocation = `${farmName}<br><small class="text-muted">${locationText}</small>`;
console.log('📍 Using defaults:', { farmName, city, country, locationText });
}
}
// Enhanced certification with real grid data
let certificationBadge = '<span class="text-muted">Unknown</span>';
if (node.grid_data && node.grid_data.certification_type) {
const certType = node.grid_data.certification_type;
const badgeClass = certType === 'Certified' ? 'bg-success' : 'bg-warning';
certificationBadge = `<span class="badge ${badgeClass}">${certType}</span>`;
} else if (node.certification_type) {
const badgeClass = node.certification_type === 'Certified' ? 'bg-success' : 'bg-warning';
certificationBadge = `<span class="badge ${badgeClass}">${node.certification_type}</span>`;
}
// Enhanced utilization calculation
let utilization = 0;
if (node.grid_data && node.grid_data.used_resources && node.grid_data.total_resources) {
// Calculate real utilization from grid data
const used = node.grid_data.used_resources;
const total = node.grid_data.total_resources;
const cpuUtil = total.cru > 0 ? (used.cru / total.cru) * 100 : 0;
const memUtil = total.mru > 0 ? (used.mru / total.mru) * 100 : 0;
utilization = Math.round((cpuUtil + memUtil) / 2);
} else if (node.utilization !== undefined) {
utilization = node.utilization;
}
// Enhanced earnings display with real data
let earningsDisplay = '<span class="text-muted">No data</span>';
if (node.monthly_earnings && node.monthly_earnings > 0) {
earningsDisplay = `<strong>${node.monthly_earnings} TFP</strong><br><small class="text-muted">This month</small>`;
} else if (node.total_earnings && node.total_earnings > 0) {
earningsDisplay = `<strong>${node.total_earnings} TFP</strong><br><small class="text-muted">Total earned</small>`;
}
// Grid Node ID display with enhanced formatting
const nodeIdDisplay = node.grid_node_id ?
`<strong>${node.grid_node_id}</strong><br><small class="text-muted">Grid Node</small>` :
`<strong>${node.id}</strong><br><small class="text-muted">Local Node</small>`;
// Staking information
let stakingInfo = '<span class="badge bg-light text-muted">Not Staked</span>';
if (node.staking_options && node.staking_options.staking_enabled) {
const stakedAmount = node.staking_options.staked_amount || 0;
stakingInfo = `<span class="badge bg-success">${stakedAmount} TFP</span><br><small class="text-muted">Staked for slashing protection</small>`;
}
// SLA & Pricing information - should always be available from saved data
let slaInfo = '<span class="text-muted">No data</span>';
if (node.marketplace_sla) {
const sla = node.marketplace_sla;
// Format uptime to 1 decimal place
const formattedUptime = parseFloat(sla.uptime_guarantee_percentage).toFixed(1);
slaInfo = `
<div class="sla-info">
<strong>${sla.base_slice_price} TFP/hr</strong><br>
<small class="text-muted">${formattedUptime}% uptime</small><br>
<small class="text-muted">${sla.bandwidth_guarantee_mbps} Mbps</small>
</div>
`;
} else if (node.slice_pricing && node.slice_pricing.base_price_per_hour) {
// Fallback to slice_pricing if marketplace_sla is missing
const formattedUptime = parseFloat(node.uptime_percentage || 99.8).toFixed(1);
const bandwidth = node.capacity ? node.capacity.bandwidth_mbps : 100;
slaInfo = `
<div class="sla-info">
<strong>${node.slice_pricing.base_price_per_hour} TFP/hr</strong><br>
<small class="text-muted">${formattedUptime}% uptime</small><br>
<small class="text-muted">${bandwidth} Mbps</small>
</div>
`;
} else {
// Final fallback - this should not happen if data is properly saved
console.warn('🚨 Node missing both marketplace_sla and slice_pricing:', node.id);
const formattedUptime = parseFloat(node.uptime_percentage || 99.8).toFixed(1);
const bandwidth = node.capacity ? node.capacity.bandwidth_mbps : 100;
slaInfo = `
<div class="sla-info">
<strong>0.5 TFP/hr</strong><br>
<small class="text-muted">${formattedUptime}% uptime</small><br>
<small class="text-muted">${bandwidth} Mbps</small>
</div>
`;
}
row.innerHTML = `
<td>${nodeIdDisplay}</td>
<td>${farmLocation}</td>
<td>${specs}</td>
<td>${slaInfo}</td>
<td>${certificationBadge}</td>
<td>${statusBadge}</td>
<td>${groupInfo}</td>
<td>${stakingInfo}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info" onclick="viewNodeDetails('${node.id}')" title="View Details">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteNodeConfiguration('${node.id}')" title="Remove Node">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
/**
* Get staking action buttons for a node
*/
function getStakingActionButtons(node) {
if (node.staking_options && node.staking_options.staking_enabled) {
// Node is staked - show update and unstake buttons
return `
<button class="btn btn-outline-warning" data-action="update-stake" data-node-id="${node.id}" title="Update Stake">
<i class="bi bi-pencil-square"></i>
</button>
<button class="btn btn-outline-secondary" data-action="unstake-node" data-node-id="${node.id}" title="Unstake">
<i class="bi bi-shield-x"></i>
</button>
`;
} else {
// Node is not staked - show stake button
return `
<button class="btn btn-outline-success" data-action="stake-node" data-node-id="${node.id}" title="Stake TFP">
<i class="bi bi-shield-check"></i>
</button>
`;
}
}
/**
* Get status badge HTML
*/
function getStatusBadge(status) {
const statusMap = {
'Online': 'bg-success',
'Offline': 'bg-danger',
'Maintenance': 'bg-warning',
'Standby': 'bg-secondary'
};
const badgeClass = statusMap[status] || 'bg-secondary';
return `<span class="badge ${badgeClass}">${status}</span>`;
}
/**
* Update node groups display
*/
function updateNodeGroups(groups) {
console.log('🌐 Updating node groups:', groups);
// This would update the node groups section
// Implementation depends on the specific UI requirements
}
/**
* Validate grid nodes
*/
async function validateGridNodes() {
console.log('🌐 Validating grid nodes');
const validateBtn = document.getElementById('validateNodesBtn');
const addBtn = document.getElementById('addNodesBtn');
const nodePreview = document.getElementById('nodePreview');
const nodePreviewContent = document.getElementById('nodePreviewContent');
// Get node IDs
const nodeIds = getNodeIdsFromInput();
if (nodeIds.length === 0) {
showNotification('Please enter at least one node ID', 'warning');
return;
}
// Guard duplicate submits and show loading state
if (!validateBtn) {
console.error('Missing validateNodesBtn element');
return;
}
if (validateBtn.dataset.inFlight === 'true') {
console.log('⚠️ Validation already in progress, ignoring duplicate call');
return;
}
validateBtn.dataset.inFlight = 'true';
validateBtn.disabled = true;
validateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Fetching...';
try {
const result = await window.apiJson('/api/dashboard/grid-nodes/validate', {
method: 'POST',
body: {
node_ids: nodeIds
}
});
console.log('🌐 Validation result:', result);
// Handle errors first (including duplicates)
if (result.errors.length > 0) {
const duplicateErrors = result.errors.filter(e => e.error.includes('already registered'));
const otherErrors = result.errors.filter(e => !e.error.includes('already registered'));
if (duplicateErrors.length > 0) {
const duplicateMsg = duplicateErrors.map(e => `Node ${e.node_id}: ${e.error}`).join('\n');
showNotification(`Duplicate nodes detected:\n${duplicateMsg}`, 'error');
}
if (otherErrors.length > 0) {
const errorMsg = otherErrors.map(e => `Node ${e.node_id}: ${e.error}`).join('\n');
showNotification(`Some nodes failed validation:\n${errorMsg}`, 'warning');
}
}
if (result.validated_nodes.length > 0) {
// Show node preview
displayNodePreview(result.validated_nodes);
nodePreview.style.display = 'block';
// Show add button
addBtn.style.display = 'inline-block';
showNotification(`Successfully validated ${result.validated_nodes.length} nodes`, 'success');
} else if (result.validated_nodes.length === 0 && result.errors.length === 0) {
showNotification('No valid nodes found', 'error');
nodePreview.style.display = 'none';
addBtn.style.display = 'none';
} else if (result.validated_nodes.length === 0) {
// All nodes had errors (duplicates or validation failures)
nodePreview.style.display = 'none';
addBtn.style.display = 'none';
}
} catch (error) {
console.error('🌐 Error validating nodes:', error);
showNotification('Failed to validate nodes', 'error');
nodePreview.style.display = 'none';
addBtn.style.display = 'none';
} finally {
// Reset button state
delete validateBtn.dataset.inFlight;
validateBtn.disabled = false;
validateBtn.innerHTML = 'Fetch Nodes';
}
}
/**
* Get node IDs from input fields
*/
function getNodeIdsFromInput() {
const singleNodeMode = document.getElementById('singleNodeMode');
const nodeIds = [];
if (singleNodeMode && singleNodeMode.checked) {
// Single node mode
const nodeIdInput = document.getElementById('gridNodeId');
if (nodeIdInput && nodeIdInput.value.trim()) {
const nodeId = parseInt(nodeIdInput.value.trim());
if (!isNaN(nodeId) && nodeId > 0) {
nodeIds.push(nodeId);
}
}
} else {
// Multiple nodes mode
const nodeIdsInput = document.getElementById('gridNodeIds');
if (nodeIdsInput && nodeIdsInput.value.trim()) {
const input = nodeIdsInput.value.trim();
// Split by commas or newlines and parse
const rawIds = input.split(/[\,\n\r]+/);
for (const rawId of rawIds) {
const nodeId = parseInt(rawId.trim());
if (!isNaN(nodeId) && nodeId > 0) {
nodeIds.push(nodeId);
}
}
}
}
return [...new Set(nodeIds)]; // Remove duplicates
}
/**
* Add grid nodes
*/
async function addGridNodes() {
console.log('🌐 Adding grid nodes');
const addBtn = document.getElementById('addNodesBtn');
const modal = document.getElementById('addNodeModal');
// Get node IDs
const nodeIds = getNodeIdsFromInput();
if (nodeIds.length === 0) {
showNotification('Please validate nodes first', 'warning');
return;
}
// Get group configuration
const groupConfig = getGroupConfiguration();
// Get slice configurations for each node
const sliceConfigs = {};
nodeIds.forEach(nodeId => {
const sliceSelect = document.getElementById(`sliceFormat_${nodeId}`);
if (sliceSelect) {
sliceConfigs[nodeId] = sliceSelect.value;
}
});
// Guard duplicate submits and show loading state
if (!addBtn) {
console.error('Missing addNodesBtn element');
return;
}
if (addBtn.dataset.inFlight === 'true') {
console.log('⚠️ Node addition already in progress, ignoring duplicate call');
return;
}
addBtn.dataset.inFlight = 'true';
addBtn.disabled = true;
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding Nodes...';
try {
// Get slice configuration from new selection system
const sliceConfiguration = getSelectedSliceConfiguration();
// Get selected node group from dropdown
const nodeGroupSelect = document.getElementById('nodeGroup');
const selectedGroupId = nodeGroupSelect ? nodeGroupSelect.value : '';
// Collect full node pricing configuration
const fullNodePricingData = collectFullNodePricingData();
const multiNodePricingConfig = getMultiNodePricingConfig();
// Collect staking configuration
const stakingConfiguration = getStakingConfiguration();
const requestData = {
node_ids: nodeIds,
slice_configuration: sliceConfiguration,
full_node_rental_config: fullNodePricingData,
multi_node_pricing: multiNodePricingConfig,
staking_configuration: stakingConfiguration
};
// Add group assignment if a group is selected
if (selectedGroupId) {
requestData.node_group_id = selectedGroupId;
console.log('🌐 Assigning nodes to group:', selectedGroupId);
}
// Add group configuration if provided (for creating new groups)
if (groupConfig) {
requestData.group_config = groupConfig;
}
console.log('🏢 Full node rental config:', fullNodePricingData);
console.log('🏢 Multi-node pricing config:', multiNodePricingConfig);
const result = await window.apiJson('/api/dashboard/grid-nodes/add', {
method: 'POST',
body: requestData
});
console.log('🌐 Add nodes result:', result);
{
const addedCount = Array.isArray(result?.added_nodes) ? result.added_nodes.length : (parseInt(result?.added, 10) || 0);
showNotification(addedCount > 0 ? `Successfully added ${addedCount} nodes` : 'Nodes added successfully', 'success');
// Close modal
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) {
modalInstance.hide();
}
// Reset form
resetAddNodeForm();
// Reload resource_provider data and node groups with small delay for backend processing
setTimeout(async () => {
await loadResourceProviderData();
await loadNodeGroups(); // FARMER FIX: Refresh node groups table after adding new nodes
await loadWalletBalance(); // Refresh wallet balance after staking
await updateStakingDisplay(); // Refresh staking statistics after staking
}, 100);
}
} catch (error) {
console.error('🌐 Error adding nodes:', error);
showNotification('Failed to add nodes', 'error');
} finally {
// Reset button state
delete addBtn.dataset.inFlight;
addBtn.disabled = false;
addBtn.innerHTML = 'Add Nodes';
}
}
/**
* Reset Add Nodes form and UI state
*/
function resetAddNodeForm() {
try {
// Clear node ID inputs
const nodeIdInput = document.getElementById('gridNodeId');
const nodeIdsInput = document.getElementById('gridNodeIds');
if (nodeIdInput) nodeIdInput.value = '';
if (nodeIdsInput) nodeIdsInput.value = '';
// Hide preview and clear content
const nodePreview = document.getElementById('nodePreview');
const nodePreviewContent = document.getElementById('nodePreviewContent');
if (nodePreview) nodePreview.style.display = 'none';
if (nodePreviewContent) nodePreviewContent.innerHTML = '';
// Hide Add Nodes button
const addBtn = document.getElementById('addNodesBtn');
if (addBtn) addBtn.style.display = 'none';
// Reset validation button
const validateBtn = document.getElementById('validateNodesBtn');
if (validateBtn) {
delete validateBtn.dataset.inFlight;
validateBtn.disabled = false;
validateBtn.innerHTML = 'Fetch Nodes';
}
// Reset group creation toggle and fields
const createGroupToggle = document.getElementById('createGroupToggle');
if (createGroupToggle) createGroupToggle.checked = false;
const groupName = document.getElementById('groupName');
const groupDescription = document.getElementById('groupDescription');
const groupSliceFormat = document.getElementById('groupSliceFormat');
const groupSlicePrice = document.getElementById('groupSlicePrice');
if (groupName) groupName.value = '';
if (groupDescription) groupDescription.value = '';
if (groupSliceFormat) groupSliceFormat.selectedIndex = 0;
if (groupSlicePrice) groupSlicePrice.value = '';
// Reset group dropdowns
const nodeGroup = document.getElementById('nodeGroup');
const existingGroup = document.getElementById('existingGroup');
if (nodeGroup) nodeGroup.selectedIndex = 0;
if (existingGroup) existingGroup.selectedIndex = 0;
} catch (e) {
console.warn('resetAddNodeForm encountered a non-fatal error:', e);
}
}
function getGroupConfiguration() {
const createGroupToggle = document.getElementById('createGroupToggle');
if (!createGroupToggle || !createGroupToggle.checked) {
return null;
}
const groupName = document.getElementById('groupName');
const groupDescription = document.getElementById('groupDescription');
const groupSliceFormat = document.getElementById('groupSliceFormat');
const groupSlicePrice = document.getElementById('groupSlicePrice');
if (!groupName || !groupName.value.trim()) {
return null;
}
return {
name: groupName.value.trim(),
description: groupDescription ? groupDescription.value.trim() : '',
slice_format: groupSliceFormat ? groupSliceFormat.value : 'performance',
slice_price: groupSlicePrice ? groupSlicePrice.value : '100'
};
}
/**
* Load existing groups for selection
*/
async function loadExistingGroups() {
try {
// Use detailed API for consistent shape and stats
const result = await window.apiJson('/api/dashboard/node-groups/api', { cache: 'no-store' });
const entries = (result && Array.isArray(result.groups)) ? result.groups : (Array.isArray(result) ? result : []);
const existingGroupSelect = document.getElementById('existingGroup');
if (existingGroupSelect) {
existingGroupSelect.innerHTML = '<option value="">Select an existing group...</option>';
const { defaults, customs } = normalizeGroups(entries);
const append = ({ group, stats }) => {
const total = stats && typeof stats.total_nodes === 'number' ? stats.total_nodes : (group.nodes ? group.nodes.length : undefined);
const isCustom = group.group_type && ((typeof group.group_type === 'string' && group.group_type === 'Custom') || (typeof group.group_type === 'object' && group.group_type.Custom));
const option = document.createElement('option');
option.value = group.id;
option.textContent = `${group.name}${isCustom ? ' (Custom)' : ''}${typeof total === 'number' ? ` (${total} nodes)` : ''}`;
existingGroupSelect.appendChild(option);
};
defaults.forEach(append);
customs.forEach(append);
}
} catch (error) {
console.error('🌐 Error loading existing groups:', error);
}
}
// Stale-guard for node details fetches
// Guard against redeclaration - use window scope to persist across reloads
window.__nodeDetailsSeq = window.__nodeDetailsSeq || 0;
let __nodeDetailsSeq = window.__nodeDetailsSeq;
/**
* View node details
*/
async function viewNodeDetails(nodeId) {
console.log('👁️ Viewing node details:', nodeId);
try {
// Abort any in-flight request to prevent stale data
try { if (window.__nodeDetailsController) { window.__nodeDetailsController.abort(); } } catch (_) {}
window.__nodeDetailsController = (typeof AbortController !== 'undefined') ? new AbortController() : null;
// Sequence guard to avoid race conditions
const requestId = ++window.__nodeDetailsSeq;
const nodeData = await window.apiJson(`/api/dashboard/farm-nodes/${nodeId}`, {
cache: 'no-store',
signal: window.__nodeDetailsController ? window.__nodeDetailsController.signal : undefined
});
// If a newer request started, ignore this response
if (requestId !== window.__nodeDetailsSeq) return;
console.log('👁️ Loaded node details:', nodeData);
showNodeDetailsModal(nodeData);
} catch (error) {
console.error('👁️ Error loading node details:', error);
showNotification('Failed to load node details', 'error');
}
}
/**
* Delete node configuration (show confirmation modal)
*/
async function deleteNodeConfiguration(nodeId) {
console.log('🗑️ Initiating node deletion for nodeId:', nodeId);
if (!nodeId) {
console.error('🗑️ No node ID provided for deletion');
showNotification('Error: No node ID provided', 'error');
return;
}
try {
// Fetch node data to get the node name for confirmation
console.log('🗑️ Fetching node data for deletion confirmation:', nodeId);
const nodeData = await window.apiJson(`/api/dashboard/farm-nodes/${nodeId}`, {
cache: 'no-store'
});
console.log('🗑️ Loaded node data for deletion:', nodeData);
// Set the pending delete node ID
window.pendingDeleteNodeId = nodeId;
// Update the modal with node information
const deleteNodeNameElement = document.getElementById('deleteNodeName');
if (deleteNodeNameElement) {
const nodeName = nodeData.name || `Node ${nodeId}`;
deleteNodeNameElement.textContent = nodeName;
}
// Show the delete confirmation modal
const deleteModal = new bootstrap.Modal(document.getElementById('deleteNodeModal'));
deleteModal.show();
} catch (error) {
console.error('🗑️ Error preparing node deletion:', error);
showNotification(`Failed to prepare node deletion: ${error.message}`, 'error');
}
}
/**
* Confirm node deletion (called from modal)
*/
async function confirmNodeDeletion() {
const nodeId = window.pendingDeleteNodeId;
console.log('🗑️ confirmNodeDeletion called with nodeId:', nodeId);
if (!nodeId) {
console.error('🗑️ No pending delete node ID found');
showNotification('Error: No node selected for deletion', 'error');
return;
}
const confirmBtn = document.getElementById('confirmDeleteNodeBtn');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Removing...';
}
try {
console.log('🗑️ Sending DELETE request to:', `/api/dashboard/farm-nodes/${nodeId}`);
await window.apiJson(`/api/dashboard/farm-nodes/${nodeId}`, { method: 'DELETE' });
console.log('🗑️ Node deleted successfully');
showNotification('Node removed successfully', 'success');
// Hide modal
const deleteModal = bootstrap.Modal.getInstance(document.getElementById('deleteNodeModal'));
if (deleteModal) {
deleteModal.hide();
}
// Reload resource_provider data and node groups with small delay for backend processing
setTimeout(() => {
loadResourceProviderData();
loadNodeGroups(); // FARMER FIX: Refresh node groups table after node deletion
}, 100);
} catch (error) {
console.error('🗑️ Error deleting node:', error);
showNotification(`Failed to remove node: ${error.message}`, 'error');
} finally {
// Reset button state
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="bi bi-trash me-2"></i>Remove Node';
}
window.pendingDeleteNodeId = null;
}
}
/**
* Load node groups for edit modal
*/
async function loadNodeGroupsForEdit() {
try {
const result = await window.apiJson('/api/dashboard/node-groups/api');
const groupsArray = (result && Array.isArray(result.groups)) ? result.groups : (Array.isArray(result) ? result : []);
const groupSelect = document.getElementById('nodeGroupSelect');
if (groupSelect) {
// Clear existing options and use "Single" to match the table display
groupSelect.innerHTML = '<option value="">Single</option>';
groupsArray.forEach(groupData => {
const group = groupData.group || groupData;
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
groupSelect.appendChild(option);
});
console.log('🔧 Loaded groups for edit modal:', groupsArray.length);
}
} catch (error) {
console.error('Error loading node groups for edit:', error);
}
}
/**
* Populate node edit form with current values
*
* @param {object} nodeData - Node data to populate the form with
*/
function populateNodeEditForm(nodeData) {
console.log('📝 Populating node edit form with data:', nodeData);
console.log('📝 Node slice formats:', nodeData.slice_formats);
console.log('📝 Node rental options:', nodeData.rental_options);
// Set group assignment toggle and selection
const assignToGroupToggle = document.getElementById('assignToGroupToggle');
const groupSelectionSection = document.getElementById('groupSelectionSection');
const groupSelect = document.getElementById('nodeGroupSelect');
if (nodeData.node_group_id) {
// Node is in a group
if (assignToGroupToggle) assignToGroupToggle.checked = true;
if (groupSelectionSection) groupSelectionSection.style.display = 'block';
if (groupSelect) groupSelect.value = nodeData.node_group_id;
console.log('📝 Set node group assignment:', nodeData.node_group_id);
} else {
// Node is individual (Single)
if (assignToGroupToggle) assignToGroupToggle.checked = false;
if (groupSelectionSection) groupSelectionSection.style.display = 'none';
if (groupSelect) groupSelect.value = '';
console.log('📝 Set node as individual (no group)');
}
// Set slice rental enabled state
const sliceRentalCheckbox = document.getElementById('enableSliceRental');
if (sliceRentalCheckbox && nodeData.rental_options?.slice_rental_enabled !== undefined) {
sliceRentalCheckbox.checked = nodeData.rental_options.slice_rental_enabled;
console.log('📝 Set slice rental enabled:', nodeData.rental_options.slice_rental_enabled);
}
// Set slice format selections using the new card format
if (nodeData.slice_formats && Array.isArray(nodeData.slice_formats)) {
console.log('📝 Setting slice format selections for:', nodeData.slice_formats);
// Simple retry mechanism for slice format population
const populateSliceFormats = (attempt = 1) => {
console.log(`📝 Attempt ${attempt} to populate slice formats`);
// Debug: List all available checkboxes first
const allCheckboxes = document.querySelectorAll('.slice-format-checkbox');
console.log('📝 Available checkboxes:', Array.from(allCheckboxes).map(cb => cb.id));
console.log('📝 Looking for:', nodeData.slice_formats.map(f => `slice_${f}`));
let successCount = 0;
nodeData.slice_formats.forEach(formatId => {
// Look for the new checkbox format from createSliceFormatCard
const checkbox = document.getElementById(`slice_${formatId}`);
console.log(`📝 Looking for checkbox: slice_${formatId}, found:`, !!checkbox);
if (checkbox) {
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));
// Also add the selected class to the card
const card = checkbox.closest('.slice-format-card');
if (card) {
card.classList.add('selected');
console.log(`📝 Added selected class to card for ${formatId}`);
}
successCount++;
console.log(`📝 ✅ Successfully set checkbox for ${formatId}`);
} else {
console.warn(`📝 ❌ Checkbox not found for format: ${formatId}`);
}
});
console.log(`📝 Attempt ${attempt}: Successfully set ${successCount}/${nodeData.slice_formats.length} slice format selections`);
// If we didn't get all formats and this is our first attempt, try again after a short delay
if (successCount < nodeData.slice_formats.length && attempt === 1) {
console.log('📝 Retrying slice format population in 100ms...');
setTimeout(() => populateSliceFormats(2), 100);
} else if (successCount === 0) {
console.error('📝 ❌ Failed to set any slice format selections - elements may not be available');
}
};
populateSliceFormats();
// Try to populate slice pricing if available
if (nodeData.slice_prices || nodeData.group_slice_price) {
console.log('📝 Found slice pricing data:', {
slice_prices: nodeData.slice_prices,
group_slice_price: nodeData.group_slice_price
});
// If there's a group slice price, we could populate that
if (nodeData.group_slice_price) {
console.log('📝 Node has group slice price:', nodeData.group_slice_price);
// This would be used for group-level pricing
}
// If there are individual slice prices, populate those
if (nodeData.slice_prices && typeof nodeData.slice_prices === 'object') {
console.log('📝 Node has individual slice prices:', nodeData.slice_prices);
// This could be used to populate slice-specific pricing fields if they exist
Object.entries(nodeData.slice_prices).forEach(([sliceFormat, price]) => {
console.log(`📝 Slice ${sliceFormat} price: ${price} TFP`);
// Future: populate slice-specific pricing fields when they're added to the UI
});
}
}
} else {
console.log('📝 No slice formats to set or invalid format');
}
// Set full node rental settings and populate pricing data
const fullNodeRentalCheckbox = document.getElementById('enableFullNodeRental');
const fullNodePricingSection = document.getElementById('fullNodePricingSection');
console.log('📝 Full node rental data:', nodeData.rental_options);
console.log('📝 Full node rental enabled:', nodeData.rental_options?.full_node_rental_enabled);
if (nodeData.rental_options?.full_node_rental_enabled) {
console.log('📝 Setting full node rental settings:', nodeData.rental_options);
if (fullNodeRentalCheckbox) {
console.log('📝 Setting full node rental checkbox to checked');
fullNodeRentalCheckbox.checked = true;
// Manually show the pricing section since the checkbox is checked
if (fullNodePricingSection) {
console.log('📝 Showing full node pricing section');
fullNodePricingSection.style.display = 'block';
}
// Trigger the change event to ensure any other handlers are called
fullNodeRentalCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
}
// Populate pricing fields if available
if (nodeData.rental_options.full_node_pricing) {
const pricing = nodeData.rental_options.full_node_pricing;
console.log('📝 Populating pricing fields:', pricing);
// Populate pricing fields immediately (no timeout needed)
const populatePricingFields = () => {
// Populate hourly rate
const hourlyInput = document.getElementById('fullNodePriceHour') || document.getElementById('fullNodePriceHourNew');
if (hourlyInput && pricing.hourly !== undefined) {
hourlyInput.value = pricing.hourly;
console.log('📝 Set hourly rate:', pricing.hourly);
}
// Populate daily rate
const dailyInput = document.getElementById('fullNodePriceDay') || document.getElementById('fullNodePriceDayNew');
if (dailyInput && pricing.daily !== undefined) {
dailyInput.value = pricing.daily;
console.log('📝 Set daily rate:', pricing.daily);
}
// Populate monthly rate
const monthlyInput = document.getElementById('fullNodePriceMonth') || document.getElementById('fullNodePriceMonthNew');
if (monthlyInput && pricing.monthly !== undefined) {
monthlyInput.value = pricing.monthly;
console.log('📝 Set monthly rate:', pricing.monthly);
}
// Populate yearly rate
const yearlyInput = document.getElementById('fullNodePriceYear') || document.getElementById('fullNodePriceYearNew');
if (yearlyInput && pricing.yearly !== undefined) {
yearlyInput.value = pricing.yearly;
console.log('📝 Set yearly rate:', pricing.yearly);
}
// Populate discount percentages
const dailyDiscountInput = document.getElementById('fullNodeDailyDiscount');
if (dailyDiscountInput && pricing.daily_discount_percent !== undefined) {
dailyDiscountInput.value = pricing.daily_discount_percent;
console.log('📝 Set daily discount:', pricing.daily_discount_percent);
}
const monthlyDiscountInput = document.getElementById('fullNodeMonthlyDiscount');
if (monthlyDiscountInput && pricing.monthly_discount_percent !== undefined) {
monthlyDiscountInput.value = pricing.monthly_discount_percent;
console.log('📝 Set monthly discount:', pricing.monthly_discount_percent);
}
const yearlyDiscountInput = document.getElementById('fullNodeYearlyDiscount');
if (yearlyDiscountInput && pricing.yearly_discount_percent !== undefined) {
yearlyDiscountInput.value = pricing.yearly_discount_percent;
console.log('📝 Set yearly discount:', pricing.yearly_discount_percent);
}
};
populatePricingFields();
}
// Populate minimum rental duration
const minRentalDurationInput = document.getElementById('minRentalDuration');
if (minRentalDurationInput && nodeData.rental_options.minimum_rental_days) {
minRentalDurationInput.value = nodeData.rental_options.minimum_rental_days;
console.log('📝 Set minimum rental days:', nodeData.rental_options.minimum_rental_days);
}
// Set auto-renewal if available
const autoRenewalInput = document.getElementById('autoRenewalEnabled');
if (autoRenewalInput && nodeData.rental_options.auto_renewal_enabled !== undefined) {
autoRenewalInput.checked = nodeData.rental_options.auto_renewal_enabled;
console.log('📝 Set auto renewal:', nodeData.rental_options.auto_renewal_enabled);
}
} else {
console.log('📝 Full node rental is disabled for this node');
if (fullNodeRentalCheckbox) {
fullNodeRentalCheckbox.checked = false;
}
if (fullNodePricingSection) {
fullNodePricingSection.style.display = 'none';
}
}
// Set staking configuration
const editEnableStaking = document.getElementById('editEnableStaking');
const editStakingConfigSection = document.getElementById('editStakingConfigSection');
console.log('📝 Processing staking configuration for node:', nodeData.id);
console.log('📝 Node staking options:', nodeData.staking_options);
if (nodeData.staking_options && nodeData.staking_options.staking_enabled) {
console.log('📝 Setting staking configuration:', nodeData.staking_options);
if (editEnableStaking) {
editEnableStaking.checked = true;
console.log('📝 Staking checkbox enabled');
// Show staking configuration section
if (editStakingConfigSection) {
editStakingConfigSection.style.display = 'block';
console.log('📝 Staking configuration section shown');
}
}
// Populate staking fields
const editStakingAmount = document.getElementById('editStakingAmount');
if (editStakingAmount && nodeData.staking_options.staked_amount) {
editStakingAmount.value = nodeData.staking_options.staked_amount;
console.log('📝 Set staking amount:', nodeData.staking_options.staked_amount);
}
const editStakingPeriod = document.getElementById('editStakingPeriod');
if (editStakingPeriod && nodeData.staking_options.staking_period_months) {
editStakingPeriod.value = nodeData.staking_options.staking_period_months;
console.log('📝 Set staking period:', nodeData.staking_options.staking_period_months);
}
const editEarlyWithdrawalAllowed = document.getElementById('editEarlyWithdrawalAllowed');
if (editEarlyWithdrawalAllowed && nodeData.staking_options.early_withdrawal_allowed !== undefined) {
editEarlyWithdrawalAllowed.checked = nodeData.staking_options.early_withdrawal_allowed;
console.log('📝 Set early withdrawal allowed:', nodeData.staking_options.early_withdrawal_allowed);
}
// Show staking status in UI
showNotification(`Node has ${nodeData.staking_options.staked_amount} TFP staked`, 'info');
} else {
console.log('📝 No staking configuration for this node - setting defaults');
if (editEnableStaking) {
editEnableStaking.checked = false;
console.log('📝 Staking checkbox disabled');
}
if (editStakingConfigSection) {
editStakingConfigSection.style.display = 'none';
console.log('📝 Staking configuration section hidden');
}
// Clear staking form fields
const editStakingAmount = document.getElementById('editStakingAmount');
if (editStakingAmount) {
editStakingAmount.value = '';
}
const editStakingPeriod = document.getElementById('editStakingPeriod');
if (editStakingPeriod) {
editStakingPeriod.value = '12'; // Default to 12 months
}
const editEarlyWithdrawalAllowed = document.getElementById('editEarlyWithdrawalAllowed');
if (editEarlyWithdrawalAllowed) {
editEarlyWithdrawalAllowed.checked = true; // Default to allowed
}
}
// Set availability status
const availabilitySelect = document.getElementById('nodeAvailabilityStatus');
if (availabilitySelect && nodeData.availability_status) {
availabilitySelect.value = nodeData.availability_status;
}
}
/**
* Set up event listeners for node edit modal
*/
function setupNodeEditEventListeners() {
console.log('🔧 setupNodeEditEventListeners called');
// Group assignment toggle
const assignToGroupToggle = document.getElementById('assignToGroupToggle');
const groupSelectionSection = document.getElementById('groupSelectionSection');
console.log('🔧 Group toggle found:', !!assignToGroupToggle);
if (assignToGroupToggle) {
assignToGroupToggle.removeEventListener('change', handleGroupAssignmentToggle);
assignToGroupToggle.addEventListener('change', handleGroupAssignmentToggle);
}
// Full node rental toggle
const fullNodeRentalCheckbox = document.getElementById('enableFullNodeRental');
if (fullNodeRentalCheckbox && !fullNodeRentalCheckbox.dataset.editListenerAttached) {
// Only attach if not already attached to prevent duplicates
fullNodeRentalCheckbox.addEventListener('change', handleFullNodeRentalToggle);
fullNodeRentalCheckbox.dataset.editListenerAttached = 'true';
console.log('🏢 Edit modal: Full node rental listener attached');
}
// Edit staking toggle
const editEnableStaking = document.getElementById('editEnableStaking');
if (editEnableStaking) {
editEnableStaking.removeEventListener('change', handleEditStakingToggle);
editEnableStaking.addEventListener('change', handleEditStakingToggle);
}
// Add validation to staking amount input
const editStakingAmount = document.getElementById('editStakingAmount');
if (editStakingAmount) {
editStakingAmount.removeEventListener('input', handleStakingAmountInput);
editStakingAmount.addEventListener('input', handleStakingAmountInput);
editStakingAmount.removeEventListener('blur', handleStakingAmountBlur);
editStakingAmount.addEventListener('blur', handleStakingAmountBlur);
}
// Save button
const saveBtn = document.getElementById('saveNodeConfigBtn');
if (saveBtn) {
// Remove any existing listeners
saveBtn.removeEventListener('click', saveNodeConfiguration);
// Add the event listener
saveBtn.addEventListener('click', function(event) {
console.log('💾 Save button clicked! Event:', event);
event.preventDefault();
event.stopPropagation();
saveNodeConfiguration();
});
console.log('💾 Save button event listener attached successfully');
} else {
console.error('💾 Save button not found!');
}
// Add event listener for delete confirmation button
const confirmDeleteNodeBtn = document.getElementById('confirmDeleteNodeBtn');
if (confirmDeleteNodeBtn) {
// Remove existing listener first
confirmDeleteNodeBtn.removeEventListener('click', confirmNodeDeletion);
// Add new listener
confirmDeleteNodeBtn.addEventListener('click', confirmNodeDeletion);
console.log('🗑️ Delete button event listener attached');
} else {
console.error('🗑️ confirmDeleteNodeBtn not found!');
}
}
/**
* Handle group assignment toggle
*/
function handleGroupAssignmentToggle() {
const groupSelectionSection = document.getElementById('groupSelectionSection');
if (groupSelectionSection) {
groupSelectionSection.style.display = this.checked ? 'block' : 'none';
}
}
/**
* Handle full node rental toggle
*/
function handleFullNodeRentalToggle() {
console.log('🏢 Edit modal: Full node rental toggle changed:', this.checked);
const fullNodePricingSection = document.getElementById('fullNodePricingSection');
if (fullNodePricingSection) {
if (this.checked) {
console.log('🏢 Edit modal: Showing full node pricing section');
fullNodePricingSection.style.display = 'block';
// Initialize pricing calculation for the edit modal
setTimeout(() => {
console.log('🏢 Edit modal: Initializing full node pricing calculation');
initializeFullNodePricingCalculation();
}, 100);
// Don't show notification here - it's already shown by the main event listener
} else {
console.log('🏢 Edit modal: Hiding full node pricing section');
fullNodePricingSection.style.display = 'none';
// Don't show notification here - it's already shown by the main event listener
}
}
}
/**
* Handle edit staking toggle
*/
function handleEditStakingToggle() {
const editStakingConfigSection = document.getElementById('editStakingConfigSection');
const editStakingAmount = document.getElementById('editStakingAmount');
console.log('🛡️ Edit staking toggle changed:', this.checked);
if (editStakingConfigSection) {
editStakingConfigSection.style.display = this.checked ? 'block' : 'none';
}
// Load wallet balance and clear previous values when staking is enabled
if (this.checked) {
console.log('🛡️ Staking enabled - loading wallet balance');
loadWalletBalance();
// Clear previous staking amount to force user to enter new value
if (editStakingAmount) {
editStakingAmount.value = '';
}
} else {
console.log('🛡️ Staking disabled');
// Clear staking amount when disabled
if (editStakingAmount) {
editStakingAmount.value = '';
}
}
}
/**
* Handle staking amount input changes (real-time validation)
*/
function handleStakingAmountInput() {
// Clear validation classes on input to provide immediate feedback
this.classList.remove('is-invalid', 'is-valid');
clearValidationError(this);
}
/**
* Handle staking amount blur (full validation)
*/
function handleStakingAmountBlur() {
if (this.value.trim() !== '') {
validateStakingAmount(this);
}
}
/**
* Show confirmation dialog for staking changes
*/
function showStakingConfirmationDialog(title, message) {
return new Promise((resolve) => {
console.log('🛡️ Showing staking confirmation dialog:', title);
// Create modal HTML
const modalHtml = `
<div class="modal fade" id="stakingConfirmModal" tabindex="-1" aria-labelledby="stakingConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="stakingConfirmModalLabel">
<i class="bi bi-shield-exclamation me-2"></i>${title}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Confirm Staking Change</strong>
</div>
<p style="white-space: pre-line;">${message}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="stakingConfirmCancel">Cancel</button>
<button type="button" class="btn btn-warning" id="stakingConfirmProceed">
<i class="bi bi-check me-1"></i>Confirm
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('stakingConfirmModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to body
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Get modal elements
const modal = new bootstrap.Modal(document.getElementById('stakingConfirmModal'));
const proceedBtn = document.getElementById('stakingConfirmProceed');
const cancelBtn = document.getElementById('stakingConfirmCancel');
// Add timeout to auto-resolve if modal doesn't work
const timeout = setTimeout(() => {
console.warn('🛡️ Staking confirmation dialog timeout - auto-confirming');
resolve(true);
}, 30000); // 30 second timeout
// Handle proceed
proceedBtn.addEventListener('click', () => {
console.log('🛡️ User confirmed staking change');
clearTimeout(timeout);
modal.hide();
resolve(true);
});
// Handle cancel
cancelBtn.addEventListener('click', () => {
console.log('🛡️ User cancelled staking change');
clearTimeout(timeout);
modal.hide();
resolve(false);
});
// Handle modal close (X button or ESC)
document.getElementById('stakingConfirmModal').addEventListener('hidden.bs.modal', () => {
console.log('🛡️ Staking confirmation modal closed');
clearTimeout(timeout);
document.getElementById('stakingConfirmModal').remove();
});
// Handle close button
document.querySelector('#stakingConfirmModal .btn-close').addEventListener('click', () => {
console.log('🛡️ User closed staking confirmation dialog');
clearTimeout(timeout);
modal.hide();
resolve(false);
});
// Show modal
modal.show();
console.log('🛡️ Staking confirmation dialog shown');
});
}
/**
* Save node configuration
*/
async function saveNodeConfiguration() {
console.log('💾 Save node configuration called');
const nodeData = window.currentEditingNode;
if (!nodeData) {
console.error('💾 No node data found for editing');
showNotification('Error: No node selected for editing', 'error');
return;
}
console.log('💾 Saving configuration for node:', nodeData.id);
const saveBtn = document.getElementById('saveNodeConfigBtn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
console.log('💾 Save button disabled and loading state set');
}
try {
// Collect form data - only include fields that are available/changed
const formData = {};
// Handle group assignment based on toggle state
const assignToGroupToggle = document.getElementById('assignToGroupToggle');
const groupSelect = document.getElementById('nodeGroupSelect');
if (assignToGroupToggle && groupSelect) {
if (assignToGroupToggle.checked && groupSelect.value) {
formData.node_group_id = groupSelect.value;
} else {
formData.node_group_id = null; // Set to Single/Individual
}
}
// Collect slice pricing settings (new automatic slice system)
const slicePriceInput = document.getElementById('editSlicePrice');
if (slicePriceInput) {
formData.slice_pricing = {
base_price_per_hour: parseFloat(slicePriceInput.value) || 0.50
};
console.log('💾 Collected slice pricing:', formData.slice_pricing);
}
// Note: Slice formats and rental options are now automatically managed
// Collect auto-renewal setting
const autoRenewalInput = document.getElementById('autoRenewalEnabled');
if (autoRenewalInput) {
formData.auto_renewal_enabled = autoRenewalInput.checked;
}
// Collect staking configuration (include in main configuration)
const editEnableStakingCheckbox = document.getElementById('editEnableStaking');
console.log('🛡️ Checking staking configuration...');
console.log('🛡️ Staking checkbox found:', !!editEnableStakingCheckbox);
console.log('🛡️ Staking checkbox checked:', editEnableStakingCheckbox?.checked);
if (editEnableStakingCheckbox) {
formData.staking_enabled = editEnableStakingCheckbox.checked;
if (editEnableStakingCheckbox.checked) {
const editStakingAmountInput = document.getElementById('editStakingAmount');
const editStakingPeriodSelect = document.getElementById('editStakingPeriod');
const editEarlyWithdrawalInput = document.getElementById('editEarlyWithdrawalAllowed');
console.log('🛡️ Staking enabled, collecting data...');
console.log('🛡️ Amount input found:', !!editStakingAmountInput);
console.log('🛡️ Amount input value:', editStakingAmountInput?.value);
console.log('🛡️ Period select found:', !!editStakingPeriodSelect);
console.log('🛡️ Period select value:', editStakingPeriodSelect?.value);
console.log('🛡️ Early withdrawal found:', !!editEarlyWithdrawalInput);
console.log('🛡️ Early withdrawal checked:', editEarlyWithdrawalInput?.checked);
if (editStakingAmountInput && editStakingAmountInput.value) {
console.log('🛡️ Validating staking amount:', editStakingAmountInput.value);
// Validate staking amount
const isValid = validateStakingAmount(editStakingAmountInput);
console.log('🛡️ Validation result:', isValid);
if (!isValid) {
console.error('🛡️ Staking amount validation failed');
throw new Error('Invalid staking amount');
}
const newStakingAmount = parseFloat(editStakingAmountInput.value);
const currentStakingAmount = nodeData.staking_options?.staked_amount || 0;
// Check if this is a significant staking change that affects wallet balance
const stakingChange = newStakingAmount - currentStakingAmount;
const walletBalance = window.userWalletBalance || 0;
// For now, skip confirmation dialogs to test basic functionality
// TODO: Re-enable confirmations after basic save is working
console.log('🛡️ Staking change detected:', {
newAmount: newStakingAmount,
currentAmount: currentStakingAmount,
change: stakingChange,
walletBalance: walletBalance
});
// Basic validation without confirmation
if (stakingChange > 0 && stakingChange > walletBalance) {
throw new Error(`Insufficient wallet balance. Need ${stakingChange} TFP, have ${walletBalance} TFP`);
}
formData.staked_amount = newStakingAmount;
formData.staking_period_months = editStakingPeriodSelect ? parseInt(editStakingPeriodSelect.value) : 12;
formData.early_withdrawal_allowed = editEarlyWithdrawalInput ? editEarlyWithdrawalInput.checked : true;
formData.early_withdrawal_penalty_percent = 25.0; // Default penalty
console.log('🛡️ Collected staking data for main configuration:', {
staked_amount: formData.staked_amount,
staking_period_months: formData.staking_period_months,
early_withdrawal_allowed: formData.early_withdrawal_allowed
});
} else {
console.error('🛡️ Staking enabled but no amount input found or no value provided');
console.error('🛡️ Amount input element:', editStakingAmountInput);
console.error('🛡️ Amount input value:', editStakingAmountInput?.value);
throw new Error('Staking amount is required when staking is enabled');
}
} else {
// Disabling staking - log the action
const currentStakingAmount = nodeData.staking_options?.staked_amount || 0;
console.log('🛡️ Disabling staking. Current staked amount:', currentStakingAmount);
if (currentStakingAmount > 0) {
const penalty = currentStakingAmount * 0.25; // 25% penalty
const netReturn = currentStakingAmount - penalty;
console.log('🛡️ Staking disable will return:', netReturn, 'TFP (after', penalty, 'TFP penalty)');
}
console.log('🛡️ Staking disabled');
}
} else {
console.log('🛡️ Staking checkbox not found');
}
// Always include availability status if control exists
const availabilitySelect = document.getElementById('nodeAvailabilityStatus');
if (availabilitySelect) {
formData.availability_status = availabilitySelect.value;
}
// Note: Slice validation removed - slices are now automatically managed
console.log('💾 Saving node configuration for node:', nodeData.id);
console.log('💾 Form data being saved:', formData);
// Save the complete node configuration (including staking)
const configResult = await window.apiJson(`/api/dashboard/farm-nodes/${nodeData.id}/configuration`, {
method: 'PUT',
body: formData
});
console.log('💾 Node configuration saved:', configResult);
// Show success message
if (formData.staking_enabled) {
showNotification('Node configuration and staking updated successfully', 'success');
} else {
showNotification('Node configuration updated successfully', 'success');
}
// Hide modal
const modal = bootstrap.Modal.getInstance(document.getElementById('nodeEditModal'));
if (modal) {
modal.hide();
}
// Reload resource_provider data and node groups with small delay for backend processing
setTimeout(async () => {
await loadResourceProviderData();
await loadNodeGroups(); // FARMER FIX: Refresh node groups table after node configuration changes
await loadWalletBalance(); // Refresh wallet balance after staking changes
await updateStakingDisplay(); // Refresh staking statistics after staking changes
}, 100);
} catch (error) {
console.error('💾 Error saving node configuration:', error);
console.error('💾 Error details:', error.message, error.stack);
showNotification(`Failed to save node configuration: ${error.message}`, 'error');
} finally {
// Reset button state
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check me-1"></i>Save Configuration';
console.log('💾 Save button reset to enabled state');
}
}
}
/**
* Test save function - can be called from browser console
*/
window.testSaveNodeConfiguration = function() {
console.log('🧪 Testing save node configuration...');
const saveBtn = document.getElementById('saveNodeConfigBtn');
console.log('🧪 Save button found:', !!saveBtn);
if (saveBtn) {
console.log('🧪 Save button disabled:', saveBtn.disabled);
console.log('🧪 Save button innerHTML:', saveBtn.innerHTML);
console.log('🧪 Save button onclick:', saveBtn.onclick);
console.log('🧪 Save button style.display:', saveBtn.style.display);
console.log('🧪 Save button offsetParent:', saveBtn.offsetParent);
console.log('🧪 Save button getBoundingClientRect:', saveBtn.getBoundingClientRect());
// Test if button is actually clickable
console.log('🧪 Attempting to click button programmatically...');
saveBtn.click();
// Test direct handler call
if (window.debugSaveHandler) {
console.log('🧪 Calling save handler directly...');
window.debugSaveHandler({ preventDefault: () => {}, stopPropagation: () => {} });
}
}
const nodeData = window.currentEditingNode;
console.log('🧪 Current editing node:', nodeData);
if (nodeData) {
console.log('🧪 Calling saveNodeConfiguration directly...');
saveNodeConfiguration();
} else {
console.log('🧪 No node data available for editing');
}
};
/**
* Test button click simulation
*/
window.testButtonClick = function() {
console.log('🧪 Testing button click simulation...');
const saveBtn = window.debugSaveBtn || document.getElementById('saveNodeConfigBtn');
if (saveBtn) {
console.log('🧪 Button found, simulating click...');
// Try different click simulation methods
console.log('🧪 Method 1: Direct click()');
saveBtn.click();
console.log('🧪 Method 2: Mouse events');
saveBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
saveBtn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
console.log('🧪 Method 3: Direct handler call');
if (window.debugSaveHandler) {
window.debugSaveHandler({
preventDefault: () => console.log('preventDefault called'),
stopPropagation: () => console.log('stopPropagation called'),
target: saveBtn,
currentTarget: saveBtn
});
}
} else {
console.log('🧪 Save button not found');
}
};
/**
* Simple test to check if staking can be enabled
*/
window.testStakingEnable = function() {
console.log('🧪 Testing staking enable...');
const stakingCheckbox = document.getElementById('editEnableStaking');
const stakingAmount = document.getElementById('editStakingAmount');
if (stakingCheckbox && stakingAmount) {
stakingCheckbox.checked = true;
stakingAmount.value = '10';
// Trigger the toggle event
stakingCheckbox.dispatchEvent(new Event('change'));
console.log('🧪 Staking enabled with 10 TFP');
console.log('🧪 Now try clicking save or call testSaveNodeConfiguration()');
} else {
console.log('🧪 Staking elements not found');
}
};
/**
* Get status badge class for node status
*/
function getStatusBadgeClass(status) {
const statusMap = {
'Online': 'bg-success',
'Offline': 'bg-danger',
'Maintenance': 'bg-warning',
'Error': 'bg-danger',
'Standby': 'bg-secondary'
};
return statusMap[status] || 'bg-secondary';
}
/**
* Show slice details modal
*/
function showSliceDetailsModal(sliceData) {
// Create modal HTML
const modalHtml = `
<div class="modal fade" id="sliceDetailsModal" tabindex="-1" aria-labelledby="sliceDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sliceDetailsModalLabel">Slice Configuration Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<h6>Basic Information</h6>
<table class="table table-sm">
<tr><td><strong>Name:</strong></td><td>${sliceData.name}</td></tr>
<tr><td><strong>Description:</strong></td><td>${sliceData.description}</td></tr>
<tr><td><strong>Provider:</strong></td><td>${sliceData.provider_name}</td></tr>
<tr><td><strong>Status:</strong></td><td><span class="badge bg-success">Active</span></td></tr>
</table>
</div>
<div class="col-md-6">
<h6>Resource Specifications</h6>
<table class="table table-sm">
<tr><td><strong>CPU Cores:</strong></td><td>${sliceData.slice_configuration?.cpu_cores || 'N/A'}</td></tr>
<tr><td><strong>Memory:</strong></td><td>${sliceData.slice_configuration?.memory_gb || 'N/A'} GB</td></tr>
<tr><td><strong>Storage:</strong></td><td>${sliceData.slice_configuration?.storage_gb || 'N/A'} GB</td></tr>
<tr><td><strong>Bandwidth:</strong></td><td>${sliceData.slice_configuration?.bandwidth_mbps || 'N/A'} Mbps</td></tr>
<tr><td><strong>Public IPs:</strong></td><td>${sliceData.slice_configuration?.public_ips || 0}</td></tr>
<tr><td><strong>Min Uptime SLA:</strong></td><td>${sliceData.slice_configuration?.min_uptime_sla || 'N/A'}%</td></tr>
</table>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<h6>Pricing Information</h6>
<div class="row">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">${sliceData.slice_configuration?.pricing?.hourly || sliceData.base_price} TFP</h5>
<p class="card-text">per hour</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">${sliceData.slice_configuration?.pricing?.daily || (sliceData.base_price * 24).toFixed(2)} TFP</h5>
<p class="card-text">per day</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">${sliceData.slice_configuration?.pricing?.monthly || (sliceData.base_price * 24 * 30).toFixed(0)} TFP</h5>
<p class="card-text">per month</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">${sliceData.slice_configuration?.pricing?.yearly || (sliceData.base_price * 24 * 365).toFixed(0)} TFP</h5>
<p class="card-text">per year</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" onclick="${sliceData.isDefault ? 'editDefaultSliceConfiguration' : 'editSliceConfiguration'}('${sliceData.id}'); bootstrap.Modal.getInstance(document.getElementById('sliceDetailsModal')).hide();">Edit Configuration</button>
${!sliceData.isDefault ? `
<button type="button" class="btn btn-danger" onclick="deleteSliceConfiguration('${sliceData.id}'); bootstrap.Modal.getInstance(document.getElementById('sliceDetailsModal')).hide();">
<i class="bi bi-trash me-2"></i>Delete
</button>
` : ''}
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('sliceDetailsModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('sliceDetailsModal'));
modal.show();
// Clean up when modal is hidden
document.getElementById('sliceDetailsModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
/**
* Show slice edit modal
*/
function showSliceEditModal(sliceData) {
const isDefault = sliceData.isDefault || false;
// Create modal HTML with enhanced pricing controls
const modalHtml = `
<div class="modal fade" id="sliceEditModal" tabindex="-1" aria-labelledby="sliceEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sliceEditModalLabel">Edit ${isDefault ? 'Default ' : ''}Slice Configuration</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="sliceEditForm">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="editSliceName" class="form-label">Template Name</label>
<input type="text" class="form-control" id="editSliceName" value="${sliceData.name}" required>
</div>
<div class="mb-3">
<label for="editSliceDescription" class="form-label">Description</label>
<textarea class="form-control" id="editSliceDescription" rows="3">${sliceData.description}</textarea>
</div>
</div>
<div class="col-md-6">
<h6>Resource Allocation</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editSliceCPU" class="form-label">vCPU Cores</label>
<input type="number" class="form-control" id="editSliceCPU" value="${sliceData.slice_configuration?.cpu_cores || 2}" min="1" required>
</div>
<div class="col-md-6 mb-3">
<label for="editSliceRAM" class="form-label">RAM (GB)</label>
<input type="number" class="form-control" id="editSliceRAM" value="${sliceData.slice_configuration?.memory_gb || 4}" min="1" required>
</div>
<div class="col-md-6 mb-3">
<label for="editSliceStorage" class="form-label">Storage (GB)</label>
<input type="number" class="form-control" id="editSliceStorage" value="${sliceData.slice_configuration?.storage_gb || 100}" min="1" required>
</div>
<div class="col-md-6 mb-3">
<label for="editSliceBandwidth" class="form-label">Bandwidth (Mbps)</label>
<input type="number" class="form-control" id="editSliceBandwidth" value="${sliceData.slice_configuration?.bandwidth_mbps || 100}" min="1" required>
</div>
<div class="col-md-6 mb-3">
<label for="editSliceUptime" class="form-label">Min Uptime SLA (%)</label>
<input type="number" class="form-control" id="editSliceUptime" value="${sliceData.slice_configuration?.min_uptime_sla || 99.0}" min="90" max="100" step="0.1" required>
</div>
<div class="col-md-6 mb-3">
<label for="editSliceIPs" class="form-label">Public IPs</label>
<input type="number" class="form-control" id="editSliceIPs" value="${sliceData.slice_configuration?.public_ips || 0}" min="0">
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<h6>Pricing Configuration</h6>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="editAutoPricingToggle" checked>
<label class="form-check-label" for="editAutoPricingToggle">
<strong>Auto-calculate pricing from hourly rate</strong>
</label>
</div>
<div class="row">
<div class="col-md-3 mb-3">
<label for="editPriceHour" class="form-label">Hourly Rate (TFP)</label>
<input type="number" class="form-control" id="editPriceHour" value="${sliceData.slice_configuration?.pricing?.hourly || sliceData.base_price}" step="0.01" min="0" required>
</div>
<div class="col-md-3 mb-3">
<label for="editPriceDay" class="form-label">Daily Rate (TFP)</label>
<input type="number" class="form-control" id="editPriceDay" value="${sliceData.slice_configuration?.pricing?.daily || (sliceData.base_price * 24).toFixed(2)}" step="0.01" min="0">
</div>
<div class="col-md-3 mb-3">
<label for="editPriceMonth" class="form-label">Monthly Rate (TFP)</label>
<input type="number" class="form-control" id="editPriceMonth" value="${sliceData.slice_configuration?.pricing?.monthly || (sliceData.base_price * 24 * 30).toFixed(0)}" step="1" min="0">
</div>
<div class="col-md-3 mb-3">
<label for="editPriceYear" class="form-label">Yearly Rate (TFP)</label>
<input type="number" class="form-control" id="editPriceYear" value="${sliceData.slice_configuration?.pricing?.yearly || (sliceData.base_price * 24 * 365).toFixed(0)}" step="1" min="0">
</div>
</div>
<div class="row" id="editDiscountControls">
<div class="col-md-4 mb-3">
<label for="editDailyDiscount" class="form-label">Daily Discount (%)</label>
<input type="number" class="form-control" id="editDailyDiscount" value="5" min="0" max="50" step="0.1">
</div>
<div class="col-md-4 mb-3">
<label for="editMonthlyDiscount" class="form-label">Monthly Discount (%)</label>
<input type="number" class="form-control" id="editMonthlyDiscount" value="15" min="0" max="50" step="0.1">
</div>
<div class="col-md-4 mb-3">
<label for="editYearlyDiscount" class="form-label">Yearly Discount (%)</label>
<input type="number" class="form-control" id="editYearlyDiscount" value="25" min="0" max="50" step="0.1">
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSliceConfiguration('${sliceData.id}')">Save Changes</button>
${!isDefault ? `
<button type="button" class="btn btn-danger" onclick="deleteSliceConfiguration('${sliceData.id}')">
<i class="bi bi-trash me-2"></i>Delete
</button>
` : ''}
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('sliceEditModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('sliceEditModal'));
modal.show();
// Initialize pricing calculation for edit modal
setTimeout(() => initializeEditPricingCalculation(), 100);
// Clean up when modal is hidden
document.getElementById('sliceEditModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
/**
* Initialize pricing calculation for edit modal
*/
function initializeEditPricingCalculation() {
const priceHour = document.getElementById('editPriceHour');
const priceDay = document.getElementById('editPriceDay');
const priceMonth = document.getElementById('editPriceMonth');
const priceYear = document.getElementById('editPriceYear');
const autoPricingToggle = document.getElementById('editAutoPricingToggle');
// Discount controls
const dailyDiscount = document.getElementById('editDailyDiscount');
const monthlyDiscount = document.getElementById('editMonthlyDiscount');
const yearlyDiscount = document.getElementById('editYearlyDiscount');
if (!priceHour || !priceDay || !priceMonth || !priceYear || !autoPricingToggle) return;
if (!dailyDiscount || !monthlyDiscount || !yearlyDiscount) return;
// Helper function to check if auto-calculation is enabled
const isAutoCalculationEnabled = () => autoPricingToggle.checked;
// Helper function to calculate rate with discount
const applyDiscount = (baseRate, discountPercent) => {
const discount = parseFloat(discountPercent) || 0;
return baseRate * (1 - discount / 100);
};
// Calculate all rates from hourly base rate
const calculateFromHourly = () => {
if (!isAutoCalculationEnabled()) return;
const hourlyRate = parseFloat(priceHour.value) || 0;
if (hourlyRate <= 0) {
priceDay.value = '';
priceMonth.value = '';
priceYear.value = '';
return;
}
// Calculate base rates (no discount)
const baseDailyRate = hourlyRate * 24;
const baseMonthlyRate = hourlyRate * 24 * 30;
const baseYearlyRate = hourlyRate * 24 * 365;
// Apply discounts
const finalDailyRate = applyDiscount(baseDailyRate, dailyDiscount.value);
const finalMonthlyRate = applyDiscount(baseMonthlyRate, monthlyDiscount.value);
const finalYearlyRate = applyDiscount(baseYearlyRate, yearlyDiscount.value);
// Update fields
priceDay.value = finalDailyRate.toFixed(2);
priceMonth.value = finalMonthlyRate.toFixed(0);
priceYear.value = finalYearlyRate.toFixed(0);
};
// Auto-calculate when hourly rate changes
priceHour.addEventListener('input', calculateFromHourly);
// Auto-calculate when discount percentages change
dailyDiscount.addEventListener('input', calculateFromHourly);
monthlyDiscount.addEventListener('input', calculateFromHourly);
yearlyDiscount.addEventListener('input', calculateFromHourly);
// Handle toggle changes
autoPricingToggle.addEventListener('change', function() {
const pricingFields = [priceDay, priceMonth, priceYear];
const discountFields = [dailyDiscount, monthlyDiscount, yearlyDiscount];
if (this.checked) {
// Enable auto-calculation
pricingFields.forEach(field => {
field.readOnly = true;
field.placeholder = 'Auto-calculated';
field.title = 'Auto-calculated from hourly rate and discount percentage';
});
discountFields.forEach(field => {
field.disabled = false;
field.style.opacity = '1';
});
// Recalculate immediately
calculateFromHourly();
} else {
// Disable auto-calculation
pricingFields.forEach(field => {
field.readOnly = false;
field.placeholder = 'Set custom rate';
field.title = 'Set custom rate for special deals';
});
discountFields.forEach(field => {
field.disabled = true;
field.style.opacity = '0.5';
});
}
});
// Initialize state
autoPricingToggle.dispatchEvent(new Event('change'));
}
/**
* Save slice configuration changes
*/
async function saveSliceConfiguration(sliceId) {
console.log('🍰 Saving slice configuration:', sliceId);
// Check if this is a default slice
const isDefault = sliceId === 'basic' || sliceId === 'standard' || sliceId === 'performance';
// Get form data
const name = document.getElementById('editSliceName').value.trim();
const description = document.getElementById('editSliceDescription').value.trim();
const cpuCores = parseInt(document.getElementById('editSliceCPU').value) || 2;
const memoryGb = parseInt(document.getElementById('editSliceRAM').value) || 4;
const storageGb = parseInt(document.getElementById('editSliceStorage').value) || 100;
const bandwidthMbps = parseInt(document.getElementById('editSliceBandwidth').value) || 100;
const minUptimeSla = parseFloat(document.getElementById('editSliceUptime').value) || 99.0;
const publicIps = parseInt(document.getElementById('editSliceIPs').value) || 0;
const hourlyPrice = parseFloat(document.getElementById('editPriceHour').value) || 0;
const dailyPrice = parseFloat(document.getElementById('editPriceDay').value) || 0;
const monthlyPrice = parseFloat(document.getElementById('editPriceMonth').value) || 0;
const yearlyPrice = parseFloat(document.getElementById('editPriceYear').value) || 0;
// Validate form data
if (!name || cpuCores <= 0 || memoryGb <= 0 || storageGb <= 0 || hourlyPrice <= 0) {
showNotification('Please fill in all required fields with valid values', 'warning');
return;
}
const updateData = {
name: name,
description: description,
pricing: {
hourly: hourlyPrice,
daily: dailyPrice,
monthly: monthlyPrice,
yearly: yearlyPrice
},
slice_configuration: {
cpu_cores: cpuCores,
memory_gb: memoryGb,
storage_gb: storageGb,
bandwidth_mbps: bandwidthMbps,
min_uptime_sla: minUptimeSla,
public_ips: publicIps,
pricing: {
hourly: hourlyPrice,
daily: dailyPrice,
monthly: monthlyPrice,
yearly: yearlyPrice
}
}
};
try {
if (isDefault) {
// Save default slice customizations
const customizationData = {
name: name,
description: description,
cpu_cores: cpuCores,
memory_gb: memoryGb,
storage_gb: storageGb,
bandwidth_mbps: bandwidthMbps,
price_per_hour: hourlyPrice
};
const result = await window.apiJson(`/api/dashboard/default-slice-customization/${sliceId}`, {
method: 'PUT',
body: customizationData
});
console.log('🍰 Default slice customization saved:', result);
showNotification(`Default slice "${name}" updated successfully`, 'success');
} else {
// Handle custom slice updates
const result = await window.apiJson(`/api/dashboard/slice-configuration/${sliceId}`, {
method: 'PUT',
body: updateData
});
console.log('🍰 Slice configuration updated:', result);
showNotification('Slice configuration updated successfully', 'success');
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('sliceEditModal'));
if (modal) {
modal.hide();
}
// Reload slice templates
loadSliceTemplates();
} catch (error) {
console.error('🍰 Error updating slice configuration:', error);
showNotification('Failed to update slice configuration', 'error');
}
}
/**
* Load slice formats for node addition modal
*/
async function loadSliceFormatsForNodeAddition() {
console.log('🍰 Loading slice formats for node addition');
const sliceFormatSelection = document.getElementById('sliceFormatSelection');
if (!sliceFormatSelection) return;
try {
// Load default slice formats
const defaultFormats = await window.apiJson('/api/dashboard/default-slice-formats');
// Load user's custom slice products
const sliceProducts = await window.apiJson('/api/dashboard/slice-products');
console.log('🍰 Loaded formats for node addition:', { defaultFormats, sliceProducts });
// Clear loading state
sliceFormatSelection.innerHTML = '';
// Add default formats
defaultFormats.forEach(format => {
const formatCard = createSliceFormatCard(format, 'default');
sliceFormatSelection.appendChild(formatCard);
});
// Add custom slice products
sliceProducts.forEach(product => {
const sliceConfig = product.attributes?.slice_configuration?.value;
if (sliceConfig) {
const formatCard = createSliceFormatCard({
id: product.id,
name: product.name,
cpu_cores: sliceConfig.cpu_cores,
memory_gb: sliceConfig.memory_gb,
storage_gb: sliceConfig.storage_gb,
bandwidth_mbps: sliceConfig.bandwidth_mbps,
price: product.base_price,
currency: product.base_currency,
pricing: sliceConfig.pricing
}, 'custom');
sliceFormatSelection.appendChild(formatCard);
}
});
// If no formats available, show message
if (defaultFormats.length === 0 && sliceProducts.length === 0) {
sliceFormatSelection.innerHTML = `
<div class="col-12 text-center text-muted">
<i class="bi bi-layers fs-1"></i>
<p class="mt-2">No slice formats available</p>
<p class="small">Create slice configurations first</p>
</div>
`;
}
} catch (error) {
console.error('🍰 Error loading slice formats for node addition:', error);
sliceFormatSelection.innerHTML = `
<div class="col-12 text-center text-danger">
<i class="bi bi-exclamation-triangle fs-1"></i>
<p class="mt-2">Failed to load slice formats</p>
<button class="btn btn-sm btn-outline-primary" onclick="loadSliceFormatsForNodeAddition()">
<i class="bi bi-arrow-clockwise me-1"></i> Retry
</button>
</div>
`;
}
}
/**
* Create a slice format selection card
*/
function createSliceFormatCard(format, type) {
const col = document.createElement('div');
col.className = 'col-md-6 col-lg-4 mb-3';
const isCustom = type === 'custom';
const currency = format.currency || 'TFP';
// Create pricing display using persistent data
let pricingDisplay = '';
if (isCustom) {
// For custom slices, the actual price is in base_price field
// The pricing object in slice_configuration is often empty/zero
let hourlyRate = 0;
// Priority order: base_price (main price) > pricing.hourly > price > fallback
if (format.base_price && format.base_price > 0) {
hourlyRate = format.base_price;
} else if (format.pricing && format.pricing.hourly && format.pricing.hourly > 0) {
hourlyRate = format.pricing.hourly;
} else if (format.price && format.price > 0) {
hourlyRate = format.price;
} else {
hourlyRate = 0; // Show 0 if no valid price found
}
console.log('🍰 Custom slice pricing debug:', {
formatId: format.id,
formatName: format.name,
base_price: format.base_price,
pricing: format.pricing,
price: format.price,
calculatedHourlyRate: hourlyRate
});
pricingDisplay = `
<div class="pricing-breakdown">
<small class="text-muted">
${hourlyRate} ${currency}/hour
</small>
</div>
`;
} else {
// Use persistent data from resource_provider settings (price_per_hour) instead of hardcoded fallback
const hourlyPrice = format.price_per_hour || format.price || 10; // Default to 10 if no price found
pricingDisplay = `<small class="text-muted">${hourlyPrice} ${currency}/hour</small>`;
}
col.innerHTML = `
<div class="slice-format-card card h-100" data-format-id="${format.id}" data-format-type="${type}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="card-title mb-0">${getSliceFormatDisplayName(format.id, format.name)}</h6>
<div class="form-check">
<input class="form-check-input slice-format-checkbox" type="checkbox"
id="slice_${format.id}" value="${format.id}">
</div>
</div>
<div class="slice-specs mb-2">
<small class="text-muted">
${format.cpu_cores} vCPU • ${format.memory_gb}GB RAM<br>
${format.storage_gb}GB Storage • ${format.bandwidth_mbps}Mbps
</small>
</div>
${pricingDisplay}
${isCustom ? '<span class="badge bg-primary badge-sm">Custom</span>' : '<span class="badge bg-info badge-sm">Default</span>'}
</div>
</div>
`;
// Add click handler for card selection
const card = col.querySelector('.slice-format-card');
const checkbox = col.querySelector('.slice-format-checkbox');
card.addEventListener('click', function(e) {
if (e.target.type !== 'checkbox') {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
}
});
checkbox.addEventListener('change', function() {
if (this.checked) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
updateSelectedSliceFormats();
});
return col;
}
/**
* Get proper display name for slice format
*/
function getSliceFormatDisplayName(formatId, formatName) {
// Handle special slice format IDs that should show proper names
const sliceFormatNames = {
'slice_d4e3d66d': 'Excellence HA',
'slice_d4e3d66dbasic': 'Excellence HA Basic',
'slice_d4e3d66dstandard': 'Excellence HA Standard',
'slice_d4e3d66dperformance': 'Excellence HA Performance',
'basic': 'Basic',
'standard': 'Standard',
'performance': 'Performance',
'small': 'Small',
'medium': 'Medium',
'large': 'Large',
'xlarge': 'Extra Large'
};
// First check hardcoded mappings for default formats
if (sliceFormatNames[formatId]) {
return sliceFormatNames[formatId];
}
// If formatName is provided, use it
if (formatName) {
return formatName;
}
// Look up custom slice products from loaded resource_provider data
if (window.resourceProviderData && window.resourceProviderData.slice_products) {
const sliceProduct = window.resourceProviderData.slice_products.find(product => product.id === formatId);
if (sliceProduct && sliceProduct.name) {
return sliceProduct.name;
}
}
// Look up from globally loaded slice products if resource_provider data not available
if (window.loadedSliceProducts) {
const sliceProduct = window.loadedSliceProducts.find(product => product.id === formatId);
if (sliceProduct && sliceProduct.name) {
return sliceProduct.name;
}
}
// Fallback to the formatId itself
return formatId;
}
/**
* Update selected slice formats display
*/
function updateSelectedSliceFormats() {
const selectedFormats = document.querySelectorAll('.slice-format-checkbox:checked');
console.log('🍰 Selected slice formats:', selectedFormats.length);
// Enable/disable add nodes button based on selection
const addNodesBtn = document.getElementById('addNodesBtn');
if (addNodesBtn) {
// At least one slice format must be selected (or full node rental enabled)
const fullNodeEnabled = document.getElementById('enableFullNodeRental')?.checked;
const hasSliceSelection = selectedFormats.length > 0;
if (hasSliceSelection || fullNodeEnabled) {
addNodesBtn.disabled = false;
} else {
addNodesBtn.disabled = true;
}
}
}
/**
* Get selected slice configuration for node addition
*/
function getSelectedSliceConfiguration() {
const selectedFormats = document.querySelectorAll('.slice-format-checkbox:checked');
const sliceFormats = Array.from(selectedFormats).map(checkbox => checkbox.value);
const fullNodeRental = document.getElementById('enableFullNodeRental')?.checked || false;
return {
slice_formats: sliceFormats,
full_node_rental_enabled: fullNodeRental,
slice_rental_enabled: sliceFormats.length > 0
};
}
/**
* Sync node data from Mycelium Grid
*/
/**
* Show default slice details modal
*/
function showDefaultSliceDetailsModal(formatData) {
console.log('🍰 Showing default slice details modal:', formatData);
const modalHtml = `
<div class="modal fade" id="defaultSliceDetailsModal" tabindex="-1" aria-labelledby="defaultSliceDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="defaultSliceDetailsModalLabel">
<i class="bi bi-layers me-2"></i>Default Slice Details: ${formatData.name}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<h6>Resource Specifications</h6>
<table class="table table-sm">
<tr><td><strong>CPU Cores:</strong></td><td>${formatData.cpu_cores} vCPU</td></tr>
<tr><td><strong>Memory:</strong></td><td>${formatData.memory_gb} GB RAM</td></tr>
<tr><td><strong>Storage:</strong></td><td>${formatData.storage_gb} GB</td></tr>
<tr><td><strong>Bandwidth:</strong></td><td>${formatData.bandwidth_mbps} Mbps</td></tr>
<tr><td><strong>Public IPs:</strong></td><td>0</td></tr>
<tr><td><strong>Min Uptime SLA:</strong></td><td>99.0%</td></tr>
</table>
</div>
<div class="col-md-6">
<h6>Pricing Information</h6>
<table class="table table-sm">
<tr><td><strong>Hourly Rate:</strong></td><td>${formatData.price_per_hour} TFP/hour</td></tr>
<tr><td><strong>Daily Rate:</strong></td><td>${formatData.price_per_hour * 24} TFP/day</td></tr>
<tr><td><strong>Monthly Rate:</strong></td><td>${formatData.price_per_hour * 24 * 30} TFP/month</td></tr>
<tr><td><strong>Yearly Rate:</strong></td><td>${formatData.price_per_hour * 24 * 365} TFP/year</td></tr>
</table>
<div class="alert alert-info mt-3">
<small><i class="bi bi-info-circle me-1"></i>Default slices have fixed resource specifications. Only pricing can be customized.</small>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<h6>Description</h6>
<p class="text-muted">${formatData.description}</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" onclick="editDefaultSliceConfiguration('${formatData.id}'); bootstrap.Modal.getInstance(document.getElementById('defaultSliceDetailsModal')).hide();">
<i class="bi bi-pencil me-2"></i>Edit Configuration
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('defaultSliceDetailsModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('defaultSliceDetailsModal'));
modal.show();
// Clean up when modal is hidden
document.getElementById('defaultSliceDetailsModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
/**
* Show default slice edit modal
*/
function showDefaultSliceEditModal(formatData) {
console.log('🍰 Showing default slice edit modal:', formatData);
const modalHtml = `
<div class="modal fade" id="defaultSliceEditModal" tabindex="-1" aria-labelledby="defaultSliceEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="defaultSliceEditModalLabel">
<i class="bi bi-pencil me-2"></i>Edit Default Slice: ${formatData.name}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Note:</strong> Default slice resource specifications cannot be modified. You can only customize the pricing.
</div>
<form id="defaultSliceEditForm">
<div class="row">
<div class="col-md-6">
<h6>Resource Specifications (Read-Only)</h6>
<div class="mb-3">
<label class="form-label">CPU Cores</label>
<input type="number" class="form-control" value="${formatData.cpu_cores}" readonly>
</div>
<div class="mb-3">
<label class="form-label">Memory (GB)</label>
<input type="number" class="form-control" value="${formatData.memory_gb}" readonly>
</div>
<div class="mb-3">
<label class="form-label">Storage (GB)</label>
<input type="number" class="form-control" value="${formatData.storage_gb}" readonly>
</div>
<div class="mb-3">
<label class="form-label">Bandwidth (Mbps)</label>
<input type="number" class="form-control" value="${formatData.bandwidth_mbps}" readonly>
</div>
</div>
<div class="col-md-6">
<h6>Pricing Configuration</h6>
<div class="mb-3">
<label for="defaultPriceHour" class="form-label">Hourly Rate (TFP)</label>
<input type="number" class="form-control" id="defaultPriceHour" value="${formatData.price_per_hour}" step="0.01" min="0">
<small class="text-muted">Base hourly rate in TFP</small>
</div>
<div class="mb-3">
<label class="form-label">Calculated Rates</label>
<div class="border rounded p-3 bg-light">
<div class="row">
<div class="col-6"><strong>Daily:</strong></div>
<div class="col-6" id="defaultCalcDaily">${formatData.price_per_hour * 24} TFP</div>
</div>
<div class="row">
<div class="col-6"><strong>Monthly:</strong></div>
<div class="col-6" id="defaultCalcMonthly">${formatData.price_per_hour * 24 * 30} TFP</div>
</div>
<div class="row">
<div class="col-6"><strong>Yearly:</strong></div>
<div class="col-6" id="defaultCalcYearly">${formatData.price_per_hour * 24 * 365} TFP</div>
</div>
</div>
<small class="text-muted">Rates are calculated automatically with no discounts</small>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveDefaultSliceConfiguration('${formatData.id}')">
<i class="bi bi-check-lg me-2"></i>Save Changes
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('defaultSliceEditModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('defaultSliceEditModal'));
modal.show();
// Add real-time calculation updates
const priceHourInput = document.getElementById('defaultPriceHour');
priceHourInput.addEventListener('input', function() {
const hourlyRate = parseFloat(this.value) || 0;
document.getElementById('defaultCalcDaily').textContent = (hourlyRate * 24).toFixed(2) + ' TFP';
document.getElementById('defaultCalcMonthly').textContent = (hourlyRate * 24 * 30).toFixed(0) + ' TFP';
document.getElementById('defaultCalcYearly').textContent = (hourlyRate * 24 * 365).toFixed(0) + ' TFP';
});
// Clean up when modal is hidden
document.getElementById('defaultSliceEditModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
/**
* Save default slice configuration
*/
async function saveDefaultSliceConfiguration(formatId) {
console.log('🍰 Saving default slice configuration:', formatId);
const priceHour = parseFloat(document.getElementById('defaultPriceHour').value) || 0;
if (priceHour <= 0) {
showNotification('Please enter a valid hourly rate', 'warning');
return;
}
const saveBtn = document.querySelector('#defaultSliceEditModal .btn-primary');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
}
try {
// For now, just show success message since we're not persisting default slice customizations
// In a real implementation, this would save to user preferences
showNotification(`Default slice pricing updated: ${priceHour} TFP/hour`, 'success');
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('defaultSliceEditModal'));
if (modal) {
modal.hide();
}
// Reload slice templates to show updated pricing
loadSliceTemplates();
} catch (error) {
console.error('🍰 Error saving default slice configuration:', error);
showNotification('Failed to save configuration', 'error');
} finally {
// Reset button state
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Save Changes';
}
}
}/**
* Show default slice details modal
*/
function showDefaultSliceDetailsModal(formatData) {
console.log('🍰 Showing default slice details modal:', formatData);
const modalHtml = `
<div class="modal fade" id="defaultSliceDetailsModal" tabindex="-1" aria-labelledby="defaultSliceDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="defaultSliceDetailsModalLabel">
<i class="bi bi-layers me-2"></i>Default Slice Details: ${formatData.name}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<h6>Resource Specifications</h6>
<table class="table table-sm">
<tr><td><strong>CPU Cores:</strong></td><td>${formatData.cpu_cores} vCPU</td></tr>
<tr><td><strong>Memory:</strong></td><td>${formatData.memory_gb} GB RAM</td></tr>
<tr><td><strong>Storage:</strong></td><td>${formatData.storage_gb} GB</td></tr>
<tr><td><strong>Bandwidth:</strong></td><td>${formatData.bandwidth_mbps} Mbps</td></tr>
<tr><td><strong>Public IPs:</strong></td><td>0</td></tr>
<tr><td><strong>Min Uptime SLA:</strong></td><td>99.0%</td></tr>
</table>
</div>
<div class="col-md-6">
<h6>Pricing Information</h6>
<table class="table table-sm">
<tr><td><strong>Hourly Rate:</strong></td><td>${formatData.price_per_hour} TFP/hour</td></tr>
<tr><td><strong>Daily Rate:</strong></td><td>${formatData.price_per_hour * 24} TFP/day</td></tr>
<tr><td><strong>Monthly Rate:</strong></td><td>${formatData.price_per_hour * 24 * 30} TFP/month</td></tr>
<tr><td><strong>Yearly Rate:</strong></td><td>${formatData.price_per_hour * 24 * 365} TFP/year</td></tr>
</table>
<div class="alert alert-info mt-3">
<small><i class="bi bi-info-circle me-1"></i>Default slices have fixed resource specifications. Only pricing can be customized.</small>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<h6>Description</h6>
<p class="text-muted">${formatData.description}</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" onclick="editDefaultSliceConfiguration('${formatData.id}'); bootstrap.Modal.getInstance(document.getElementById('defaultSliceDetailsModal')).hide();">
<i class="bi bi-pencil me-2"></i>Edit Configuration
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('defaultSliceDetailsModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('defaultSliceDetailsModal'));
modal.show();
// Clean up when modal is hidden
document.getElementById('defaultSliceDetailsModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
/**
* Show default slice edit modal
*/
function showDefaultSliceEditModal(formatData) {
console.log('🍰 Showing default slice edit modal:', formatData);
const modalHtml = `
<div class="modal fade" id="defaultSliceEditModal" tabindex="-1" aria-labelledby="defaultSliceEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="defaultSliceEditModalLabel">
<i class="bi bi-pencil me-2"></i>Edit Default Slice: ${formatData.name}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Note:</strong> Default slice resource specifications cannot be modified. You can only customize the pricing.
</div>
<form id="defaultSliceEditForm">
<div class="row">
<div class="col-md-6">
<h6>Resource Specifications (Read-Only)</h6>
<div class="mb-3">
<label class="form-label">CPU Cores</label>
<input type="number" class="form-control" value="${formatData.cpu_cores}" readonly>
</div>
<div class="mb-3">
<label class="form-label">Memory (GB)</label>
<input type="number" class="form-control" value="${formatData.memory_gb}" readonly>
</div>
<div class="mb-3">
<label class="form-label">Storage (GB)</label>
<input type="number" class="form-control" value="${formatData.storage_gb}" readonly>
</div>
<div class="mb-3">
<label class="form-label">Bandwidth (Mbps)</label>
<input type="number" class="form-control" value="${formatData.bandwidth_mbps}" readonly>
</div>
</div>
<div class="col-md-6">
<h6>Pricing Configuration</h6>
<div class="mb-3">
<label for="defaultPriceHour" class="form-label">Hourly Rate (TFP)</label>
<input type="number" class="form-control" id="defaultPriceHour" value="${formatData.price_per_hour}" step="0.01" min="0">
<small class="text-muted">Base hourly rate in TFP</small>
</div>
<div class="mb-3">
<label class="form-label">Calculated Rates</label>
<div class="border rounded p-3 bg-light">
<div class="row">
<div class="col-6"><strong>Daily:</strong></div>
<div class="col-6" id="defaultCalcDaily">${formatData.price_per_hour * 24} TFP</div>
</div>
<div class="row">
<div class="col-6"><strong>Monthly:</strong></div>
<div class="col-6" id="defaultCalcMonthly">${formatData.price_per_hour * 24 * 30} TFP</div>
</div>
<div class="row">
<div class="col-6"><strong>Yearly:</strong></div>
<div class="col-6" id="defaultCalcYearly">${formatData.price_per_hour * 24 * 365} TFP</div>
</div>
</div>
<small class="text-muted">Rates are calculated automatically with no discounts</small>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveDefaultSliceConfiguration('${formatData.id}')">
<i class="bi bi-check-lg me-2"></i>Save Changes
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('defaultSliceEditModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('defaultSliceEditModal'));
modal.show();
// Add real-time calculation updates
const priceHourInput = document.getElementById('defaultPriceHour');
priceHourInput.addEventListener('input', function() {
const hourlyRate = parseFloat(this.value) || 0;
document.getElementById('defaultCalcDaily').textContent = (hourlyRate * 24).toFixed(2) + ' TFP';
document.getElementById('defaultCalcMonthly').textContent = (hourlyRate * 24 * 30).toFixed(0) + ' TFP';
document.getElementById('defaultCalcYearly').textContent = (hourlyRate * 24 * 365).toFixed(0) + ' TFP';
});
// Clean up when modal is hidden
document.getElementById('defaultSliceEditModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
/**
* Save default slice configuration
*/
async function saveDefaultSliceConfiguration(formatId) {
console.log('🍰 Saving default slice configuration:', formatId);
const priceHour = parseFloat(document.getElementById('defaultPriceHour').value) || 0;
if (priceHour <= 0) {
showNotification('Please enter a valid hourly rate', 'warning');
return;
}
const saveBtn = document.querySelector('#defaultSliceEditModal .btn-primary');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
}
try {
// For now, just show success message since we're not persisting default slice customizations
// In a real implementation, this would save to user preferences
showNotification(`Default slice pricing updated: ${priceHour} TFP/hour`, 'success');
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('defaultSliceEditModal'));
if (modal) {
modal.hide();
}
// Reload slice templates to show updated pricing
loadSliceTemplates();
} catch (error) {
console.error('🍰 Error saving default slice configuration:', error);
showNotification('Failed to save configuration', 'error');
} finally {
// Reset button state
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Save Changes';
}
}
}
// =============================================================================
// NODE GROUPS MANAGEMENT
// =============================================================================
/**
* Load node groups data
*/
async function loadNodeGroups() {
console.log('📦 Loading node groups');
// Show loading indicator
const nodeGroupsTable = document.getElementById('node-groups-table');
if (nodeGroupsTable) {
nodeGroupsTable.innerHTML = `
<tr>
<td colspan="7" class="text-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Refreshing node groups...
</td>
</tr>
`;
}
try {
const result = await window.apiJson('/api/dashboard/node-groups/api');
console.log('📦 Node groups loaded:', result);
// apiJson returns unwrapped data; support both array and { groups: [...] }
const groups = Array.isArray(result?.groups) ? result.groups : (Array.isArray(result) ? result : []);
updateNodeGroupsTable(groups);
updateNodeGroupSelects(groups);
} catch (error) {
console.error('📦 Error loading node groups:', error);
showNodeGroupsError();
}
}
/**
* Update node groups table
*/
function updateNodeGroupsTable(groups) {
const tbody = document.getElementById('node-groups-table');
if (!tbody) return;
tbody.innerHTML = '';
// Defensive coding: check if groups is defined and is an array
if (!groups || !Array.isArray(groups) || groups.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="text-center text-muted">
<div class="py-4">
<i class="bi bi-collection fs-1 text-muted"></i>
<p class="mt-2">No node groups found</p>
<p class="small">Default groups will be created automatically</p>
</div>
</td>
</tr>
`;
return;
}
groups.forEach(groupData => {
const group = groupData.group;
const stats = groupData.stats;
const row = document.createElement('tr');
// Group type badge
let typeBadge = '';
if (group.group_type && group.group_type.Default) {
const defaultType = group.group_type.Default;
typeBadge = `<span class="badge bg-primary">Default - ${getDefaultGroupDisplayName(defaultType)}</span>`;
} else {
typeBadge = `<span class="badge bg-secondary">Custom</span>`;
}
// Status badge
let statusBadge = '';
if (stats) {
if (stats.online_nodes === stats.total_nodes && stats.total_nodes > 0) {
statusBadge = `<span class="badge bg-success">All Online</span>`;
} else if (stats.online_nodes > 0) {
statusBadge = `<span class="badge bg-warning">Partial</span>`;
} else if (stats.total_nodes > 0) {
statusBadge = `<span class="badge bg-danger">All Offline</span>`;
} else {
statusBadge = `<span class="badge bg-secondary">Empty</span>`;
}
} else {
statusBadge = `<span class="badge bg-secondary">Unknown</span>`;
}
// Resources summary
let resourcesSummary = 'No nodes';
if (stats && stats.total_nodes > 0) {
resourcesSummary = `${stats.total_capacity.cpu_cores} cores, ${stats.total_capacity.memory_gb}GB RAM, ${Math.round(stats.total_capacity.storage_gb/1000)}TB storage`;
}
// Actions
let actions = '';
const gt = group.group_type;
const isCustom = (typeof gt === 'string' && gt === 'Custom') || (gt && typeof gt === 'object' && gt.Custom);
if (isCustom) {
actions = `
<button class="btn btn-sm btn-outline-primary me-1" onclick="manageNodeGroup('${group.id}')">
<i class="bi bi-eye"></i> View
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteCustomNodeGroup('${group.id}')">
<i class="bi bi-trash"></i> Delete
</button>
`;
} else {
actions = `
<button class="btn btn-sm btn-outline-primary" onclick="manageNodeGroup('${group.id}')">
<i class="bi bi-eye"></i> View
</button>
`;
}
row.innerHTML = `
<td>
<strong>${group.name}</strong>
${group.description ? `<br><small class="text-muted">${group.description}</small>` : ''}
</td>
<td>${typeBadge}</td>
<td>
<strong>${stats ? stats.total_nodes : 0}</strong> nodes
${stats && stats.online_nodes !== stats.total_nodes ? `<br><small class="text-muted">${stats.online_nodes} online</small>` : ''}
</td>
<td><small>${resourcesSummary}</small></td>
<td>${statusBadge}</td>
<td>${stats ? `${stats.average_uptime.toFixed(1)}%` : 'N/A'}</td>
<td>${actions}</td>
`;
tbody.appendChild(row);
});
}
/**
* Normalize groups from API entries into defaults and customs with stats
*/
function normalizeGroups(entries) {
const defaults = [];
const customs = [];
(entries || []).forEach(entry => {
const group = entry && (entry.group || entry);
if (!group) return;
const gt = group.group_type;
const isDefault = (gt && typeof gt === 'object' && gt.Default)
|| (typeof gt === 'string' && ['Compute', 'Storage', 'AiGpu', 'AI/GPU', 'Default'].includes(gt));
const isCustom = (gt && typeof gt === 'object' && gt.Custom)
|| (typeof gt === 'string' && gt === 'Custom');
const bucket = isDefault ? defaults : (isCustom ? customs : null);
if (bucket) bucket.push({ group, stats: entry.stats });
});
// Sort alphabetically within sections
const byName = (a, b) => String(a.group?.name || '').localeCompare(String(b.group?.name || ''));
defaults.sort(byName);
customs.sort(byName);
return { defaults, customs };
}
/**
* Update node group select dropdowns
*/
function updateNodeGroupSelects(groups) {
// Exclude #existingGroup here; it is populated by loadExistingGroups()
const selects = document.querySelectorAll('.node-group-select, select[name="node_group"], #nodeGroup, #nodeGroupSelect');
const { defaults, customs } = normalizeGroups(groups);
selects.forEach(select => {
const currentValue = select.value;
select.innerHTML = '<option value="">Single (No Group)</option>';
const append = ({ group, stats }) => {
const total = stats && typeof stats.total_nodes === 'number' ? stats.total_nodes : (group.nodes ? group.nodes.length : undefined);
const isCustom = group.group_type && ((typeof group.group_type === 'string' && group.group_type === 'Custom') || (typeof group.group_type === 'object' && group.group_type.Custom));
const option = document.createElement('option');
option.value = group.id;
option.textContent = `${group.name}${isCustom ? ' (Custom)' : ''}${typeof total === 'number' ? ` (${total} nodes)` : ''}`;
select.appendChild(option);
};
defaults.forEach(append);
customs.forEach(append);
// Restore previous value if it still exists
if (currentValue) {
select.value = currentValue;
}
});
}
/**
* Show node groups loading error
*/
function showNodeGroupsError() {
const tbody = document.getElementById('node-groups-table');
if (!tbody) return;
tbody.innerHTML = `
<tr>
<td colspan="7" class="text-center text-danger">
<div class="py-4">
<i class="bi bi-exclamation-triangle fs-1"></i>
<p class="mt-2">Failed to load node groups</p>
<button class="btn btn-sm btn-outline-primary" onclick="loadNodeGroups()">
<i class="bi bi-arrow-clockwise me-1"></i> Retry
</button>
</div>
</td>
</tr>
`;
}
/**
* Create custom node group
*/
async function createCustomNodeGroup() {
console.log('📦 Creating custom node group');
const form = document.getElementById('createCustomGroupForm');
const formData = new FormData(form);
const name = document.getElementById('customGroupName').value.trim();
const description = document.getElementById('customGroupDescription').value.trim();
const resourceOptimization = document.getElementById('resourceOptimization').value;
const autoScaling = document.getElementById('autoScaling').checked;
// Get selected slice formats
const preferredSliceFormats = [];
document.querySelectorAll('input[type="checkbox"][value]').forEach(checkbox => {
if (checkbox.checked && ['basic', 'standard', 'performance'].includes(checkbox.value)) {
preferredSliceFormats.push(checkbox.value);
}
});
if (!name) {
showNotification('Group name is required', 'error');
return;
}
const createBtn = document.getElementById('createCustomGroupBtn');
createBtn.disabled = true;
createBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
try {
const result = await window.apiJson('/api/dashboard/node-groups/custom', {
method: 'POST',
body: {
name,
description: description || null,
resource_optimization: resourceOptimization,
auto_scaling: autoScaling,
preferred_slice_formats: preferredSliceFormats
}
});
console.log('📦 Custom group created:', result);
// apiJson resolved -> treat as success
showNotification('Custom node group created successfully', 'success');
// Hide modal
const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomNodeGroupModal'));
if (modal) {
modal.hide();
}
// Reset form
form.reset();
// Reload node groups and refresh existing-group select
loadNodeGroups();
try { loadExistingGroups(); } catch (_) {}
} catch (error) {
console.error('📦 Error creating custom group:', error);
showNotification('Failed to create custom group', 'error');
} finally {
createBtn.disabled = false;
createBtn.innerHTML = 'Create Custom Group';
}
}
/**
* Delete custom node group
*/
async function deleteCustomNodeGroup(groupId) {
console.log('📦 Deleting custom node group:', groupId);
// Remove existing modal if present
const existing = document.getElementById('deleteGroupConfirmModal');
if (existing) existing.remove();
// Try to resolve group name for nicer messaging
let groupName = 'this custom node group';
try {
const result = await window.apiJson('/api/dashboard/node-groups/api', { cache: 'no-store' });
const entries = (result && Array.isArray(result.groups)) ? result.groups : (Array.isArray(result) ? result : []);
const entry = entries.find(e => String((e && e.group && e.group.id) ? e.group.id : e && e.id) === String(groupId));
const group = entry && (entry.group || entry);
if (group && group.name) groupName = `"${group.name}"`;
} catch (_) {}
const modalHtml = `
<div class="modal fade" id="deleteGroupConfirmModal" tabindex="-1" aria-labelledby="deleteGroupConfirmLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-danger">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteGroupConfirmLabel"><i class="bi bi-exclamation-triangle me-2"></i>Delete Node Group</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex align-items-start gap-3">
<i class="bi bi-trash text-danger" style="font-size:1.5rem;"></i>
<div>
<p class="mb-1">Are you sure you want to permanently delete the group <strong>${groupName}</strong>?</p>
<ul class="text-muted small mb-0">
<li>Nodes in this group will be moved to <strong>Single (No Group)</strong>.</li>
<li>This action cannot be undone.</li>
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteGroupButton">
<i class="bi bi-trash me-1"></i> Delete Group
</button>
</div>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modalEl = document.getElementById('deleteGroupConfirmModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
const confirmBtn = modalEl.querySelector('#confirmDeleteGroupButton');
const onHidden = () => { modalEl.removeEventListener('hidden.bs.modal', onHidden); modalEl.remove(); };
modalEl.addEventListener('hidden.bs.modal', onHidden);
confirmBtn.addEventListener('click', async () => {
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Deleting...';
try {
const result = await window.apiJson(`/api/dashboard/node-groups/${groupId}`, { method: 'DELETE' });
console.log('📦 Custom group deleted:', result);
showNotification('Custom node group deleted successfully', 'success');
modal.hide();
setTimeout(() => { loadNodeGroups(); try { loadExistingGroups(); } catch (_) {} loadResourceProviderData(); }, 100);
} catch (error) {
console.error('📦 Error deleting custom group:', error);
showNotification('Failed to delete custom group', 'error');
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="bi bi-trash me-1"></i> Delete Group';
}
}, { once: true });
modal.show();
}
/**
* Manage node group (placeholder)
*/
async function manageNodeGroup(groupId) {
console.log('📦 Managing node group:', groupId);
try {
// Load group details from detailed API and find by id
const result = await window.apiJson('/api/dashboard/node-groups/api', { cache: 'no-store' });
const entries = (result && Array.isArray(result.groups)) ? result.groups : (Array.isArray(result) ? result : []);
const entry = entries.find(e => String((e && e.group && e.group.id) ? e.group.id : e && e.id) === String(groupId));
if (!entry) {
showNotification('Failed to load group details', 'error');
return;
}
// Normalize shapes
const group = entry.group || entry;
const stats = entry.stats || {};
const nodes = Array.isArray(entry.nodes) ? entry.nodes : null;
const nodeIds = Array.isArray(group.node_ids) ? group.node_ids : (nodes ? nodes.map(n => n.id) : []);
// Build a detailed, read-only details modal dynamically
const existing = document.getElementById('manageNodeGroupModal');
if (existing) existing.remove();
const modalHtml = `
<div class="modal fade" id="manageNodeGroupModal" tabindex="-1" aria-labelledby="manageNodeGroupModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="manageNodeGroupModalLabel">
<i class="bi bi-eye me-2"></i>Group Details: ${group.name || 'Group'}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div class="d-flex gap-3 flex-wrap">
<span class="badge bg-secondary">${(typeof group.group_type === 'string' && group.group_type === 'Custom') || (group.group_type && group.group_type.Custom) ? 'Custom' : ((group.group_type && group.group_type.Default) ? 'Default' : 'Group')}</span>
<span class="badge bg-info">${(stats.total_nodes ?? (nodeIds ? nodeIds.length : 0))} nodes</span>
${typeof stats.online_nodes === 'number' ? `<span class=\"badge bg-success\">${stats.online_nodes} online</span>` : ''}
</div>
</div>
${group.description ? `<p class="text-muted">${group.description}</p>` : ''}
<div class="row g-3">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6 class="card-title">Statistics</h6>
<ul class="list-unstyled small mb-0">
<li><strong>Total nodes:</strong> ${stats.total_nodes ?? (nodeIds ? nodeIds.length : 0)}</li>
${typeof stats.online_nodes === 'number' ? `<li><strong>Online:</strong> ${stats.online_nodes}</li>` : ''}
${stats.average_uptime !== undefined ? `<li><strong>Avg. uptime:</strong> ${Number(stats.average_uptime).toFixed(1)}%</li>` : ''}
${stats.total_capacity ? `
<li><strong>CPU cores:</strong> ${stats.total_capacity.cpu_cores}</li>
<li><strong>Memory:</strong> ${stats.total_capacity.memory_gb} GB</li>
<li><strong>Storage:</strong> ${Math.round(stats.total_capacity.storage_gb/1000)} TB</li>
` : ''}
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6 class="card-title">Nodes</h6>
${nodeIds && nodeIds.length > 0 ? `
<div class="table-responsive" style="max-height: 240px;">
<table class="table table-sm align-middle mb-0">
<thead>
<tr><th>ID</th><th style="width:1%"></th></tr>
</thead>
<tbody>
${nodeIds.map(id => `
<tr>
<td><code>${id}</code></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="viewNodeDetails('${id}')" title="View Node"><i class="bi bi-eye"></i></button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : '<p class="text-muted small mb-0">No nodes in this group.</p>'}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modalEl = document.getElementById('manageNodeGroupModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
} catch (error) {
console.error('📦 Error opening manage group modal:', error);
showNotification('Failed to open manage group modal', 'error');
}
}
/**
* Assign node to group
*/
async function assignNodeToGroup(nodeId, groupId) {
console.log('📦 Assigning node to group:', nodeId, groupId);
try {
const result = await window.apiJson('/api/dashboard/nodes/assign-group', {
method: 'POST',
body: {
node_id: nodeId,
group_id: groupId || null
}
});
console.log('📦 Node assignment updated:', result);
// apiJson resolved -> treat as success
showNotification('Node assignment updated successfully', 'success');
// FARMER FIX: Add small delay to ensure backend processing is complete
setTimeout(() => {
loadResourceProviderData(); // Reload to show updated assignments
loadNodeGroups(); // Reload to update group statistics
}, 100);
} catch (error) {
console.error('📦 Error updating node assignment:', error);
showNotification('Failed to update node assignment', 'error');
}
}
/**
* Get display name for default group types
*/
function getDefaultGroupDisplayName(defaultType) {
switch (defaultType) {
case 'Compute': return 'Compute';
case 'Storage': return 'Storage';
case 'AiGpu': return 'AI/GPU';
default: return defaultType;
}
}
/**
* View node group details
*/
async function viewNodeGroupDetails(groupId) {
console.log('📦 Viewing node group details:', groupId);
try {
// Load from list API and select the group client-side
const result = await window.apiJson('/api/dashboard/node-groups', { cache: 'no-store' });
const groups = (result && Array.isArray(result.groups)) ? result.groups : (Array.isArray(result) ? result : []);
const group = groups.find(g => String(g.id) === String(groupId));
if (!group) {
showNotification('Failed to load group details', 'error');
return;
}
console.log('📦 Group details loaded:', group);
// Show group details (simple toast for now)
const total = (group.stats && typeof group.stats.total_nodes === 'number') ? group.stats.total_nodes : (Array.isArray(group.nodes) ? group.nodes.length : 0);
showNotification(`Group: ${group.name} - ${total} nodes`, 'info');
} catch (error) {
console.error('📦 Error loading group details:', error);
showNotification('Failed to load group details', 'error');
}
}
/**
* Edit node group
*/
async function editNodeGroup(groupId) {
console.log('📦 Editing node group:', groupId);
// For now, just show a notification - you can implement a full edit modal
showNotification('Group editing functionality coming soon', 'info');
}
/**
* Delete node group
*/
async function deleteNodeGroup(groupId) {
console.log('📦 Deleting node group:', groupId);
if (!confirm('Are you sure you want to delete this custom node group? This action cannot be undone.')) {
return;
}
try {
await window.apiJson(`/api/dashboard/node-groups/${groupId}`, {
method: 'DELETE'
});
showNotification('Node group deleted successfully', 'success');
// Reload node groups
loadNodeGroups();
} catch (error) {
console.error('📦 Error deleting node group:', error);
showNotification('Failed to delete node group', 'error');
}
}
// Initialize custom group modal event listeners
document.addEventListener('DOMContentLoaded', function() {
const createCustomGroupBtn = document.getElementById('createCustomGroupBtn');
if (createCustomGroupBtn) {
createCustomGroupBtn.addEventListener('click', createCustomNodeGroup);
}
// Global event delegation for dashboard actions
document.addEventListener('click', function(event) {
// Refresh slice calculations buttons
if (event.target.matches('[data-action="slice.refresh"]') || event.target.closest('[data-action="slice.refresh"]')) {
event.preventDefault();
try { refreshSliceCalculations(); } catch (e) { console.error(e); }
return;
}
// Sync with Grid buttons
if (event.target.matches('[data-action="grid.sync"]') || event.target.closest('[data-action="grid.sync"]')) {
event.preventDefault();
try { syncWithGrid(); } catch (e) { console.error(e); }
return;
}
// Node view details buttons
if (event.target.matches('[data-action="node.view"]') || event.target.closest('[data-action="node.view"]')) {
event.preventDefault();
const button = event.target.matches('[data-action="node.view"]') ? event.target : event.target.closest('[data-action="node.view"]');
const nodeId = button && button.getAttribute('data-node-id');
if (nodeId) {
try { viewNodeDetails(nodeId); } catch (e) { console.error(e); }
}
return;
}
// Create Custom Group modal: programmatic fallback show to avoid darken-only issue
if (event.target.matches('[data-bs-target="#createCustomNodeGroupModal"]') || event.target.closest('[data-bs-target="#createCustomNodeGroupModal"]')) {
// Stop Bootstrap's own delegated handler to avoid double show/backdrop
try { event.preventDefault(); event.stopPropagation(); if (event.stopImmediatePropagation) event.stopImmediatePropagation(); } catch (_) {}
const modalEl = document.getElementById('createCustomNodeGroupModal');
if (modalEl && window.bootstrap && window.bootstrap.Modal) {
try {
// Ensure modal is attached directly to <body> to avoid stacking context/overflow issues
if (modalEl.parentElement !== document.body) {
document.body.appendChild(modalEl);
}
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
} catch (e) {
console.error('Error showing Create Custom Group modal:', e);
showNotification('Failed to open Create Custom Group modal', 'error');
}
} else {
console.error('Create Custom Group modal element or Bootstrap missing');
showNotification('Failed to open Create Custom Group modal', 'error');
}
return;
}
});
});
// =============================================================================
// STAKING MANAGEMENT FUNCTIONS
// =============================================================================
/**
* Initialize staking management functionality
*/
function initializeStakingManagement() {
console.log('🛡️ Initializing staking management');
// Initialize staking toggles
initializeStakingToggles();
// Initialize staking validation
initializeStakingValidation();
// Load user wallet balance
loadWalletBalance();
// Update staking display
updateStakingDisplay();
// Initialize staking API functions
initializeStakingAPI();
}
/**
* Initialize staking API functions
*/
function initializeStakingAPI() {
console.log('🛡️ Initializing staking API functions');
// Add event listeners for staking actions
document.addEventListener('click', function(event) {
// Handle stake button clicks
if (event.target.matches('[data-action="stake-node"]') || event.target.closest('[data-action="stake-node"]')) {
const button = event.target.matches('[data-action="stake-node"]') ? event.target : event.target.closest('[data-action="stake-node"]');
const nodeId = button.getAttribute('data-node-id');
if (nodeId) {
showStakeNodeModal(nodeId);
}
}
// Handle unstake button clicks
if (event.target.matches('[data-action="unstake-node"]') || event.target.closest('[data-action="unstake-node"]')) {
const button = event.target.matches('[data-action="unstake-node"]') ? event.target : event.target.closest('[data-action="unstake-node"]');
const nodeId = button.getAttribute('data-node-id');
if (nodeId) {
unstakeFromNode(nodeId);
}
}
// Handle update stake button clicks
if (event.target.matches('[data-action="update-stake"]') || event.target.closest('[data-action="update-stake"]')) {
const button = event.target.matches('[data-action="update-stake"]') ? event.target : event.target.closest('[data-action="update-stake"]');
const nodeId = button.getAttribute('data-node-id');
if (nodeId) {
showUpdateStakeModal(nodeId);
}
}
});
}
/**
* Initialize staking toggle functionality
*/
function initializeStakingToggles() {
// Add node modal staking toggle
const enableStaking = document.getElementById('enableStaking');
const stakingConfigSection = document.getElementById('stakingConfigSection');
const multiNodeStakingOptions = document.getElementById('multiNodeStakingOptions');
if (enableStaking && stakingConfigSection) {
enableStaking.addEventListener('change', function() {
if (this.checked) {
stakingConfigSection.style.display = 'block';
loadWalletBalance();
// Show multi-node options if multiple nodes detected
const nodePreviewContent = document.getElementById('nodePreviewContent');
if (nodePreviewContent) {
const nodeCards = nodePreviewContent.querySelectorAll('.card');
if (nodeCards.length > 1 && multiNodeStakingOptions) {
multiNodeStakingOptions.style.display = 'block';
}
}
} else {
stakingConfigSection.style.display = 'none';
if (multiNodeStakingOptions) {
multiNodeStakingOptions.style.display = 'none';
}
}
});
console.log('🛡️ Add Node modal staking toggle initialized');
} else {
console.warn('🛡️ Add Node modal staking elements not found:', {
enableStaking: !!enableStaking,
stakingConfigSection: !!stakingConfigSection
});
}
// Edit node modal staking toggle
const editStakingEnabled = document.getElementById('editStakingEnabled');
const editStakingConfigSection = document.getElementById('editStakingConfigSection');
if (editStakingEnabled && editStakingConfigSection) {
editStakingEnabled.addEventListener('change', function() {
if (this.checked) {
editStakingConfigSection.style.display = 'block';
loadWalletBalance();
} else {
editStakingConfigSection.style.display = 'none';
}
});
}
// Multi-node staking radio buttons
const sameStakingForAllNodes = document.getElementById('sameStakingForAllNodes');
const individualStaking = document.getElementById('individualStaking');
if (sameStakingForAllNodes && individualStaking) {
sameStakingForAllNodes.addEventListener('change', function() {
if (this.checked) {
cleanupIndividualStakingSections();
}
});
individualStaking.addEventListener('change', function() {
if (this.checked) {
createIndividualStakingForms();
}
});
}
}
/**
* Initialize staking validation
*/
function initializeStakingValidation() {
// Staking amount validation
const stakingAmount = document.getElementById('stakingAmount');
if (stakingAmount) {
stakingAmount.addEventListener('input', function() {
validateStakingAmount(this);
});
}
// Edit staking amount validation
const editStakingAmount = document.getElementById('editStakingAmount');
if (editStakingAmount) {
editStakingAmount.addEventListener('input', function() {
validateStakingAmount(this);
});
}
// Staking period change handler
const stakingPeriod = document.getElementById('stakingPeriod');
if (stakingPeriod) {
stakingPeriod.addEventListener('change', function() {
updateStakingDiscountDisplay(this.value);
});
}
// Edit staking period change handler
const editStakingPeriod = document.getElementById('editStakingPeriod');
if (editStakingPeriod) {
editStakingPeriod.addEventListener('change', function() {
updateStakingPeriodDisplay(this.value);
});
}
}
/**
* Validate staking amount against wallet balance
*/
function validateStakingAmount(input) {
const amount = parseFloat(input.value) || 0;
const availableBalance = parseFloat(window.userWalletBalance) || 0;
// Remove existing validation classes
input.classList.remove('is-valid', 'is-invalid');
// Find or create feedback element
let feedback = input.parentNode.querySelector('.invalid-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
input.parentNode.appendChild(feedback);
}
if (amount <= 0) {
input.classList.add('is-invalid');
feedback.textContent = 'Staking amount must be greater than 0';
return false;
} else if (amount > availableBalance) {
input.classList.add('is-invalid');
feedback.textContent = `Insufficient balance. Available: ${availableBalance} TFP`;
return false;
} else {
input.classList.add('is-valid');
feedback.textContent = '';
return true;
}
}
/**
* Update staking period display (removed discount functionality)
*/
function updateStakingPeriodDisplay(period) {
// Staking is now only for slashing protection, no discounts
console.log(`🛡️ Staking period set to ${period} months for slashing protection`);
return period;
}
/**
* Load user wallet balance
*/
async function loadWalletBalance() {
try {
const data = await window.apiJson('/api/dashboard/user-data');
// Fix: Access wallet balance from the correct nested structure
const walletBalance = data.wallet_summary?.balance || data.wallet_balance || 0;
// Store globally for validation
window.userWalletBalance = walletBalance;
// Update balance displays in staking modals
const availableBalanceElements = document.querySelectorAll('#availableBalance, #editAvailableBalance');
availableBalanceElements.forEach(el => {
el.textContent = walletBalance;
});
// Update main dashboard wallet balance
const walletBalanceElement = document.getElementById('wallet-balance');
if (walletBalanceElement) {
walletBalanceElement.textContent = walletBalance;
}
console.log('💰 Wallet balance loaded:', walletBalance, 'TFP');
} catch (error) {
console.error('❌ Error loading wallet balance:', error);
}
}
/**
* Validate staking amount input
*/
function validateStakingAmount(input) {
const amount = parseFloat(input.value);
const walletBalance = window.userWalletBalance || 0;
const nodeData = window.currentEditingNode;
const currentStaked = nodeData?.staking_options?.staked_amount || 0;
// Clear previous validation
input.classList.remove('is-invalid', 'is-valid');
// Check if amount is valid number
if (isNaN(amount) || amount <= 0) {
input.classList.add('is-invalid');
showValidationError(input, 'Please enter a valid staking amount greater than 0');
return false;
}
// Check minimum staking amount (1 TFP)
if (amount < 1) {
input.classList.add('is-invalid');
showValidationError(input, 'Minimum staking amount is 1 TFP');
return false;
}
// Check if user has sufficient balance for additional staking
const additionalStakeNeeded = amount - currentStaked;
if (additionalStakeNeeded > 0 && additionalStakeNeeded > walletBalance) {
input.classList.add('is-invalid');
showValidationError(input, `Insufficient balance. Available: ${walletBalance} TFP, Required: ${additionalStakeNeeded} TFP`);
return false;
}
// Validation passed
input.classList.add('is-valid');
clearValidationError(input);
return true;
}
/**
* Show validation error for input field
*/
function showValidationError(input, message) {
// Remove existing error message
clearValidationError(input);
// Create error message element
const errorDiv = document.createElement('div');
errorDiv.className = 'invalid-feedback';
errorDiv.textContent = message;
errorDiv.id = input.id + '_error';
// Insert after input
input.parentNode.insertBefore(errorDiv, input.nextSibling);
}
/**
* Clear validation error for input field
*/
function clearValidationError(input) {
const errorElement = document.getElementById(input.id + '_error');
if (errorElement) {
errorElement.remove();
}
}
/**
* Update staking display in dashboard
*/
async function updateStakingDisplay() {
try {
const data = await window.apiJson('/api/dashboard/resource_provider-data');
const nodes = data.nodes || [];
// Calculate staking statistics
let totalStaked = 0;
let stakedNodes = 0;
let nextUnlockDate = null;
let averageDiscount = 0; // Initialize averageDiscount to prevent undefined error
nodes.forEach(node => {
if (node.staking_options && node.staking_options.staking_enabled) {
totalStaked += parseFloat(node.staking_options.staked_amount) || 0;
stakedNodes++;
// Calculate discount if available
if (node.staking_options.discount_percentage) {
averageDiscount += parseFloat(node.staking_options.discount_percentage) || 0;
}
// Find earliest unlock date
if (node.staking_options.staking_start_date) {
const endDate = new Date(node.staking_options.staking_start_date);
endDate.setMonth(endDate.getMonth() + (node.staking_options.staking_period_months || 12));
if (!nextUnlockDate || endDate < nextUnlockDate) {
nextUnlockDate = endDate;
}
}
}
});
// Calculate average discount
if (stakedNodes > 0) {
averageDiscount = averageDiscount / stakedNodes;
// Update staked nodes count (replaces discount display)
const stakedNodesElement = document.getElementById('staked-nodes-count');
if (stakedNodesElement) {
stakedNodesElement.textContent = stakedNodes;
}
// Legacy elements for backward compatibility
const legacyTotalStakedElement = document.getElementById('total-staked-amount');
if (legacyTotalStakedElement) {
legacyTotalStakedElement.textContent = `${totalStaked} TFP`;
}
const stakingDiscountElement = document.getElementById('staking-discount');
if (stakingDiscountElement) {
stakingDiscountElement.textContent = `Staking Discount: ${averageDiscount.toFixed(1)}%`;
}
const stakingNodesElement = document.getElementById('staking-nodes');
if (stakingNodesElement) {
stakingNodesElement.textContent = `Staked Nodes: ${stakedNodes}`;
}
const nextUnlockElement = document.getElementById('next-unlock');
if (nextUnlockElement) {
if (nextUnlockDate) {
nextUnlockElement.textContent = `Next Unlock: ${nextUnlockDate.toLocaleDateString()}`;
} else {
nextUnlockElement.textContent = 'Next Unlock: -';
}
}
console.log('🛡️ Staking display updated:', {
totalStaked,
stakedNodes,
averageDiscount: averageDiscount.toFixed(1) + '%'
});
}
} catch (error) {
console.error('❌ Error updating staking display:', error);
}
}
/**
* Create individual staking forms for multiple nodes
*/
function createIndividualStakingForms() {
console.log('🛡️ Creating individual staking forms');
// Clean up existing forms first
cleanupIndividualStakingSections();
// Get nodes from preview
const nodePreviewContent = document.getElementById('nodePreviewContent');
if (!nodePreviewContent) {
console.warn('🛡️ No node preview content found');
return;
}
const nodeCards = nodePreviewContent.querySelectorAll('.card');
if (nodeCards.length === 0) {
console.warn('🛡️ No nodes found in preview');
return;
}
// Find the staking config section
const stakingConfigSection = document.getElementById('stakingConfigSection');
if (!stakingConfigSection) {
console.warn('🛡️ Staking config section not found');
return;
}
// Hide the general staking inputs
const stakingAmount = document.getElementById('stakingAmount');
const stakingPeriod = document.getElementById('stakingPeriod');
if (stakingAmount && stakingPeriod) {
stakingAmount.closest('.row').style.display = 'none';
}
// Create individual staking table
const tableContainer = document.createElement('div');
tableContainer.id = 'individualStakingContainer';
tableContainer.className = 'mt-3';
let tableHTML = `
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Individual Node Staking:</strong> Set different staking amounts for each node.
</div>
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>Node</th>
<th>Staking Amount (TFP)</th>
<th>Staking Period</th>
<th>Discount</th>
</tr>
</thead>
<tbody>
`;
// Add a row for each node
nodeCards.forEach((card, index) => {
// Extract node ID from card content
const nodeIdMatch = card.textContent.match(/Grid Node (\d+)/);
const nodeId = nodeIdMatch ? `grid_${nodeIdMatch[1]}` : `node_${index}`;
const nodeName = nodeIdMatch ? `Node ${nodeIdMatch[1]}` : `Node ${index + 1}`;
tableHTML += `
<tr>
<td><strong>${nodeName}</strong></td>
<td>
<input type="number" class="form-control individual-staking-amount"
id="staking_amount_${nodeId}"
data-node-id="${nodeId}"
step="1" min="0" placeholder="0">
</td>
<td>
<select class="form-select individual-staking-period"
id="staking_period_${nodeId}"
data-node-id="${nodeId}">
<option value="3">3 Months</option>
<option value="6">6 Months</option>
<option value="12" selected>12 Months</option>
<option value="24">24 Months</option>
</select>
</td>
<td>
<span class="badge bg-success individual-staking-discount"
id="staking_discount_${nodeId}">10%</span>
</td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
</div>
`;
tableContainer.innerHTML = tableHTML;
// Insert the table into the staking config section
stakingConfigSection.querySelector('.card-body').appendChild(tableContainer);
// Add event listeners for validation and discount updates
nodeCards.forEach((card, index) => {
const nodeIdMatch = card.textContent.match(/Grid Node (\d+)/);
const nodeId = nodeIdMatch ? `grid_${nodeIdMatch[1]}` : `node_${index}`;
const amountInput = document.getElementById(`staking_amount_${nodeId}`);
const periodSelect = document.getElementById(`staking_period_${nodeId}`);
const discountBadge = document.getElementById(`staking_discount_${nodeId}`);
if (amountInput) {
amountInput.addEventListener('input', function() {
validateStakingAmount(this);
});
}
if (periodSelect && discountBadge) {
periodSelect.addEventListener('change', function() {
const discountMap = {
'3': '5%',
'6': '8%',
'12': '10%',
'24': '15%'
};
discountBadge.textContent = discountMap[this.value] || '10%';
});
}
});
console.log('🛡️ Individual staking forms created successfully');
}
/**
* Clean up individual staking sections
*/
function cleanupIndividualStakingSections() {
const existingContainer = document.getElementById('individualStakingContainer');
if (existingContainer) {
existingContainer.remove();
}
// Show the general staking inputs again
const stakingAmount = document.getElementById('stakingAmount');
if (stakingAmount) {
const row = stakingAmount.closest('.row');
if (row) {
row.style.display = '';
}
}
}
/**
* Get staking configuration data for form submission
*/
function getStakingConfiguration() {
const enableStaking = document.getElementById('enableStaking');
if (!enableStaking || !enableStaking.checked) {
return null;
}
// Check if individual staking is enabled
const individualStaking = document.getElementById('individualStaking');
if (individualStaking && individualStaking.checked) {
return getIndividualStakingConfiguration();
} else {
return getGeneralStakingConfiguration();
}
}
/**
* Get general staking configuration
*/
function getGeneralStakingConfiguration() {
const stakingAmount = document.getElementById('stakingAmount');
const stakingPeriod = document.getElementById('stakingPeriod');
const earlyWithdrawalAllowed = document.getElementById('earlyWithdrawalAllowed');
if (!stakingAmount || !stakingPeriod) {
return null;
}
const amount = parseFloat(stakingAmount.value) || 0;
const period = parseInt(stakingPeriod.value) || 12;
const earlyWithdrawal = earlyWithdrawalAllowed ? earlyWithdrawalAllowed.checked : true;
if (amount <= 0) {
return null;
}
return {
staking_enabled: true,
staked_amount: amount,
staking_period_months: period,
early_withdrawal_allowed: earlyWithdrawal,
early_withdrawal_penalty_percent: 25.0
};
}
/**
* Get individual staking configuration
*/
function getIndividualStakingConfiguration() {
const individualAmounts = document.querySelectorAll('.individual-staking-amount');
const stakingConfig = {};
individualAmounts.forEach(input => {
const nodeId = input.dataset.nodeId;
const amount = parseFloat(input.value) || 0;
if (amount > 0) {
const periodSelect = document.getElementById(`staking_period_${nodeId}`);
const period = periodSelect ? parseInt(periodSelect.value) || 12 : 12;
stakingConfig[nodeId] = {
staking_enabled: true,
staked_amount: amount,
staking_period_months: period,
early_withdrawal_allowed: true,
early_withdrawal_penalty_percent: 25.0
};
}
});
return Object.keys(stakingConfig).length > 0 ? stakingConfig : null;
}
/**
* Handle manage staking button click
*/
function handleManageStaking() {
console.log('🛡️ Manage staking clicked');
// This could open a dedicated staking management modal
// For now, we'll just refresh the staking display
updateStakingDisplay();
}
// Add event listener for manage staking button
document.addEventListener('DOMContentLoaded', function() {
const manageStakingBtn = document.getElementById('manageStakingBtn');
if (manageStakingBtn) {
manageStakingBtn.addEventListener('click', handleManageStaking);
}
});
// =============================================================================
// STAKING API FUNCTIONS
// =============================================================================
/**
* Show stake node modal
*/
function showStakeNodeModal(nodeId) {
console.log('🛡️ Showing stake modal for node:', nodeId);
// Find the node data
const node = window.resourceProviderData?.nodes?.find(n => n.id === nodeId);
if (!node) {
showNotification('Node not found', 'error');
return;
}
// Check if node is already staked
if (node.staking_options && node.staking_options.staking_enabled) {
showNotification('Node is already staked. Use "Update Stake" to modify.', 'warning');
return;
}
// Create modal HTML
const modalHTML = `
<div class="modal fade" id="stakeNodeModal" tabindex="-1" aria-labelledby="stakeNodeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="stakeNodeModalLabel">
<i class="bi bi-shield-check me-2"></i>Stake TFP on Node
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Staking Benefits:</strong> Stake TFP to earn discounts on node operations and support network security.
</div>
<div class="mb-3">
<h6>Node Information</h6>
<p><strong>Node:</strong> ${node.name || node.id}</p>
<p><strong>Location:</strong> ${node.location || 'Unknown'}</p>
<p><strong>Status:</strong> ${node.status || 'Unknown'}</p>
</div>
<form id="stakeNodeForm">
<div class="mb-3">
<label for="stakeAmount" class="form-label">Staking Amount (TFP)</label>
<input type="number" class="form-control" id="stakeAmount" min="1" step="1" required>
<small class="text-muted">Available Balance: <span id="availableBalanceStake">Loading...</span> TFP</small>
</div>
<div class="mb-3">
<label for="stakePeriod" class="form-label">Staking Period</label>
<select class="form-select" id="stakePeriod" required>
<option value="3">3 Months (5% discount)</option>
<option value="6">6 Months (8% discount)</option>
<option value="12" selected>12 Months (10% discount)</option>
<option value="24">24 Months (15% discount)</option>
</select>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="allowEarlyWithdrawal" checked>
<label class="form-check-label" for="allowEarlyWithdrawal">
Allow early withdrawal (with penalty)
</label>
</div>
</div>
<div class="alert alert-warning">
<small><strong>Note:</strong> Staked TFP will be deducted from your wallet balance and locked for the selected period.</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="confirmStakeBtn">
<i class="bi bi-shield-check me-2"></i>Stake TFP
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('stakeNodeModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Load wallet balance
loadWalletBalanceForStaking();
// Show modal
const modal = new bootstrap.Modal(document.getElementById('stakeNodeModal'));
modal.show();
// Add event listener for confirm button
document.getElementById('confirmStakeBtn').addEventListener('click', () => {
stakeOnNode(nodeId, modal);
});
}
/**
* Stake TFP on a node
*/
async function stakeOnNode(nodeId, modal) {
console.log('🛡️ Staking TFP on node:', nodeId);
const stakeAmount = document.getElementById('stakeAmount').value;
const stakePeriod = document.getElementById('stakePeriod').value;
const allowEarlyWithdrawal = document.getElementById('allowEarlyWithdrawal').checked;
if (!stakeAmount || stakeAmount <= 0) {
showNotification('Please enter a valid staking amount', 'error');
return;
}
// Disable button during request
const confirmBtn = document.getElementById('confirmStakeBtn');
const originalText = confirmBtn.innerHTML;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Staking...';
try {
const result = await window.apiJson(`/api/dashboard/farm-nodes/${nodeId}/stake`, {
method: 'POST',
body: {
staked_amount: stakeAmount,
staking_period_months: parseInt(stakePeriod),
early_withdrawal_allowed: allowEarlyWithdrawal
}
});
// apiJson resolved -> treat as success
showNotification(`Successfully staked ${stakeAmount} TFP on node`, 'success');
modal.hide();
// Refresh resource_provider data to show updated staking
await loadResourceProviderData();
// Update wallet balance display
await loadWalletBalance();
// Update staking statistics display
await updateStakingDisplay();
} catch (error) {
console.error('🛡️ Error staking TFP:', error);
showNotification(`Failed to stake TFP: ${error.message}`, 'error');
} finally {
// Re-enable button
confirmBtn.disabled = false;
confirmBtn.innerHTML = originalText;
}
}
/**
* Unstake TFP from a node
*/
async function unstakeFromNode(nodeId) {
console.log('🛡️ Unstaking TFP from node:', nodeId);
// Find the node data
const node = window.resourceProviderData?.nodes?.find(n => n.id === nodeId);
if (!node || !node.staking_options || !node.staking_options.staking_enabled) {
showNotification('Node is not currently staked', 'error');
return;
}
const stakedAmount = node.staking_options.staked_amount;
const earlyWithdrawal = !node.staking_options.is_staking_period_ended;
let confirmMessage = `Are you sure you want to unstake ${stakedAmount} TFP from this node?`;
if (earlyWithdrawal) {
confirmMessage += '\n\nNote: Early withdrawal penalty may apply.';
}
if (!confirm(confirmMessage)) {
return;
}
try {
const result = await window.apiJson(`/api/dashboard/farm-nodes/${nodeId}/staking`, {
method: 'PUT',
body: {
action: 'unstake'
}
});
// apiJson resolved -> treat as success
const returnedAmount = result?.returned_amount || stakedAmount;
showNotification(`Successfully unstaked ${returnedAmount} TFP from node`, 'success');
// Refresh resource_provider data to show updated staking
await loadResourceProviderData();
// Update wallet balance display
await loadWalletBalance();
} catch (error) {
console.error('🛡️ Error unstaking TFP:', error);
showNotification(`Failed to unstake TFP: ${error.message}`, 'error');
}
}
/**
* Show update stake modal
*/
function showUpdateStakeModal(nodeId) {
console.log('🛡️ Showing update stake modal for node:', nodeId);
// Find the node data
const node = window.resourceProviderData?.nodes?.find(n => n.id === nodeId);
if (!node || !node.staking_options || !node.staking_options.staking_enabled) {
showNotification('Node is not currently staked', 'error');
return;
}
const currentStake = node.staking_options.staked_amount;
const currentPeriod = node.staking_options.staking_period_months;
// Create modal HTML
const modalHTML = `
<div class="modal fade" id="updateStakeModal" tabindex="-1" aria-labelledby="updateStakeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="updateStakeModalLabel">
<i class="bi bi-pencil-square me-2"></i>Update Node Staking
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<h6>Current Staking</h6>
<p><strong>Amount:</strong> ${currentStake} TFP</p>
<p><strong>Period:</strong> ${currentPeriod} months</p>
</div>
<form id="updateStakeForm">
<div class="mb-3">
<label for="newStakeAmount" class="form-label">New Staking Amount (TFP)</label>
<input type="number" class="form-control" id="newStakeAmount" min="0" step="1" value="${currentStake}" required>
<small class="text-muted">Available Balance: <span id="availableBalanceUpdate">Loading...</span> TFP</small>
</div>
<div class="mb-3">
<label for="newStakePeriod" class="form-label">Staking Period</label>
<select class="form-select" id="newStakePeriod" required>
<option value="3" ${currentPeriod === 3 ? 'selected' : ''}>3 Months (5% discount)</option>
<option value="6" ${currentPeriod === 6 ? 'selected' : ''}>6 Months (8% discount)</option>
<option value="12" ${currentPeriod === 12 ? 'selected' : ''}>12 Months (10% discount)</option>
<option value="24" ${currentPeriod === 24 ? 'selected' : ''}>24 Months (15% discount)</option>
</select>
</div>
<div class="alert alert-info">
<small><strong>Note:</strong> Increasing stake will deduct additional TFP from your wallet. Decreasing stake may incur early withdrawal penalties.</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmUpdateStakeBtn">
<i class="bi bi-check-circle me-2"></i>Update Stake
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('updateStakeModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Load wallet balance
loadWalletBalanceForUpdate();
// Show modal
const modal = new bootstrap.Modal(document.getElementById('updateStakeModal'));
modal.show();
// Add event listener for confirm button
document.getElementById('confirmUpdateStakeBtn').addEventListener('click', () => {
updateNodeStaking(nodeId, modal);
});
}
/**
* Update node staking
*/
async function updateNodeStaking(nodeId, modal) {
console.log('🛡️ Updating staking for node:', nodeId);
const newStakeAmount = document.getElementById('newStakeAmount').value;
const newStakePeriod = document.getElementById('newStakePeriod').value;
if (!newStakeAmount || newStakeAmount < 0) {
showNotification('Please enter a valid staking amount', 'error');
return;
}
// Disable button during request
const confirmBtn = document.getElementById('confirmUpdateStakeBtn');
const originalText = confirmBtn.innerHTML;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Updating...';
try {
const result = await window.apiJson(`/api/dashboard/farm-nodes/${nodeId}/staking`, {
method: 'PUT',
body: {
action: 'update',
staked_amount: newStakeAmount,
staking_period_months: parseInt(newStakePeriod),
early_withdrawal_allowed: true
}
});
// apiJson resolved -> treat as success
showNotification(`Successfully updated staking to ${newStakeAmount} TFP`, 'success');
modal.hide();
// Refresh resource_provider data to show updated staking
await loadResourceProviderData();
// Update wallet balance display
await loadWalletBalance();
} catch (error) {
console.error('🛡️ Error updating staking:', error);
showNotification(`Failed to update staking: ${error.message}`, 'error');
} finally {
// Re-enable button
confirmBtn.disabled = false;
confirmBtn.innerHTML = originalText;
}
}
/**
* Load wallet balance for staking modal
*/
async function loadWalletBalanceForStaking() {
try {
const data = await window.apiJson('/api/dashboard/user-data');
if (data && data.wallet_balance !== undefined) {
const balanceElement = document.getElementById('availableBalanceStake');
if (balanceElement) {
balanceElement.textContent = data.wallet_balance;
}
}
} catch (error) {
console.error('🛡️ Error loading wallet balance for staking:', error);
const balanceElement = document.getElementById('availableBalanceStake');
if (balanceElement) {
balanceElement.textContent = 'Error loading';
}
}
}
/**
* Load wallet balance for update modal
*/
async function loadWalletBalanceForUpdate() {
try {
const data = await window.apiJson('/api/dashboard/user-data');
if (data && data.wallet_balance !== undefined) {
const balanceElement = document.getElementById('availableBalanceUpdate');
if (balanceElement) {
balanceElement.textContent = data.wallet_balance;
}
}
} catch (error) {
console.error('🛡️ Error loading wallet balance for update:', error);
const balanceElement = document.getElementById('availableBalanceUpdate');
if (balanceElement) {
balanceElement.textContent = 'Error loading';
}
}
}
/**
* Format node location - show only country if city is "Unknown"
*/
function formatNodeLocation(nodeData) {
if (!nodeData.grid_data) {
return nodeData.location || 'Unknown';
}
const city = nodeData.grid_data.city;
const country = nodeData.grid_data.country;
if (!city || city === 'Unknown') {
return country || 'Unknown';
}
return `${city}, ${country}`;
}
/**
* Format location display elements on page load
*/
function formatLocationDisplays() {
console.log('🗺️ Formatting location displays');
// Format location spans in both My Nodes and Slice Management tables
const locationElements = document.querySelectorAll('.node-location');
locationElements.forEach(element => {
const city = element.getAttribute('data-city');
const country = element.getAttribute('data-country');
if (!city || city === 'Unknown') {
element.textContent = country || 'Unknown';
} else {
element.textContent = `${city}, ${country}`;
}
});
}
/**
* Format node group - show actual group name instead of "In Group"
*/
function formatNodeGroup(nodeGroupId) {
if (!nodeGroupId) {
return 'Individual';
}
// Map group IDs to readable names
const groupNames = {
'group_1': 'Compute',
'group_2': 'Storage',
'group_3': 'Network',
'single': 'Single',
'compute': 'Compute',
'storage': 'Storage',
'network': 'Network'
};
return groupNames[nodeGroupId] || nodeGroupId;
}
/**
* Get node slice pricing from available combinations or fallback
*/
function getNodeSlicePricing(nodeData) {
// First try to get from available_combinations (most accurate)
if (nodeData.available_combinations && nodeData.available_combinations.length > 0) {
const baseSlice = nodeData.available_combinations.find(combo => combo.multiplier === 1);
if (baseSlice) {
return baseSlice.price_per_hour;
}
}
// Fallback to slice_pricing if available
if (nodeData.slice_pricing && nodeData.slice_pricing.base_price_per_hour) {
return nodeData.slice_pricing.base_price_per_hour;
}
// Default fallback
return '1.00';
}
/**
* Format staking information section
*/
function formatStakingInfo(nodeData) {
if (!nodeData.staking_options || !nodeData.staking_options.staking_enabled) {
return '';
}
const staking = nodeData.staking_options;
const stakedAmount = staking.staked_amount || 0;
const stakingPeriod = staking.staking_period_months || 0;
const startDate = staking.staking_start_date ? new Date(staking.staking_start_date).toLocaleDateString() : 'Unknown';
return `
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #dee2e6;">
<h6 style="color: #495057; margin-bottom: 10px;">
<i class="bi bi-piggy-bank" style="margin-right: 8px; color: #28a745;"></i>Staking Information
</h6>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 4px 0; font-weight: bold;">Staked Amount:</td><td style="padding: 4px 0;"><strong style="color: #28a745;">${stakedAmount} TFP</strong></td></tr>
<tr><td style="padding: 4px 0; font-weight: bold;">Staking Period:</td><td style="padding: 4px 0;">${stakingPeriod} months</td></tr>
</table>
</div>
<div>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 4px 0; font-weight: bold;">Start Date:</td><td style="padding: 4px 0;">${startDate}</td></tr>
<tr><td style="padding: 4px 0; font-weight: bold;">Early Withdrawal:</td><td style="padding: 4px 0;">${staking.early_withdrawal_allowed ? 'Allowed' : 'Not Allowed'}</td></tr>
</table>
</div>
</div>
${staking.early_withdrawal_allowed ? `
<div style="margin-top: 10px; padding: 8px; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 4px; font-size: 12px;">
<i class="bi bi-exclamation-triangle" style="color: #856404; margin-right: 4px;"></i>
Early withdrawal penalty: ${staking.early_withdrawal_penalty_percent || 25}%
</div>
` : ''}
</div>
`;
}