8076 lines
339 KiB
JavaScript
8076 lines
339 KiB
JavaScript
// 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>
|
||
`;
|
||
}
|