diff --git a/Dockerfile b/Dockerfile index 2009414..9c85a0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,16 +21,8 @@ COPY ./index.html ./ COPY ./index.js ./ COPY ./vite.config.js ./ COPY ./src ./src +COPY ./public ./public COPY ./runtime-params.json ./ -COPY ./resource-graph.js ./ -COPY ./network-graph.js ./ -COPY ./dashboard.html ./ -COPY ./dashboard.js ./ -COPY ./favicon.svg ./ -COPY ./redoc-swagger.html ./ -COPY ./swagger.html ./ -COPY ./scalar.html ./ -COPY ./img ./img # Build with Vite RUN npm run build @@ -54,16 +46,8 @@ COPY --from=builder /app/index.html ./ COPY --from=builder /app/index.js ./ COPY --from=builder /app/vite.config.js ./ COPY --from=builder /app/src ./src +COPY --from=builder /app/public ./public COPY --from=builder /app/runtime-params.json ./ -COPY --from=builder /app/resource-graph.js ./ -COPY --from=builder /app/network-graph.js ./ -COPY --from=builder /app/dashboard.html ./ -COPY --from=builder /app/dashboard.js ./ -COPY --from=builder /app/favicon.svg ./ -COPY --from=builder /app/redoc-swagger.html ./ -COPY --from=builder /app/swagger.html ./ -COPY --from=builder /app/scalar.html ./ -COPY --from=builder /app/img ./img # Copy dependencies and configs COPY --from=builder /app/node_modules ./node_modules diff --git a/index-backup.html b/index-backup.html deleted file mode 100644 index 16d0bcd..0000000 --- a/index-backup.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - Cloud-Barista Tumblebug Map Client - - - - - - - - -
- - - \ No newline at end of file diff --git a/index.html b/index.html index 724afbf..3230251 100644 --- a/index.html +++ b/index.html @@ -1241,7 +1241,7 @@ - + - `, - showCancelButton: true, - confirmButtonText: "Create Infra", - cancelButtonText: "Cancel", - confirmButtonColor: validationStatus === "error" ? "#ffc107" : "#28a745", - scrollbarPadding: false, - didOpen: () => { - // Initialize NodeGroup management functions - window.toggleAllNodeGroups = function() { - const selectAllCheckbox = document.getElementById('selectAllNodeGroups'); - const nodegroupCheckboxes = document.querySelectorAll('.nodegroup-checkbox'); - - nodegroupCheckboxes.forEach(checkbox => { - checkbox.checked = selectAllCheckbox.checked; - }); - - updateReviewButtonState(); - }; - - window.updateReviewButtonState = function() { - const selectedNodeGroups = document.querySelectorAll('.nodegroup-checkbox:checked'); - const reviewButton = document.getElementById('reviewWithSelectedNodeGroups'); - const selectAllCheckbox = document.getElementById('selectAllNodeGroups'); - - if (reviewButton) { - if (selectedNodeGroups.length === 0) { - reviewButton.style.background = '#dc3545'; - reviewButton.textContent = '⚠️ No NodeGroups Selected'; - reviewButton.disabled = true; - } else { - reviewButton.style.background = '#28a745'; - reviewButton.textContent = '🔄 Review Selected (' + selectedNodeGroups.length + ')'; - reviewButton.disabled = false; - } - } - - // Update "Select All" checkbox state - const allNodeGroups = document.querySelectorAll('.nodegroup-checkbox'); - if (selectAllCheckbox && allNodeGroups.length > 0) { - selectAllCheckbox.checked = selectedNodeGroups.length === allNodeGroups.length; - selectAllCheckbox.indeterminate = selectedNodeGroups.length > 0 && selectedNodeGroups.length < allNodeGroups.length; - } - }; - - // Toggle Build Image options visibility - window.toggleBuildImageOptions = function() { - const buildImageCheckbox = document.getElementById('buildimage-checkbox'); - const postCommandCheckbox = document.getElementById('postcommand-checkbox'); - - if (buildImageCheckbox && buildImageCheckbox.checked) { - // When build image is enabled, automatically enable post-commands - if (postCommandCheckbox) { - postCommandCheckbox.checked = true; - postCommandCheckbox.disabled = false; - } - } - }; - window.getSelectedNodeGroups = function() { - const selectedCheckboxes = document.querySelectorAll('.nodegroup-checkbox:checked'); - return Array.from(selectedCheckboxes).map(cb => cb.getAttribute('data-nodegroup-name')); - }; - - // Set up event listeners - const reviewButton = document.getElementById('reviewWithSelectedNodeGroups'); - if (reviewButton) { - reviewButton.addEventListener('click', function() { - const selectedNodeGroups = getSelectedNodeGroups(); - if (selectedNodeGroups.length === 0) { - alert('Please select at least one NodeGroup to review.'); - return; - } - - // Store selected nodegroups and current config for re-review - window.selectedNodeGroupsForReview = selectedNodeGroups; - - // Close current modal and trigger re-review - Swal.close(); - - // Use setTimeout to ensure modal is closed before starting new review - setTimeout(() => { - reviewWithSelectedNodeGroups(selectedNodeGroups); - }, 100); - }); - } - - // Initialize button state - updateReviewButtonState(); - - // Add change listeners to all nodegroup checkboxes - const nodegroupCheckboxes = document.querySelectorAll('.nodegroup-checkbox'); - nodegroupCheckboxes.forEach(checkbox => { - checkbox.addEventListener('change', updateReviewButtonState); - }); - - // Add change listener for build image checkbox - const buildImageCheckbox = document.getElementById('buildimage-checkbox'); - if (buildImageCheckbox) { - buildImageCheckbox.addEventListener('change', toggleBuildImageOptions); - } - }, - preConfirm: () => { - if (validationStatus !== "error") { - return { - monitoring: document.getElementById('monitoring-checkbox') ? document.getElementById('monitoring-checkbox').checked : false, - hold: document.getElementById('hold-checkbox') ? document.getElementById('hold-checkbox').checked : false, - addPostCommand: document.getElementById('postcommand-checkbox') ? document.getElementById('postcommand-checkbox').checked : false, - buildAgnosticImage: document.getElementById('buildimage-checkbox') ? document.getElementById('buildimage-checkbox').checked : false - }; - } - return null; - } - }).then((result) => { - if (result.isConfirmed) { - if (validationStatus === "error") { - // Show warning and ask for confirmation to proceed despite errors - Swal.fire({ - icon: "warning", - title: "⚠️ Configuration Has Errors", - html: ` -

Your Infra configuration has validation errors.

-

Proceeding may result in deployment failures or unexpected behavior.

-

Do you want to proceed anyway?

- `, - showCancelButton: true, - confirmButtonText: "Yes, Create Anyway", - cancelButtonText: "Cancel", - confirmButtonColor: "#dc3545", - cancelButtonColor: "#6c757d" - }).then((forceResult) => { - if (forceResult.isConfirmed) { - // Force proceed with Infra creation using selected options - const options = result.value || { monitoring: false, hold: false, addPostCommand: false }; - - createInfraReq.installMonAgent = "no"; - var infraCreationUrl = finalUrl; - - if (options.monitoring) { - createInfraReq.installMonAgent = "yes"; - } - if (options.hold) { - infraCreationUrl += "?option=hold"; - } - - // Handle post-deployment commands for force creation - if (options.addPostCommand || options.buildAgnosticImage) { - // Show the same post-command dialog as normal flow (with buildAgnosticImage flag) - showPostCommandDialog(createInfraReq, infraCreationUrl, username, password, options.buildAgnosticImage); - } else { - proceedWithInfraCreation(createInfraReq, infraCreationUrl, username, password); - } - } - }); - } else { - // Proceed directly to Infra creation with selected options - const options = result.value || { monitoring: false, hold: false, addPostCommand: false }; - - createInfraReq.installMonAgent = "no"; - var infraCreationUrl = finalUrl; - - if (options.monitoring) { - createInfraReq.installMonAgent = "yes"; - } - if (options.hold) { - infraCreationUrl += "?option=hold"; - } - - if (options.addPostCommand || options.buildAgnosticImage) { - // Show post-command dialog (with buildAgnosticImage flag) - showPostCommandDialog(createInfraReq, infraCreationUrl, username, password, options.buildAgnosticImage); - } else { - proceedWithInfraCreation(createInfraReq, infraCreationUrl, username, password); - } - } - } - }); - }) - .catch(function (error) { - console.error("Review failed:", error); - - var errorMessage = "Unknown error occurred during review"; - if (error.response && error.response.data) { - errorMessage = error.response.data.message || JSON.stringify(error.response.data); - } else if (error.message) { - errorMessage = error.message; - } - - Swal.fire({ - icon: "error", - title: "Review Failed", - html: ` -

Unable to review Infra configuration:

-
- ${errorMessage} -
-

Would you like to proceed without review?

- `, - showCancelButton: true, - confirmButtonText: "Proceed Anyway", - cancelButtonText: "Cancel", - confirmButtonColor: "#ffc107", - }).then((result) => { - if (result.isConfirmed) { - // Proceed to final confirmation even without review - call the same logic as successful review - const options = { monitoring: false, hold: false, addPostCommand: false }; - - createInfraReq.installMonAgent = "no"; - - proceedWithInfraCreation(createInfraReq, finalUrl, username, password); - } - }); - }); -} - -// Function to review Infra with selected NodeGroups only -function reviewWithSelectedNodeGroups(selectedNodeGroups) { - // Get current Infra request data - var hostname = configHostname; - var port = configPort; - var username = configUsername; - var password = configPassword; - var namespace = configNamespace; - - // Get current createInfraReq from global nodeGroupRequestFromSpecList - if (nodeGroupRequestFromSpecList.length === 0) { - Swal.fire({ - icon: "error", - title: "No Configuration Found", - text: "No Infra configuration found. Please create a new configuration first." - }); - return; - } - - // Filter Node requests to include only selected NodeGroups - var filteredNodeRequests = nodeGroupRequestFromSpecList.filter(nodeReq => { - return selectedNodeGroups.includes(nodeReq.name); - }); - - if (filteredNodeRequests.length === 0) { - Swal.fire({ - icon: "warning", - title: "No NodeGroups Selected", - text: "Please select at least one NodeGroup to proceed with the review." - }); - return; - } - - // Create modified Infra request with filtered VMs - var modifiedCreateInfraReq = JSON.parse(JSON.stringify(createInfraReqTmplt)); - modifiedCreateInfraReq.name = "mc-" + generateInfraName(); - modifiedCreateInfraReq.nodeGroups = filteredNodeRequests; - - // Calculate costs and details for selected NodeGroups - let totalCost = 0; - let totalNodeScale = 0; - let costDetailsHtml = ""; - let nodeGroupReqString = ""; - - filteredNodeRequests.forEach(nodeReq => { - totalNodeScale += parseInt(nodeReq.nodeGroupSize || 1); - nodeGroupReqString += `${nodeReq.name} (${nodeReq.nodeGroupSize || 1} Nodes)
`; - }); - - costDetailsHtml = ` -
- Selected NodeGroups: ${filteredNodeRequests.length}
- Total Nodes: ${totalNodeScale}
-
- ${nodeGroupReqString} -
-
- `; - - var finalUrl = `${tbApiBase()}/ns/${namespace}/infraDynamic`; - - // Show loading message - Swal.fire({ - title: "Reviewing Modified Configuration", - html: ` -
-
-
-
-

Validating configuration with ${filteredNodeRequests.length} selected NodeGroups...

-
- - `, - showConfirmButton: false, - allowOutsideClick: false - }); - - // Trigger review with modified configuration - setTimeout(() => { - reviewInfraConfiguration(modifiedCreateInfraReq, hostname, port, username, password, namespace, finalUrl, totalCost, totalNodeScale, costDetailsHtml, nodeGroupReqString); - }, 1000); -} - -function createInfra() { - // Scroll Provision panel to top - var scrollableCol = document.querySelector('.scrollable-column'); - if (scrollableCol) scrollableCol.scrollTop = 0; - - if (nodeGroupRequestFromSpecList.length != 0) { - var hostname = configHostname; - var port = configPort; - var username = configUsername; - var password = configPassword; - var namespace = configNamespace; - - var url = `${tbApiBase()}/ns/${namespace}/infraDynamic`; - - var createInfraReq = createInfraReqTmplt; - createInfraReq.name = "mc-" + generateInfraName(); - createInfraReq.nodeGroups = nodeGroupRequestFromSpecList; - let totalCost = 0; - let totalNodeScale = 0; - - var nodeGroupReqString = ""; - for (i = 0; i < createInfraReq.nodeGroups.length; i++) { - - totalNodeScale += parseInt(createInfraReq.nodeGroups[i].nodeGroupSize); - let costPerHour = recommendedSpecList[i].costPerHour; - let subTotalCost = "unknown"; - if (costPerHour < 0 || !costPerHour) { - costPerHour = "unknown"; - costPerHour = "Estimated Price(USD/1H)$" + subTotalCost + " ($" + costPerHour + " * " + createInfraReq.nodeGroups[i].nodeGroupSize + ")" + ""; - } else { - totalCost += parseFloat(costPerHour) * createInfraReq.nodeGroups[i].nodeGroupSize; - - subTotalCost = (parseFloat(costPerHour) * createInfraReq.nodeGroups[i].nodeGroupSize).toFixed(4); - costPerHour = "Estimated Price(USD/1H)$" + subTotalCost + " ($" + costPerHour + " * " + createInfraReq.nodeGroups[i].nodeGroupSize + ")" + ""; - } - let acceleratorType = recommendedSpecList[i].acceleratorType; - let acceleratorModel = recommendedSpecList[i].acceleratorModel; - if (acceleratorType == "gpu") { - acceleratorType = "AcceleratorGPU (" + acceleratorModel + ")" - } else { - acceleratorType = "Acceleratornone" - } - - var html = - "" + - "" + - "" + - costPerHour + - "" + - "" + - "" + - acceleratorType + - "" + - "" + - - ((createInfraReq.nodeGroups[i].label && Object.keys(createInfraReq.nodeGroups[i].label).length > 0) ? - "" : "") + - - "
[#" + (i + 1).toString() + "] NodeGroup Name" + createInfraReq.nodeGroups[i].name + " (" + createInfraReq.nodeGroups[i].nodeGroupSize + " node(s))
Spec" + createInfraReq.nodeGroups[i].specId + "
vCPU" + recommendedSpecList[i].vCPU + "
Mem(GiB)" + recommendedSpecList[i].memoryGiB + "
RootDisk(GB)" + (createInfraReq.nodeGroups[i].rootDiskSize > 0 ? createInfraReq.nodeGroups[i].rootDiskSize : 'Default') + " (type: " + createInfraReq.nodeGroups[i].rootDiskType + ")
Selected Image" + createInfraReq.nodeGroups[i].imageId + "
Labels" + - Object.entries(createInfraReq.nodeGroups[i].label).map(([key, value]) => - `${key}=${value}` - ).join(", ") + - "
" + - "
" - ; - - nodeGroupReqString = nodeGroupReqString + html; - } - - var costDetailsHtml = - "" + - "" + - "" + - "" + - "" + - "
Usage PeriodEstimated Cost
Hourly$" + totalCost.toFixed(4) + "
Daily$" + (totalCost * 24).toFixed(4) + "
Monthly$" + (totalCost * 24 * 31).toFixed(4) + "

(Do not rely on this estimated cost. It is just an estimation using spec price.)
"; - - // Step 1: Infra Name Input - Swal.fire({ - title: "Enter the name of the Infra you wish to create", - input: "text", - inputAttributes: { - autocapitalize: "off", - }, - inputValue: createInfraReq.name, - showCancelButton: true, - confirmButtonText: "Next: Review Configuration", - }).then((result) => { - if (result.value) { - createInfraReq.name = result.value; - - // Step 2: Start Infra Review process - reviewInfraConfiguration(createInfraReq, hostname, port, username, password, namespace, url, totalCost, totalNodeScale, costDetailsHtml, nodeGroupReqString); - } - }); - } else { - console.log( - "To create a Infra, Nodes should be configured! Click the Map to add a config for Node request." - ); - errorAlert("Please configure Infra first\n(Click the Map to add Nodes)"); - } -} -window.createInfra = createInfra; -window.proceedWithBuildAgnosticImage = proceedWithBuildAgnosticImage; - -// Function to check if K8s node image designation is needed -async function checkK8sNodeImageDesignation(providerName, hostname, port, username, password) { - try { - const url = `${tbApiBase()}/checkK8sNodeImageDesignation?providerName=${providerName}`; - - const response = await axios.get(url, { - auth: { - username: username, - password: password - }, - headers: { - 'accept': 'application/json' - } - }); - - // Return true if image designation is needed, false if should use "default" - return response.data?.result === "true"; - } catch (error) { - console.warn("Failed to check K8s node image designation:", error); - // Default to true (use provided imageId) if check fails - return true; - } -} - -// K8s Cluster creation function (supports single and multi-cluster creation) -function createK8sCluster() { - if (nodeGroupRequestFromSpecList.length < 1) { - errorAlert("Please configure at least one NodeGroup to create K8s Cluster(s)"); - return; - } - - const isMultiCluster = nodeGroupRequestFromSpecList.length > 1; - const nodeGroup = nodeGroupRequestFromSpecList[0]; - const spec = recommendedSpecList[0]; - - // Generate random names for K8s resources - const k8sClusterRandomName = "k8s-" + generateRandomString(); - const k8sNodeGroupRandomName = "ng-" + generateRandomString(); - - const hostname = configHostname; - const port = configPort; - const username = configUsername; - const password = configPassword; - const namespace = configNamespace; - - // For multi-cluster, use namePrefix approach (simplified dialog) - if (isMultiCluster) { - // Build cluster configuration summary - const clusterSummary = nodeGroupRequestFromSpecList.map((sg, idx) => { - const sp = recommendedSpecList[idx]; - return ` - ${idx + 1} - ${sp?.providerName || 'Unknown'} - ${sp?.regionName || 'Unknown'} - ${sp?.cspSpecName || 'Unknown'} - `; - }).join(''); - - Swal.fire({ - title: `Create ${nodeGroupRequestFromSpecList.length} K8s Clusters`, - html: ` -
-
-
- -
- Clusters will be named: {prefix}-{csp}-{number} (e.g., ${k8sClusterRandomName}-aws-1) -
-
-
- Clusters to create (${nodeGroupRequestFromSpecList.length}): - - - ${clusterSummary} -
#ProviderRegionSpec
-
-
- Note: All clusters will be created in parallel. K8s versions will use defaults for each provider. -
-
- `, - showCancelButton: true, - confirmButtonText: `Create ${nodeGroupRequestFromSpecList.length} Clusters`, - cancelButtonText: "Cancel", - preConfirm: () => { - const namePrefix = document.getElementById('k8sNamePrefix').value.trim(); - if (!namePrefix) { - Swal.showValidationMessage('Please enter name prefix'); - return false; - } - return { namePrefix }; - } - }).then((result) => { - if (result.isConfirmed) { - const { namePrefix } = result.value; - - // Build multi-cluster request - const clusters = nodeGroupRequestFromSpecList.map((sg, idx) => { - const clusterReq = { - imageId: sg.imageId || "default", - specId: sg.specId - }; - if (sg.rootDiskType) clusterReq.rootDiskType = sg.rootDiskType; - if (sg.rootDiskSize) clusterReq.rootDiskSize = sg.rootDiskSize; - if (sg.nodeGroupName || sg.name) clusterReq.nodeGroupName = sg.nodeGroupName || sg.name; - if (sg.version) clusterReq.version = sg.version; - if (sg.nodeGroupSize || sg.desiredNodeSize) clusterReq.desiredNodeSize = sg.nodeGroupSize || sg.desiredNodeSize; - if (sg.minNodeSize) clusterReq.minNodeSize = sg.minNodeSize; - if (sg.maxNodeSize) clusterReq.maxNodeSize = sg.maxNodeSize; - if (sg.onAutoScaling) clusterReq.onAutoScaling = sg.onAutoScaling; - if (sg.connectionName) clusterReq.connectionName = sg.connectionName; - return clusterReq; - }); - - const multiClusterReq = { - namePrefix: namePrefix, - clusters: clusters - }; - - // Do not use skipVersionCheck without explicit version - let CB-TB use default versions per CSP - const url = `${tbApiBase()}/ns/${namespace}/k8sMultiClusterDynamic`; - const taskId = addSpinnerTask(`Create ${clusters.length} K8s Clusters`); - - axios.post(url, multiClusterReq, { - auth: { username, password }, - headers: { 'Content-Type': 'application/json' } - }).then(function (response) { - removeSpinnerTask(taskId); - const createdClusters = response.data?.clusters || []; - const failedClusters = response.data?.failedClusters || []; - const successCount = createdClusters.length; - const failedCount = failedClusters.length; - const totalCount = clusters.length; - - const clusterList = createdClusters.length > 0 - ? createdClusters.map(c => `
  • \u2713 ${c.name || c.id || 'Unknown'} (${c.connectionName || 'N/A'})
  • `).join('') - : '
  • No clusters created
  • '; - - // Build failed clusters list with details - const failedList = failedClusters.length > 0 - ? failedClusters.map(f => `
  • \u2717 ${f.name || 'Unknown'} (${f.connectionName || 'N/A'})
    ${f.error || 'Unknown error'}
  • `).join('') - : ''; - - // Check if partial success (HTTP 207) - const isPartialSuccess = response.status === 207; - const title = isPartialSuccess ? "Partial Success" : - (successCount === totalCount && successCount > 0 ? "All Clusters Created!" : "Cluster Creation Failed"); - const icon = isPartialSuccess ? "warning" : - (successCount === totalCount && successCount > 0 ? "success" : "error"); - - Swal.fire({ - title: title, - html: ` -
    -

    Created: ${successCount} / ${totalCount}

    - - ${failedCount > 0 ? ` -

    Failed: ${failedCount}

    - - ` : ''} -
    - `, - icon: icon, - confirmButtonText: "OK" - }); - }).catch(function (error) { - removeSpinnerTask(taskId); - console.error("Multi-cluster creation failed:", error); - - let errorMessage = "Failed to create K8s Clusters"; - if (error.response?.data) { - errorMessage += `\n${error.response.data.message || error.response.data.error || ''}`; - } - errorAlert(errorMessage); - }); - } - }); - return; - } - - // Single cluster creation (original flow) - // First, get available K8s versions - const versionUrl = `${tbApiBase()}/availableK8sVersion?providerName=${spec.providerName}®ionName=${spec.regionName}`; - - const versionTaskId = addSpinnerTask("getK8sVersions"); - - axios.get(versionUrl, { - auth: { - username: username, - password: password - } - }).then(function (versionResponse) { - removeSpinnerTask(versionTaskId); - - const availableVersions = versionResponse.data || []; - console.log("Available K8s versions:", availableVersions); - - // Create version options - let versionOptions = ''; - if (availableVersions.length > 0) { - versionOptions += availableVersions.map(version => - `` - ).join(''); - } - versionOptions += ''; - - // Create confirmation dialog with version selection - Swal.fire({ - title: "Create Kubernetes Cluster", - html: ` -
    -
    -
    - -
    -
    -
    - -
    -
    -
    - - -
    - ${availableVersions.length > 0 ? 'Select from available versions or choose custom to enter manually' : 'No versions available, please enter custom version'} -
    -
    -
    - Configuration:
    - Provider: ${spec.providerName}
    - Region: ${spec.regionName}
    - Spec: ${spec.cspSpecName}
    - Image: ${nodeGroup.imageId} -
    -
    - Note: This will create a new Kubernetes cluster using the configured NodeGroup settings. -
    -
    - `, - showCancelButton: true, - confirmButtonText: "Create K8s Cluster", - cancelButtonText: "Cancel", - didOpen: () => { - // Handle version selection change - const versionSelect = document.getElementById('k8sVersionSelect'); - const customVersionInput = document.getElementById('k8sCustomVersion'); - - versionSelect.addEventListener('change', function() { - if (this.value === 'custom') { - customVersionInput.style.display = 'block'; - customVersionInput.focus(); - } else { - customVersionInput.style.display = 'none'; - customVersionInput.value = ''; - } - }); - }, - preConfirm: () => { - const clusterName = document.getElementById('k8sClusterName').value.trim(); - const nodeGroupName = document.getElementById('k8sNodeGroupName').value.trim(); - const selectedVersion = document.getElementById('k8sVersionSelect').value; - const customVersion = document.getElementById('k8sCustomVersion').value.trim(); - - if (!clusterName) { - Swal.showValidationMessage('Please enter cluster name'); - return false; - } - if (!nodeGroupName) { - Swal.showValidationMessage('Please enter node group name'); - return false; - } - - let k8sVersion = ''; - if (selectedVersion === 'custom') { - if (!customVersion) { - Swal.showValidationMessage('Please enter custom K8s version'); - return false; - } - k8sVersion = customVersion; - } else if (selectedVersion) { - k8sVersion = selectedVersion; - } - // If no version selected, k8sVersion will be empty (default behavior) - - return { clusterName, nodeGroupName, k8sVersion }; - } - }).then((result) => { - let taskId; // Declare taskId in higher scope for error handling - - if (result.isConfirmed) { - const { clusterName, nodeGroupName, k8sVersion } = result.value; - - // Check if image designation is needed - taskId = addSpinnerTask("Checking image requirements"); - - checkK8sNodeImageDesignation(spec.providerName, hostname, port, username, password) - .then(imageDesignationNeeded => { - removeSpinnerTask(taskId); - - // Create K8s cluster request body - const k8sClusterReq = { - imageId: imageDesignationNeeded ? (nodeGroup.imageId || "default") : "default", - specId: nodeGroup.specId, - name: clusterName, - nodeGroupName: nodeGroupName - }; - - // Add version if specified - if (k8sVersion) { - k8sClusterReq.version = k8sVersion; - } - - // Add rootDiskType and rootDiskSize if available - if (nodeGroup.rootDiskType) { - k8sClusterReq.rootDiskType = nodeGroup.rootDiskType; - } - if (nodeGroup.rootDiskSize) { - k8sClusterReq.rootDiskSize = nodeGroup.rootDiskSize; - } - - // Check if using custom version (not from available versions list) - const selectedVersion = document.getElementById('k8sVersionSelect').value; - const isCustomVersion = selectedVersion === 'custom'; - - // Add skipVersionCheck parameter for custom versions - const skipVersionParam = isCustomVersion ? '?skipVersionCheck=true' : ''; - const url = `${tbApiBase()}/ns/${namespace}/k8sClusterDynamic${skipVersionParam}`; - - // Debug: uncomment if K8s creation debugging needed - // console.log("Creating K8s Cluster:", k8sClusterReq); - // console.log("Image designation needed:", imageDesignationNeeded); - // console.log("Using custom version:", isCustomVersion); - - taskId = addSpinnerTask("Create K8s "+k8sClusterReq.name); - - axios.post(url, k8sClusterReq, { - auth: { - username: username, - password: password - }, - headers: { - 'Content-Type': 'application/json' - } - }).then(function (response) { - removeSpinnerTask(taskId); - // Debug: uncomment if K8s creation response debugging needed - // console.log("K8s Cluster creation response:", response.data); - - Swal.fire({ - title: "K8s Cluster Created Successfully!", - html: ` -
    -

    Cluster ID: ${response.data?.id || 'Unknown'}

    -

    Status: ${response.data?.status || 'Unknown'}

    -

    Provider: ${response.data?.connectionName || 'Unknown'}

    - ${k8sVersion ? `

    Version: ${k8sVersion}

    ` : ''} -
    - `, - icon: "success", - confirmButtonText: "OK" - }); - - // K8s cluster created successfully, no additional refresh needed - - }).catch(function (error) { - removeSpinnerTask(taskId); - console.error("K8s Cluster creation failed:", error); - - let errorMessage = "Failed to create K8s Cluster"; - if (error.response && error.response.data) { - errorMessage += `\n${error.response.data.message || error.response.data.error || ''}`; - } - - errorAlert(errorMessage); - }); - }) - .catch(function (error) { - removeSpinnerTask(taskId); - console.error("Failed to check image designation:", error); - errorAlert("Failed to check image requirements. Please try again."); - }); - } - }).catch(function (error) { - // Handle any unexpected errors in the Swal dialog - console.error("K8s Cluster creation dialog error:", error); - // Clean up spinner if it was started - if (taskId) { - removeSpinnerTask(taskId); - } - }); - - }).catch(function (error) { - removeSpinnerTask(versionTaskId); - console.error("Failed to get K8s versions:", error); - - // Extract error message from server response - let errorMessage = 'Unknown error occurred'; - if (error.response && error.response.data && error.response.data.message) { - errorMessage = error.response.data.message; - } else if (error.message) { - errorMessage = error.message; - } - - // Show error message and stop execution - errorAlert(`Failed to get available Kubernetes versions.\n\nError: ${errorMessage}\n\nProvider: ${spec.providerName}\nRegion: ${spec.regionName}`); - return; // Stop execution - }); -} -window.createK8sCluster = createK8sCluster; - -// Add NodeGroup to existing K8s Cluster function (supports single and multi-NodeGroup) -function addNodeGroupToK8sCluster() { - if (nodeGroupRequestFromSpecList.length < 1) { - errorAlert("Please configure at least one NodeGroup to add NodeGroup(s) to K8s Cluster"); - return; - } - - const hostname = configHostname; - const port = configPort; - const username = configUsername; - const password = configPassword; - const namespace = configNamespace; - - // First, get list of existing K8s clusters - const listUrl = `${tbApiBase()}/ns/${namespace}/k8sCluster`; - const listTaskId = addSpinnerTask("listK8sClusters"); - - axios.get(listUrl, { auth: { username, password } }).then(function (response) { - removeSpinnerTask(listTaskId); - - const clusters = response.data?.cluster || response.data?.K8sClusterInfo || []; - - if (clusters.length === 0) { - errorAlert("No K8s clusters found. Please create a K8s cluster first."); - return; - } - - const isMultiNodeGroup = nodeGroupRequestFromSpecList.length > 1; - - if (isMultiNodeGroup) { - // Multi-NodeGroup: Each NodeGroup maps to a compatible cluster - showMultiNodeGroupDialog(clusters, hostname, port, username, password, namespace); - } else { - // Single NodeGroup (original flow) - showSingleNodeGroupDialog(clusters, hostname, port, username, password, namespace); - } - }).catch(function (error) { - removeSpinnerTask(listTaskId); - console.error("Failed to get K8s cluster list:", error); - errorAlert("Failed to get K8s cluster list"); - }); -} - -// Single NodeGroup dialog (original behavior) -function showSingleNodeGroupDialog(clusters, hostname, port, username, password, namespace) { - const nodeGroup = nodeGroupRequestFromSpecList[0]; - const spec = recommendedSpecList[0]; - const k8sNodeGroupRandomName = "ng-" + generateRandomString(); - const nodeGroupProvider = spec.providerName || ''; - const nodeGroupRegion = spec.regionName || ''; - const nodeGroupProviderLower = nodeGroupProvider.toLowerCase(); - const nodeGroupRegionLower = nodeGroupRegion.toLowerCase(); - - const clusterOptions = clusters.map(cluster => { - // Use cluster-level status for determining availability - const clusterStatus = cluster?.status || 'Unknown'; - const isActive = clusterStatus === 'Active'; - - // Check if provider and region match - const clusterProvider = (cluster?.connectionConfig?.providerName || '').toLowerCase(); - const clusterRegion = (cluster?.connectionConfig?.regionDetail?.regionName || '').toLowerCase(); - - const providerRegionMatch = (clusterProvider === nodeGroupProviderLower && clusterRegion === nodeGroupRegionLower); - - // Enable only if cluster is Active AND provider/region matches - const isSelectable = isActive && providerRegionMatch; - const disabled = !isSelectable ? 'disabled' : ''; - - // Set colors based on status and compatibility - let statusColor = '#6c757d'; // Default gray for disabled - let statusText = clusterStatus; - - if (isActive && providerRegionMatch) { - statusColor = '#28a745'; // Green for selectable - statusText = `${clusterStatus} ✓`; - } else if (isActive && !providerRegionMatch) { - statusColor = '#ffc107'; // Yellow for active but incompatible - statusText = `${clusterStatus} (Provider/Region mismatch)`; - } - - const clusterId = cluster?.id || ''; - const clusterName = cluster?.name || 'Unknown'; - const connectionName = cluster?.connectionName || 'Unknown'; - - return ``; - }).join(''); - - // Check if there are any selectable clusters - const selectableClusters = clusters.filter(cluster => { - const clusterStatus = cluster?.status || 'Unknown'; - const isActive = clusterStatus === 'Active'; - const clusterProvider = (cluster?.connectionConfig?.providerName || '').toLowerCase(); - const clusterRegion = (cluster?.connectionConfig?.regionDetail?.regionName || '').toLowerCase(); - const providerRegionMatch = (clusterProvider === nodeGroupProviderLower && clusterRegion === nodeGroupRegionLower); - return isActive && providerRegionMatch; - }); - - if (selectableClusters.length === 0) { - errorAlert(`No compatible K8s clusters found.\n\nRequired:\n- Status: Active\n- Provider: ${nodeGroupProvider}\n- Region: ${nodeGroupRegion}\n\nPlease create a compatible K8s cluster first or check existing cluster configurations.`); - return; - } - - Swal.fire({ - title: "Add NodeGroup to K8s Cluster", - html: ` -
    -
    -
    - -
    - Note: Only Active clusters with matching Provider (${nodeGroupProvider}) and Region (${nodeGroupRegion}) can be selected -
    -
    -
    -
    - -
    -
    - NodeGroup Configuration:
    - Provider: ${spec.providerName}
    - Region: ${spec.regionName}
    - Spec: ${spec.cspSpecName}
    - Image: ${nodeGroup.imageId} -
    -
    - Note: This will add a new NodeGroup to the selected active K8s cluster. -
    -
    - `, - showCancelButton: true, - confirmButtonText: "Add NodeGroup", - cancelButtonText: "Cancel", - preConfirm: () => { - const clusterId = document.getElementById('k8sClusterSelect').value; - const nodeGroupName = document.getElementById('newNodeGroupName').value.trim(); - - if (!clusterId) { - Swal.showValidationMessage('Please select a K8s cluster'); - return false; - } - - // Find selected cluster and check if it's Active - const selectedCluster = clusters.find(cluster => cluster?.id === clusterId); - if (!selectedCluster) { - Swal.showValidationMessage('Selected cluster not found'); - return false; - } - - // Check cluster status - only Active clusters can have NodeGroups added - const clusterStatus = selectedCluster?.status || 'Unknown'; - if (clusterStatus !== 'Active') { - Swal.showValidationMessage(`Cluster is not Active (current status: ${clusterStatus}). Please wait for cluster to become Active.`); - return false; - } - - if (!nodeGroupName) { - Swal.showValidationMessage('Please enter node group name'); - return false; - } - - return { clusterId, nodeGroupName }; - } - }).then((result) => { - let taskId; // Declare taskId in higher scope for error handling - - if (result.isConfirmed) { - const { clusterId, nodeGroupName } = result.value; - - // Check if image designation is needed - taskId = addSpinnerTask("Checking image requirements"); - - checkK8sNodeImageDesignation(spec.providerName, hostname, port, username, password) - .then(imageDesignationNeeded => { - removeSpinnerTask(taskId); - - // Create NodeGroup request body - const nodeGroupReq = { - imageId: imageDesignationNeeded ? (nodeGroup.imageId || "default") : "default", - specId: nodeGroup.specId, - name: nodeGroupName - }; - - // Add rootDiskType and rootDiskSize if available - if (nodeGroup.rootDiskType) { - nodeGroupReq.rootDiskType = nodeGroup.rootDiskType; - } - if (nodeGroup.rootDiskSize) { - nodeGroupReq.rootDiskSize = nodeGroup.rootDiskSize; - } - - const url = `${tbApiBase()}/ns/${namespace}/k8sCluster/${clusterId}/k8sNodeGroupDynamic`; - - console.log("Adding NodeGroup to K8s Cluster:", nodeGroupReq); - console.log("Image designation needed:", imageDesignationNeeded); - - taskId = addSpinnerTask("Add NodeGroup " + nodeGroupReq.name); - - axios.post(url, nodeGroupReq, { - auth: { - username: username, - password: password - }, - headers: { - 'Content-Type': 'application/json' - } - }).then(function (response) { - removeSpinnerTask(taskId); - console.log("NodeGroup addition response:", response.data); - - // Safely extract response data with fallbacks - const clusterId = response.data?.id || 'Unknown'; - const clusterStatus = response.data?.status || 'Unknown'; - - Swal.fire({ - title: "NodeGroup Added Successfully!", - html: ` -
    -

    Cluster ID: ${clusterId}

    -

    NodeGroup: ${nodeGroupName}

    -

    Status: ${clusterStatus}

    -
    - `, - icon: "success", - confirmButtonText: "OK" - }); - - // NodeGroup added successfully, no additional refresh needed - - }).catch(function (error) { - removeSpinnerTask(taskId); - console.error("NodeGroup addition failed:", error); - - let errorMessage = "Failed to add NodeGroup to K8s Cluster"; - if (error.response && error.response.data) { - errorMessage += `\n${error.response.data.message || error.response.data.error || ''}`; - } - - errorAlert(errorMessage); - }); - }) - .catch(function (error) { - removeSpinnerTask(taskId); - console.error("Failed to check image designation:", error); - errorAlert("Failed to check image requirements. Please try again."); - }); - } - }).catch(function (error) { - // Handle any unexpected errors in the NodeGroup dialog - console.error("NodeGroup addition dialog error:", error); - }); -} - -// Multi-NodeGroup dialog: maps each NodeGroup to compatible clusters -function showMultiNodeGroupDialog(clusters, hostname, port, username, password, namespace) { - const nodeGroupPrefix = "ng-" + generateRandomString(); - - // Build mapping of NodeGroups to compatible clusters - const nodeGroupMappings = nodeGroupRequestFromSpecList.map((sg, idx) => { - const spec = recommendedSpecList[idx]; - const provider = spec?.providerName || ''; - const region = spec?.regionName || ''; - const providerLower = provider.toLowerCase(); - const regionLower = region.toLowerCase(); - - // Find compatible clusters (Active + matching provider/region) - const compatibleClusters = clusters.filter(c => { - const cProvider = (c?.connectionConfig?.providerName || '').toLowerCase(); - const cRegion = (c?.connectionConfig?.regionDetail?.regionName || '').toLowerCase(); - return c?.status === 'Active' && cProvider === providerLower && cRegion === regionLower; - }); - - return { idx, sg, spec, provider, region, compatibleClusters }; - }); - - // Check if any NodeGroup has compatible clusters - const hasAnyCompatible = nodeGroupMappings.some(m => m.compatibleClusters.length > 0); - if (!hasAnyCompatible) { - errorAlert("No compatible K8s clusters found for any NodeGroup configuration.\n\nEnsure you have Active clusters matching the Provider/Region of your NodeGroups."); - return; - } - - // Build HTML for cluster selection per NodeGroup - const mappingRows = nodeGroupMappings.map(m => { - const clusterOpts = m.compatibleClusters.length > 0 - ? m.compatibleClusters.map(c => ``).join('') - : ''; - - return ` - ${m.idx + 1} - ${m.provider} - ${m.region} - - `; - }).join(''); - - Swal.fire({ - title: `Add ${nodeGroupRequestFromSpecList.length} NodeGroups`, - html: ` -
    -
    -
    - -
    -
    - NodeGroup → Cluster Mapping: - - - ${mappingRows} -
    #ProviderRegionTarget Cluster
    -
    -
    - Each NodeGroup will be added to its selected cluster sequentially. -
    -
    - `, - showCancelButton: true, - confirmButtonText: `Add ${nodeGroupRequestFromSpecList.length} NodeGroups`, - cancelButtonText: "Cancel", - preConfirm: () => { - const prefix = document.getElementById('ngNamePrefix').value.trim(); - if (!prefix) { - Swal.showValidationMessage('Please enter name prefix'); - return false; - } - - // Collect cluster selections - const selections = []; - for (let i = 0; i < nodeGroupRequestFromSpecList.length; i++) { - const sel = document.getElementById(`clusterSelect_${i}`); - if (sel && sel.value) { - selections.push({ idx: i, clusterId: sel.value }); - } - } - - if (selections.length === 0) { - Swal.showValidationMessage('No valid cluster selections'); - return false; - } - - return { prefix, selections }; - } - }).then((result) => { - if (result.isConfirmed) { - const { prefix, selections } = result.value; - executeMultiNodeGroupAddition(selections, prefix, hostname, port, username, password, namespace); - } - }); -} - -// Execute multiple NodeGroup additions sequentially -async function executeMultiNodeGroupAddition(selections, prefix, hostname, port, username, password, namespace) { - const results = []; - const taskId = addSpinnerTask(`Add ${selections.length} NodeGroups`); - - try { - for (const sel of selections) { - const sg = nodeGroupRequestFromSpecList[sel.idx]; - const ngName = `${prefix}-${sel.idx + 1}`; - - const nodeGroupReq = { - imageId: sg.imageId || "default", - specId: sg.specId, - name: ngName - }; - if (sg.rootDiskType) nodeGroupReq.rootDiskType = sg.rootDiskType; - if (sg.rootDiskSize) nodeGroupReq.rootDiskSize = sg.rootDiskSize; - - const url = `${tbApiBase()}/ns/${namespace}/k8sCluster/${sel.clusterId}/k8sNodeGroupDynamic`; - - try { - await axios.post(url, nodeGroupReq, { - auth: { username, password }, - headers: { 'Content-Type': 'application/json' } - }); - results.push({ ngName, clusterId: sel.clusterId, success: true }); - } catch (error) { - console.error(`Failed to add NodeGroup ${ngName}:`, error); - results.push({ ngName, clusterId: sel.clusterId, success: false, error: error.response?.data?.message || error.message }); - } - } - } finally { - removeSpinnerTask(taskId); - } - - const successCount = results.filter(r => r.success).length; - const resultList = results.map(r => - `
  • ${r.ngName} → ${r.clusterId}: ${r.success ? '✓' : '✗ ' + (r.error || 'Failed')}
  • ` - ).join(''); - - Swal.fire({ - title: successCount === results.length ? "All NodeGroups Added!" : "NodeGroups Added (Partial)", - html: ` -
    -

    Added: ${successCount} / ${results.length}

    - -
    - `, - icon: successCount === results.length ? "success" : "warning", - confirmButtonText: "OK" - }); -} - -window.addNodeGroupToK8sCluster = addNodeGroupToK8sCluster; - -// Function to set Kubernetes-appropriate configuration values -function setKubernetesConfig() { - // Set recommended Kubernetes values - document.getElementById("minVCPU").value = "4"; - document.getElementById("minRAM").value = "16"; - document.getElementById("diskSize").value = "100"; - - // Get selected providers for display - var selectedProviders = getSelectedProviders(); - var allCheckbox = document.getElementById("provider-all"); - var providerInfo = ""; - - if (allCheckbox && allCheckbox.checked) { - providerInfo = "All Providers"; - } else if (selectedProviders.length > 0) { - providerInfo = selectedProviders.map(p => p.toUpperCase()).join(", "); - } else { - providerInfo = "No Providers Selected"; - } - - // Show comprehensive Kubernetes information - Swal.fire({ - title: "⚙️ Kubernetes Configuration Guide", - html: ` -
    -
    - ⚠️ Notice: Managed Kubernetes Provisioning is under development and may have stability issues. -
    - -
    - ✅ Configuration Set: Min vCPU: 4, Min Memory: 16GB, Disk: 100GB
    - 🏢 Selected Providers: ${providerInfo} -
    - - -
    - Node Group created with cluster:
    - 🟦 Azure, 🟩 GCP, 🟫 IBM, 🟧 NHN -
    - -
    - Node Group added separately after cluster creation:
    - 🟫 AWS, 🟨 Alibaba, 🟥 Tencent -
    - -
    - - 📖 CSP-specific Details & Examples - -
    - -
    - 🟫 AWS
    - • Prerequisites: awscli + aws configure
    - • Cluster creates without NodeGroup, add separately after status becomes Active
    - • Example: {"imageId": "default", "specId": "aws+ap-northeast-2+t3a.xlarge"} -
    - -
    - 🟨 Alibaba Cloud
    - • Use Kubernetes-optimized images
    - • Example: {"imageId": "aliyun_3_x64_20G_container_optimized_*.vhd", "specId": "alibaba+ap-northeast-2+ecs.g6e.xlarge"} -
    - -
    - 🟦 Azure
    - • NodeGroup name must follow ^[a-z][a-z0-9]*$ regex
    - • Example: {"imageId": "default", "specId": "azure+koreacentral+standard_b4ms"} -
    - -
    - 🟩 GCP
    - • Prerequisites: gcloud CLI + google-cloud-sdk-gke-gcloud-auth-plugin
    - • Run gcloud auth login first
    - • Example: {"imageId": "default", "specId": "gcp+asia-east1+e2-standard-4"} -
    - -
    - 🟧 NHN Cloud
    - • Use Container-optimized images
    - • Example: {"imageId": "efe7f58f-*", "specId": "nhn+kr1+m2.c4m8"} -
    - -
    - 🟥 Tencent Cloud
    - • ap-hongkong region has kubeconfig access limitations
    - • NodeGroup creation enables kubeconfig usage
    - • Example: {"imageId": "img-22trbn9x", "specId": "tencent+ap-seoul+s5.medium4"} -
    -
    -
    -
    - `, - icon: "info", - confirmButtonText: "OK", - confirmButtonColor: "#007bff", - width: "700px" - }); -} -window.setKubernetesConfig = setKubernetesConfig; - -// Workload type management - store previous configurations -let workloadConfigurations = { - vmInfra: { - minVCPU: "1", - minRAM: "0.5", - diskSize: "", - isActive: true - }, - k8sInfra: { - minVCPU: "4", - minRAM: "16", - diskSize: "100", - isActive: false - } -}; - -// Store K8s cluster information -let k8sClusterInfo = null; - -// Global variable to track current workload type -let currentWorkloadType = 'node'; // default to VM/Node mode (not k8s) - -// Helper function to check current workload type -function getCurrentWorkloadType() { - // First try to get from radio buttons - const nodeModeInput = document.getElementById("nodeMode"); - const k8sModeInput = document.getElementById("k8sMode"); - - // Debug: uncomment if workload type debugging needed - // console.log('getCurrentWorkloadType() called'); - // console.log('nodeModeInput:', nodeModeInput); - // console.log('k8sModeInput:', k8sModeInput); - // console.log('nodeModeInput.checked:', nodeModeInput?.checked); - // console.log('k8sModeInput.checked:', k8sModeInput?.checked); - - if (k8sModeInput && k8sModeInput.checked) { - console.log('Returning k8s from radio button'); - currentWorkloadType = 'k8s'; - return 'k8s'; - } else if (nodeModeInput && nodeModeInput.checked) { - console.log('Returning node mode from radio button'); - currentWorkloadType = 'node'; - return 'node'; - } - - // Fallback to global variable - console.log('Returning from global variable:', currentWorkloadType); - return currentWorkloadType; -} -window.getCurrentWorkloadType = getCurrentWorkloadType; - -// Function to fetch K8s cluster information -async function fetchK8sClusterInfo() { - const hostname = configHostname; - const port = configPort; - const username = configUsername; - const password = configPassword; - - const url = `${tbApiBase()}/k8sClusterInfo`; - const auth = btoa(`${username}:${password}`); - - try { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Authorization': `Basic ${auth}` - } - }); - - if (response.ok) { - const data = await response.json(); - k8sClusterInfo = data; - return data; - } else { - console.error('Failed to fetch K8s cluster info:', response.status); - return null; - } - } catch (error) { - console.error('Error fetching K8s cluster info:', error); - return null; - } -} - -// Function to update provider selection based on K8s availability -function updateProvidersForK8s(k8sInfo) { - if (!k8sInfo || !k8sInfo.k8s_cluster) { - return; - } - - // Get available K8s providers - const availableProviders = Object.keys(k8sInfo.k8s_cluster); - - // Uncheck ALL first - const allCheckbox = document.getElementById("provider-all"); - if (allCheckbox) { - allCheckbox.checked = false; - } - - // Uncheck all individual providers first - const allProviderCheckboxes = document.querySelectorAll('#provider-checkboxes input[type="checkbox"]'); - allProviderCheckboxes.forEach(cb => cb.checked = false); - - // Check only available K8s providers - availableProviders.forEach(provider => { - const checkbox = document.getElementById(`provider-${provider}`); - if (checkbox) { - checkbox.checked = true; - } - }); - - // Update dropdown text - updateProviderDropdownText(); -} - -// Function to toggle between MC-Infra (Node) and K8s-Infra -async function toggleWorkloadType() { - const nodeModeInput = document.getElementById("nodeMode"); - const k8sModeInput = document.getElementById("k8sMode"); - const isK8sMode = k8sModeInput && k8sModeInput.checked; - - // Update global workload type - currentWorkloadType = isK8sMode ? 'k8s' : 'node'; - console.log('toggleWorkloadType: currentWorkloadType set to', currentWorkloadType); - - // Save current configuration before switching - if (isK8sMode) { - // Switching from Node to K8s - save Node config - workloadConfigurations.vmInfra.minVCPU = document.getElementById("minVCPU").value || "1"; - workloadConfigurations.vmInfra.minRAM = document.getElementById("minRAM").value || "0.5"; - workloadConfigurations.vmInfra.diskSize = document.getElementById("diskSize").value || ""; - - // Store current Node provider selection - workloadConfigurations.vmInfra.selectedProviders = getSelectedProviders(); - workloadConfigurations.vmInfra.allSelected = document.getElementById("provider-all")?.checked || false; - - // Apply K8s configuration - document.getElementById("minVCPU").value = workloadConfigurations.k8sInfra.minVCPU; - document.getElementById("minRAM").value = workloadConfigurations.k8sInfra.minRAM; - document.getElementById("diskSize").value = workloadConfigurations.k8sInfra.diskSize; - - // Fetch K8s cluster info and update providers - const k8sInfo = await fetchK8sClusterInfo(); - if (k8sInfo) { - updateProvidersForK8s(k8sInfo); - } - - // Show K8s configuration info with dynamic data - showK8sConfigurationInfo(k8sInfo); - - // Update active state - workloadConfigurations.vmInfra.isActive = false; - workloadConfigurations.k8sInfra.isActive = true; - - } else { - // Switching from K8s to Node - save Node config - workloadConfigurations.k8sInfra.minVCPU = document.getElementById("minVCPU").value || "4"; - workloadConfigurations.k8sInfra.minRAM = document.getElementById("minRAM").value || "16"; - workloadConfigurations.k8sInfra.diskSize = document.getElementById("diskSize").value || "100"; - - // Apply Node configuration (restore previous or defaults) - document.getElementById("minVCPU").value = workloadConfigurations.vmInfra.minVCPU; - document.getElementById("minRAM").value = workloadConfigurations.vmInfra.minRAM; - document.getElementById("diskSize").value = workloadConfigurations.vmInfra.diskSize; - - // Restore Node provider selection - if (workloadConfigurations.vmInfra.allSelected) { - const allCheckbox = document.getElementById("provider-all"); - if (allCheckbox) { - allCheckbox.checked = true; - // Uncheck individual providers - const providerCheckboxes = document.querySelectorAll('#provider-checkboxes input[type="checkbox"]'); - providerCheckboxes.forEach(cb => cb.checked = false); - } - } else { - // Uncheck ALL first - const allCheckbox = document.getElementById("provider-all"); - if (allCheckbox) { - allCheckbox.checked = false; - } - - // Restore individual provider selections - const allProviderCheckboxes = document.querySelectorAll('#provider-checkboxes input[type="checkbox"]'); - allProviderCheckboxes.forEach(cb => cb.checked = false); - - if (workloadConfigurations.vmInfra.selectedProviders) { - workloadConfigurations.vmInfra.selectedProviders.forEach(provider => { - const checkbox = document.getElementById(`provider-${provider}`); - if (checkbox) { - checkbox.checked = true; - } - }); - } - } - - // Update dropdown text - updateProviderDropdownText(); - - // Update active state - workloadConfigurations.vmInfra.isActive = true; - workloadConfigurations.k8sInfra.isActive = false; - - // No alert needed for Node mode as requested - } - - console.log('Workload Type Changed:', isK8sMode ? 'K8s-Infra' : 'MC-Infra (Node)'); - console.log('Current Configuration:', workloadConfigurations); -} -window.toggleWorkloadType = toggleWorkloadType; - -// Function to show K8s configuration information with dynamic data -function showK8sConfigurationInfo(k8sInfo = null) { - // Get selected providers for display - var selectedProviders = getSelectedProviders(); - var allCheckbox = document.getElementById("provider-all"); - var providerInfo = ""; - - if (allCheckbox && allCheckbox.checked) { - providerInfo = "All Providers"; - } else if (selectedProviders.length > 0) { - providerInfo = selectedProviders.map(p => p.toUpperCase()).join(", "); - } else { - providerInfo = "No Providers Selected"; - } - - // Generate dynamic provider-specific information - let providerDetailsHtml = ""; - let nodeGroupCreationInfo = ""; - let nodeGroupSeparateInfo = ""; - - if (k8sInfo && k8sInfo.k8s_cluster) { - const providers = k8sInfo.k8s_cluster; - - // Separate providers by nodegroups_on_creation - const withNodeGroups = []; - const withoutNodeGroups = []; - - Object.keys(providers).forEach(provider => { - const info = providers[provider]; - if (info.nodegroups_on_creation) { - withNodeGroups.push(provider.toUpperCase()); - } else { - withoutNodeGroups.push(provider.toUpperCase()); - } - }); - - nodeGroupCreationInfo = withNodeGroups.length > 0 ? - `🟢 ${withNodeGroups.join(", ")}` : "None"; - nodeGroupSeparateInfo = withoutNodeGroups.length > 0 ? - `🔴 ${withoutNodeGroups.join(", ")}` : "None"; - - // Generate provider details - Object.keys(providers).forEach(provider => { - const info = providers[provider]; - const providerUpper = provider.toUpperCase(); - - providerDetailsHtml += ` -
    - ${providerUpper}
    - • NodeGroups on Creation: ${info.nodegroups_on_creation ? '✅ Yes' : '❌ No'}
    - • Node Image Designation: ${info.node_image_designation ? '✅ Required' : '❌ Not Required'}
    - • Required Subnet Count: ${info.required_subnet_count}
    - ${info.nodegroup_naming_rule ? `• NodeGroup Naming Rule: ${info.nodegroup_naming_rule}
    ` : ''} -
    - `; - }); - } else { - nodeGroupCreationInfo = "Unable to fetch current data"; - nodeGroupSeparateInfo = "Unable to fetch current data"; - providerDetailsHtml = "
    Unable to fetch provider-specific information
    "; - } - - // Show comprehensive Kubernetes information - Swal.fire({ - title: "⚙️ K8s-Infra Mode Activated", - html: ` -
    -
    - ⚠️ Notice: Managed Kubernetes Provisioning is under development and may have stability issues. -
    - -
    - ✅ Configuration Applied: Min vCPU: 4, Min Memory: 16GB, Disk: 100GB
    - 🏢 Available K8s Providers: ${providerInfo} -
    - -
    - Node Group created with cluster:
    - ${nodeGroupCreationInfo} -
    - -
    - Node Group added separately after cluster creation:
    - ${nodeGroupSeparateInfo} -
    - -
    - - 📖 Provider-specific Details - -
    - ${providerDetailsHtml} -
    -
    - -
    - - 📄 Raw K8s Cluster Info (JSON) - -
    -
    ${k8sInfo ? JSON.stringify(k8sInfo, null, 2) : 'Unable to fetch data'}
    -
    -
    -
    - `, - icon: "info", - confirmButtonText: "OK", - confirmButtonColor: "#007bff", - width: "700px" - }); -} - -// Function to get current workload type -// Function to get workload configuration -function getWorkloadConfiguration() { - return { - currentType: getCurrentWorkloadType(), - configurations: workloadConfigurations - }; -} -window.getWorkloadConfiguration = getWorkloadConfiguration; - -function getRecommendedSpec(idx, latitude, longitude) { - var hostname = configHostname; - var port = configPort; - var username = configUsername; - var password = configPassword; - - var minVCPU = document.getElementById("minVCPU").value; - var maxVCPU = document.getElementById("maxVCPU").value; - var minRAM = document.getElementById("minRAM").value; - var maxRAM = document.getElementById("maxRAM").value; - var specName = document.getElementById("specName").value; - var architecture = document.getElementById("architecture").value; - var selectedProviders = getSelectedProviders(); - var acceleratorModel = document.getElementById("acceleratorModel").value; - var minAcceleratorCount = document.getElementById("minAcceleratorCount").value; - var maxAcceleratorCount = document.getElementById("maxAcceleratorCount").value; - var minAMEM = document.getElementById("minAMEM").value; - var maxAMEM = document.getElementById("maxAMEM").value; - - var url = `${tbApiBase()}/recommendSpec`; - - function createPolicyConditions(metric, values, type) { - const conditions = []; - - if (type === 'range') { - if (values.min) conditions.push({ operand: `${values.min}`, operator: ">=" }); - if (values.max) conditions.push({ operand: `${values.max}`, operator: "<=" }); - } else if (type === 'single') { - if (values.value) conditions.push({ operand: `${values.value}` }); - } - - return { metric: metric, condition: conditions }; - } - - // Handle GPU-related conditions - var gpuPolicies = []; - if (acceleratorModel === "any") { - // For "Any GPU", add acceleratorType as "gpu" but exclude AcceleratorModel - gpuPolicies.push(createPolicyConditions("AcceleratorType", { value: "gpu" }, "single")); - } else if (acceleratorModel && acceleratorModel !== "") { - // For specific GPU models, add AcceleratorModel condition - gpuPolicies.push(createPolicyConditions("AcceleratorModel", { value: acceleratorModel }, "single")); - } - - // Handle provider conditions - support multiple providers with comma-separated values - var providerPolicies = []; - if (selectedProviders && selectedProviders.length > 0) { - // Check if ALL is selected or if no specific providers are selected - var isAllSelected = selectedProviders.includes("ALL") || selectedProviders.length === 0; - - if (!isAllSelected) { - // Create a single condition with comma-separated provider names - var providerString = selectedProviders.join(","); - providerPolicies.push(createPolicyConditions("ProviderName", { value: providerString }, "single")); - console.log("Provider filter applied:", providerString); - } else { - console.log("No provider filter applied (ALL selected or none specified)"); - } - } - // If no providers selected or ALL is selected, don't add provider conditions (means all providers) - - var policies = [ - createPolicyConditions("vCPU", { min: minVCPU, max: maxVCPU }, "range"), - createPolicyConditions("MemoryGiB", { min: minRAM, max: maxRAM }, "range"), - createPolicyConditions("CspSpecName", { value: specName }, "single"), - ...providerPolicies, // Spread the provider policies array (now supports comma-separated values) - createPolicyConditions("Architecture", { value: architecture }, "single"), // Architecture can also support comma-separated values - ...gpuPolicies, - createPolicyConditions("AcceleratorMemoryGB", { min: minAMEM, max: maxAMEM }, "range"), - createPolicyConditions("AcceleratorCount", { min: minAcceleratorCount, max: maxAcceleratorCount }, "range"), - ]; - - var recommendationPolicy = recommendPolicy.value; - var priorities = { - "location": { - metric: "location", - parameter: [{ key: "coordinateClose", val: [`${latitude}/${longitude}`] }], - weight: 1.0 - }, - "cost": { - metric: "cost", - weight: 1.0 - }, - "performance": { - metric: "performance", - weight: 1.0 - }, - "random": { - metric: "random", - weight: 1.0 - } - }; - - var struct = { - filter: { policy: policies }, - limit: 200, - priority: { policy: [priorities[recommendationPolicy]] } - }; - - var jsonBody = JSON.stringify(struct); - console.log("Request body for infraDynamicCheckRequest:", jsonBody); - - // // Show loading popup while API is processing - // Swal.fire({ - // title: 'Recommending Specification list', - // text: 'Please wait for a moment...', - // allowOutsideClick: false, - // allowEscapeKey: false, - // showConfirmButton: false, - // didOpen: () => { - // Swal.showLoading(); - // } - // }); - - axios({ - method: "post", - url: url, - headers: { "Content-Type": "application/json" }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - // Close loading popup - Swal.close(); - console.log(res); // for debug - handleAxiosResponse(res); - - if (res.data == null || res.data.length == 0) { - errorAlert("No recommended spec found with the given condition"); - return; - } - - // Spec selection popup - Swal.fire({ - title: "Select a Spec from the Recommendation List", - width: 1200, - position: 'center', - - // Spec selection popup HTML part with row selection instead of buttons - html: ` -
    -
    - - - - - - - - - - - - - - - - ${res.data.map((spec, index) => { - let costPerHour = spec.costPerHour < 0 || !spec.costPerHour - ? "unknown" - : `$${spec.costPerHour}`; - - - let acceleratorInfo; - if (spec.acceleratorModel && spec.acceleratorModel !== "undefined" && spec.acceleratorModel !== "") { - acceleratorInfo = `${spec.acceleratorModel} (C:${spec.acceleratorCount} ${spec.acceleratorMemoryGB})`; - } else { - acceleratorInfo = "None"; - } - - return ` - - - - - - - - - - - - `; - }).join('')} - -
    #CSPRegionSpecNameArchvCPUMem(Gi)Cost($/h)Accelerator
    ${index + 1}${spec.providerName.toUpperCase()}${spec.regionName}${spec.cspSpecName}${spec.architecture}${spec.vCPU}${spec.memoryGiB}${costPerHour}${acceleratorInfo}
    -
    -
    -
    Selected Spec Details
    -
    -
    - -
    - -`, - didOpen: () => { - // Set up row click event for the table - $('#specSelectionTable tbody').on('click', 'tr', function () { - const index = $(this).data('index'); - selectSpecRow(index); - }); - - // Spec selection function - window.selectSpecRow = function (index) { - // Reset previous selection - document.querySelectorAll('#specSelectionTable tbody tr').forEach(row => { - row.classList.remove('selected-spec'); - }); - - // Select new row - const selectedRow = document.getElementById(`spec-row-${index}`); - if (selectedRow) { - selectedRow.classList.add('selected-spec'); - } - - // Save selected index and update details - document.getElementById('selectedSpecIndex').value = index; - updateSpecDetails(index); - }; - - // Update spec details function - function updateSpecDetails(index) { - const spec = res.data[index]; - let costPerHour = spec.costPerHour < 0 || !spec.costPerHour ? "unknown" : `$${spec.costPerHour}`; - - // Basic spec information - styled to match image details - const specInfoHTML = ` -
    -
    - CSP: ${spec.providerName.toUpperCase()} -
    -
    - Region: ${spec.regionName} -
    -
    - Spec Name: ${spec.cspSpecName} -
    -
    - Architecture: ${spec.architecture} -
    -
    - vCPU: ${spec.vCPU} -
    -
    - Memory: ${spec.memoryGiB} GiB -
    -
    - Cost: ${costPerHour}/hour -
    - ${spec.acceleratorType === "gpu" ? ` -
    - Accelerator: ✓ GPU (${spec.acceleratorModel}) -
    -
    - GPU Count: ${spec.acceleratorCount} -
    -
    - GPU Memory: ${spec.acceleratorMemoryGB} GB -
    - ` : ` -
    - Accelerator: None -
    - `} -
    - `; - - // Details table - styled to match image details - let detailsTableHTML = ""; - if (spec.details && Array.isArray(spec.details) && spec.details.length > 0) { - detailsTableHTML = ` -
    - - - - - - - - - ${spec.details.map(item => - ` - - - ` - ).join('')} - -
    PropertyValue
    ${item.key}${item.value}
    -
    - `; - } - - const detailsHTML = specInfoHTML + detailsTableHTML; - - document.getElementById('specDetailsContent').innerHTML = detailsHTML; - } - - // Initialize DataTable - $('#specSelectionTable').DataTable({ - "paging": true, - "searching": true, - "ordering": true, - "info": true, - "responsive": true, - "scrollX": true, - "pageLength": 5, - "lengthMenu": [5, 10, 25, 50], - "order": [[0, 'asc']], - "columnDefs": [ - { - "targets": -1, - "orderable": false - } - ], - "language": { - "search": "Filtering Keyword:", - "lengthMenu": "Show _MENU_ entries", - "info": "_START_ - _END_ of _TOTAL_", - "infoEmpty": "No data available", - "paginate": { - "first": "First", - "last": "Last", - "next": "Next", - "previous": "Previous" - } - } - }); - - // Initialize spec details - updateSpecDetails(0); - }, - showCancelButton: true, - confirmButtonText: "Continue", - cancelButtonText: "Cancel", - preConfirm: () => { - return parseInt(document.getElementById('selectedSpecIndex').value); - } - }).then((result) => { - if (result.isConfirmed) { - // User selected a spec and confirmed - var selectedSpec = res.data[result.value]; - console.log("User selected spec:", selectedSpec); - - // Search for images based on the selected spec - const searchImageURL = `${tbApiBase()}/ns/system/resources/searchImage`; - const searchImageBody = { - matchedSpecId: selectedSpec.id, - osType: document.getElementById("osImage").value, - }; - - console.log("Searching images for selected spec:", selectedSpec.id); - - // Get namespace for custom image API call - var namespace = configNamespace; - - // Search images API call and custom images API call in parallel - Promise.all([ - // Regular images search - axios({ - method: "post", - url: searchImageURL, - headers: { "Content-Type": "application/json" }, - data: JSON.stringify(searchImageBody), - auth: { - username: `${username}`, - password: `${password}`, - }, - }), - // Custom images fetch - axios({ - method: "get", - url: `${tbApiBase()}/ns/${namespace}/resources/customImage`, - headers: { "Content-Type": "application/json" }, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).catch(err => { - console.log("Failed to fetch custom images (will continue with regular images only):", err); - return { data: { customImage: [] } }; // Return empty array if custom images API fails - }) - ]).then(([searchRes, customImageRes]) => { - console.log("searchImage response:", searchRes.data); - console.log("customImage response:", customImageRes.data); - - let availableImages = []; - let customImages = []; - - // Process regular images - if (searchRes.data && searchRes.data.imageList && searchRes.data.imageList.length > 0) { - availableImages = searchRes.data.imageList.map(img => ({ - id: img.id || "unknown", - cspImageName: img.cspImageName || "unknown", - osType: img.osType || "unknown", - osDistribution: img.osDistribution || "unknown", - osArchitecture: img.osArchitecture || "unknown", - creationDate: img.creationDate || "unknown", - description: img.description || "No description", - imageStatus: img.imageStatus || "unknown", - osPlatform: img.osPlatform || "unknown", - osDiskType: img.osDiskType || "unknown", - osDiskSizeGB: img.osDiskSizeGB || "unknown", - providerName: img.providerName || "unknown", - connectionName: img.connectionName || "unknown", - infraType: img.infraType || "unknown", - isGPUImage: img.isGPUImage || false, - isKubernetesImage: img.isKubernetesImage || false, - isBasicImage: img.isBasicImage || false, - isBasicGpuImage: img.isBasicGpuImage || false, - isCustomImage: false, - details: img.details || [] - })); - - console.log("Available regular images for this spec:"); - console.table(availableImages); - } - - // Process custom images - filter by matching provider and region - if (customImageRes.data && customImageRes.data.customImage && customImageRes.data.customImage.length > 0) { - const selectedProvider = selectedSpec.providerName; - const selectedRegion = selectedSpec.regionName; - - customImages = customImageRes.data.customImage - .filter(img => { - // Match provider - const imgProvider = img.providerName || ''; - if (imgProvider !== selectedProvider) return false; - - // Match region (regionList is an array) - const imgRegions = Array.isArray(img.regionList) ? img.regionList : [img.regionList]; - if (!imgRegions.includes(selectedRegion)) return false; - - return true; - }) - .map(img => ({ - id: img.id || "unknown", - cspImageName: img.cspImageName || img.name || "unknown", - osType: img.osType || img.guestOS || "unknown", - osDistribution: img.osDistribution || img.description || "Custom Image", - osArchitecture: img.osArchitecture || "unknown", - creationDate: img.creationDate || "unknown", - description: img.description || "Custom Image", - imageStatus: img.imageStatus || img.status || "unknown", - osPlatform: img.osPlatform || "unknown", - osDiskType: img.osDiskType || "unknown", - osDiskSizeGB: img.osDiskSizeGB || "unknown", - providerName: img.providerName || "unknown", - connectionName: img.connectionName || "unknown", - infraType: img.infraType || "unknown", - isGPUImage: false, - isKubernetesImage: false, - isBasicImage: false, - isBasicGpuImage: false, - isCustomImage: true, // Mark as custom image - details: img.details || [] - })); - - console.log("Available custom images for this spec:"); - console.table(customImages); - } - - // Merge custom images at the top, then regular images - availableImages = [...customImages, ...availableImages]; - - if (availableImages.length === 0) { - errorAlert("No images found for the selected specification"); - return; - } - - // Detect GPU spec - const isGpuSpec = selectedSpec.acceleratorType === "gpu"; - - // Re-sort when GPU spec is selected: custom > basic GPU > basic OS > GPU > rest - if (isGpuSpec) { - const gpuSortScore = img => - img.isCustomImage ? 4 : - img.isBasicGpuImage ? 3 : - img.isBasicImage ? 2 : - img.isGPUImage ? 1 : 0; - availableImages.sort((a, b) => gpuSortScore(b) - gpuSortScore(a)); - } - - // Build spec summary for display in image selection popup - const esc = window.escapeHtml; - const specCost = (selectedSpec.costPerHour > 0) - ? `$${parseFloat(selectedSpec.costPerHour).toFixed(5)}/h` - : 'N/A'; - const specAccel = (selectedSpec.acceleratorType === 'gpu' && selectedSpec.acceleratorModel) - ? ` | GPU: ${esc(selectedSpec.acceleratorModel)} ×${esc(String(selectedSpec.acceleratorCount || '?'))} (${esc(String(selectedSpec.acceleratorMemoryGB || '?'))}GB/ea)` - : ''; - - // Image selection popup - Swal.fire({ - title: "Select an Image from the Image Search List", - width: 1200, - html: ` -
    -
    - Selected Spec - ${esc(selectedSpec.id || '')} - | ${esc((selectedSpec.providerName || '').toUpperCase())} ${esc(selectedSpec.regionName || '')} - | vCPU: ${esc(String(selectedSpec.vCPU || ''))} | Mem: ${esc(String(selectedSpec.memoryGiB || ''))} GiB | Arch: ${esc(selectedSpec.architecture || 'N/A')} - | ${esc(specCost)} - ${specAccel} -
    - ${isGpuSpec ? ` -
    - - GPU Spec selected⭐🧮 Basic GPU images (GPU drivers pre-installed) are listed first. Plain OS images are also available. -
    ` : ''} -
    - - - - - - - - - - - - - ${availableImages.map((image, index) => { - const isRecommendedGpu = isGpuSpec && image.isBasicGpuImage; - - // Row class - const isCustomClass = image.isCustomImage ? 'custom-image-row' : ''; - const rowClass = isRecommendedGpu ? 'recommended-gpu-image-row' : (image.isBasicImage ? 'basic-image-row' : ''); - - // # column icons - const customIcon = image.isCustomImage ? ' 📸' : ''; - const basicIcon = image.isBasicImage ? ' ' : ''; - - // Support column icons — always show GPU/k8s status regardless of spec type - // isBasicGpuImage: GPU drivers pre-installed (recommended for GPU workloads) - // isGPUImage without isBasicGpuImage: GPU-capable but no pre-installed drivers - const gpuIcon = image.isBasicGpuImage - ? ' ⭐🧮' - : (image.isGPUImage ? ' 🧮' : ''); - const k8sIcon = image.isKubernetesImage ? ' ☸️' : ''; - - // Truncate long text for better table layout - increased limits for more space - const truncateText = (text, maxLength) => { - if (text.length <= maxLength) return text; - return text.substring(0, maxLength) + '..'; - }; - - const truncatedImageName = truncateText(image.cspImageName, 70); - const truncatedDistribution = truncateText(image.osDistribution, 70); - - return ` - - - - - - - - - `; - }).join('')} - -
    #OS TypeImage NameDistributionSupportArch
    ${index + 1}${customIcon}${basicIcon}${image.osType}${truncatedImageName}${truncatedDistribution}${gpuIcon}${k8sIcon}${image.osArchitecture}
    -
    -
    -
    Selected Image Details
    -
    -
    -
    - Enter Image ID directly... -
    -
    - - - -
    -
    -
    -
    - - - -
    - - `, - didOpen: () => { - // Set up row click event for the table - $('#imageSelectionTable tbody').on('click', 'tr', function () { - const index = $(this).data('index'); - selectImageRow(index); - }); - - // Image selection function - window.selectImageRow = function (index) { - // Reset previous selection - document.querySelectorAll('#imageSelectionTable tbody tr').forEach(row => { - row.classList.remove('selected-image'); - }); - - // Select new row - const selectedRow = document.getElementById(`image-row-${index}`); - if (selectedRow) { - selectedRow.classList.add('selected-image'); - } - - // Save selected index and update details - document.getElementById('selectedImageIndex').value = index; - updateImageDetails(index); - }; - - // Update image details function - function updateImageDetails(index) { - const image = availableImages[index]; - - // Combined image information - simplified layout - const imageInfoHTML = ` -
    -
    - Name: ${image.cspImageName} -
    -
    - Distribution: ${image.osDistribution} -
    -
    - Description: ${image.description} -
    -
    - Status: ${image.imageStatus} -
    - ${image.isKubernetesImage ? `
    K8s Support: ✓ Yes
    ` : ''} - ${image.isGPUImage ? `
    GPU Support: ✓ Yes
    ` : ''} - ${image.isBasicImage ? `
    Basic Image: ✓ Yes
    ` : ''} - ${image.isBasicGpuImage ? `
    Basic GPU Image: ✓ Yes (GPU drivers pre-installed)
    ` : ''} -
    - `; - - // Details table - simplified - let detailsTableHTML = ""; - if (image.details && Array.isArray(image.details) && image.details.length > 0) { - detailsTableHTML = ` -
    - - - - - - - - - ${image.details.map(item => - ` - - - ` - ).join('')} - -
    PropertyValue
    ${item.key}${item.value}
    -
    - `; - } - - const detailsHTML = imageInfoHTML + detailsTableHTML; - - document.getElementById('imageDetailsContent').innerHTML = detailsHTML; - } - - // Initialize DataTable - $('#imageSelectionTable').DataTable({ - "paging": true, - "searching": true, - "ordering": true, - "info": true, - "responsive": true, - "scrollX": true, - "pageLength": 5, - "lengthMenu": [5, 10, 25, 50], - "order": [[0, 'asc']], - "columnDefs": [ - { - "targets": 0, - "type": "num" - }, - { - "targets": -1, - "orderable": false - }, - { - "targets": -2, - "orderable": false - } - ], - "language": { - "search": "Filtering Keyword:", - "lengthMenu": "Show _MENU_ entries", - "info": "_START_ - _END_ of _TOTAL_", - "infoEmpty": "No data available", - "paginate": { - "first": "First", - "last": "Last", - "next": "Next", - "previous": "Previous" - } - } - }); - - // Initialize image details - updateImageDetails(0); - - // Direct Image ID button handlers - $('#useDirectImageIdBtn').on('click', function() { - const directImageId = $('#directImageIdInput').val().trim(); - if (!directImageId) { - $('#directImageIdStatus').html('⚠️ Please enter an Image ID'); - return; - } - // Set the flags and value - $('#useDirectImageIdFlag').val('true'); - $('#directImageIdValue').val(directImageId); - // Clear table selection and show status - $('#imageSelectionTable tbody tr').removeClass('selected-image'); - // XSS-safe: escape user input before inserting into HTML - const escapedId = $('
    ').text(directImageId).html(); - $('#directImageIdStatus').html('✅ Applied: ' + escapedId + ''); - $('#directImageIdContainer').css('border-color', '#28a745').css('background-color', '#d4edda'); - }); - - $('#clearDirectImageIdBtn').on('click', function() { - $('#useDirectImageIdFlag').val('false'); - $('#directImageIdValue').val(''); - $('#directImageIdInput').val(''); - $('#directImageIdStatus').html(''); - $('#directImageIdContainer').css('border-color', '#ced4da').css('background-color', '#f8f9fa'); - // Re-select the first row - selectImageRow(0); - }); - }, - showCancelButton: true, - confirmButtonText: "Continue", - cancelButtonText: "Cancel", - preConfirm: () => { - const useDirect = document.getElementById('useDirectImageIdFlag').value === 'true'; - const directImageId = document.getElementById('directImageIdValue').value; - const selectedIndex = parseInt(document.getElementById('selectedImageIndex').value); - return { - useDirectImageId: useDirect, - directImageId: directImageId, - selectedIndex: selectedIndex - }; - } - }).then((imageResult) => { - if (imageResult.isConfirmed) { - // Determine which image to use - let selectedImageId; - let selectedImage; - - if (imageResult.value.useDirectImageId && imageResult.value.directImageId) { - // User specified a direct image ID - selectedImageId = imageResult.value.directImageId; - selectedImage = { - cspImageName: selectedImageId, - osDistribution: "Direct Image ID (will be auto-registered if available in CSP)", - osType: "Unknown", - osArchitecture: "Unknown", - isDirectInput: true - }; - console.log("User specified direct image ID:", selectedImageId); - } else { - // User selected from the list - selectedImage = availableImages[imageResult.value.selectedIndex]; - selectedImageId = selectedImage.cspImageName; - console.log("User selected image from list:", selectedImage); - } - - // Now proceed to the final spec confirmation step - var createInfraReqVm = $.extend({}, createInfraReqVmTmplt); - var recommendedSpec = selectedSpec; - - createInfraReqVm.name = "g" + (nodeGroupRequestFromSpecList.length + 1).toString(); - - var osImage = document.getElementById("osImage"); - var diskSize = document.getElementById("diskSize"); - - createInfraReqVm.specId = selectedSpec.id; - createInfraReqVm.imageId = selectedImageId; // Use selected image ID (from list or custom input) - createInfraReqVm.rootDiskType = selectedSpec.rootDiskType; - - var diskSizeInput = parseInt(diskSize.value, 10); - if (isNaN(diskSizeInput) || diskSizeInput <= 0) { - diskSizeInput = 0; // 0 means use CSP default - } - createInfraReqVm.rootDiskSize = diskSizeInput; - // Note: 0 means use CSP default, positive values specify exact size - // selectedSpec.rootDiskSize is now an integer from the API - - // Create image display for the confirmation popup (full width available) - let imageSelectHTML = ` -
    -
    - ${selectedImage.osDistribution} -
    - ${selectedImage.cspImageName} -
    - `; - - let costPerHour = selectedSpec.costPerHour; - if (costPerHour < 0 || !costPerHour) { - costPerHour = "unknown"; - } - - // Store costPerHour in selectedSpec for buildSpecConfigPopupHtml - selectedSpec.costPerHour = costPerHour; - - // Use setTimeout to open as independent popup (not nested) - setTimeout(() => { - Swal.fire({ - title: "📋 NodeGroup Configuration", - width: 650, - html: buildSpecConfigPopupHtml(selectedSpec, createInfraReqVm, { - isEdit: false, - showValidation: true, - imageSelectHTML: imageSelectHTML, - currentLabels: '' - }), - - didOpen: () => { - // Helper: read current dropdown values for refining the review. - // Empty/"default" rootDiskType means "let CSP/Spider pick its - // default"; the backend treats both as the same sentinel. - const getReviewRefinements = () => { - const rdtEl = document.getElementById('rootDiskTypeSelect'); - const zoneEl = document.getElementById('zoneSelect'); - return { - rootDiskType: rdtEl ? rdtEl.value : '', - zone: zoneEl ? zoneEl.value : '' - }; - }; - - // Call specImagePairReview API. Re-fires whenever rootDiskType - // or zone changes so the user sees real-time stock feedback. - // A monotonic request counter ensures that out-of-order responses - // (a slower earlier request resolving after a newer one) cannot - // overwrite the UI with stale validity/suggestions. - let reviewRequestSeq = 0; - const reviewSpecImagePair = async () => { - const statusEl = document.getElementById('specImageReviewStatus'); - const spinnerEl = document.getElementById('specImageReviewSpinner'); - const detailsEl = document.getElementById('specImageReviewDetails'); - const sectionEl = document.getElementById('specImageReviewSection'); - - if (!statusEl || !detailsEl || !sectionEl) return; - - const mySeq = ++reviewRequestSeq; - - if (spinnerEl) spinnerEl.style.display = ''; - statusEl.textContent = 'Checking...'; - statusEl.style.backgroundColor = '#6c757d'; - statusEl.style.color = '#fff'; - - const refinements = getReviewRefinements(); - - try { - const response = await fetch(`${tbApiBase()}/specImagePairReview`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Basic ' + btoa(configUsername + ':' + configPassword) - }, - body: JSON.stringify({ - specId: selectedSpec.id, - imageId: selectedImageId, - rootDiskType: refinements.rootDiskType, - zone: refinements.zone - }) - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - // Drop stale responses: a newer review has been kicked off - // since this one started. - if (mySeq !== reviewRequestSeq) return; - if (spinnerEl) spinnerEl.style.display = 'none'; - - // Helper function to escape HTML (prevent XSS) - const escapeHtml = (str) => $('
    ').text(str).html(); - - // Build a "suggestion" line from availability hints. - const suggestionParts = []; - if (result.suggestedZone) { - suggestionParts.push('Suggested zone: ' + escapeHtml(result.suggestedZone)); - } - if (result.suggestedSystemDisk) { - suggestionParts.push('Suggested rootDiskType: ' + escapeHtml(result.suggestedSystemDisk)); - } - const suggestionLine = suggestionParts.length > 0 - ? '
    💡 ' + suggestionParts.join(' · ') + '' - : ''; - - // Add CSP-reported available disk types to dropdown - const rdtSelect = document.getElementById('rootDiskTypeSelect'); - if (rdtSelect && result.availability && result.availability.zones) { - const allDisks = new Set(); - result.availability.zones.forEach(z => { - if (z.available && z.supportedDisks) { - z.supportedDisks.forEach(d => { if (d) allDisks.add(d); }); - } - }); - const newDisks = Array.from(allDisks).filter( - disk => !Array.from(rdtSelect.options).some(o => o.value === disk) - ); - if (newDisks.length > 0) { - // Remove existing validation group if re-triggered - const existingGroup = rdtSelect.querySelector('optgroup[data-validation]'); - if (existingGroup) existingGroup.remove(); - const group = document.createElement('optgroup'); - group.label = '💡 Available (from validation)'; - group.setAttribute('data-validation', '1'); - newDisks.forEach(disk => { - const opt = document.createElement('option'); - opt.value = disk; - opt.textContent = disk; - group.appendChild(opt); - }); - rdtSelect.appendChild(group); - } - if (result.suggestedSystemDisk && (rdtSelect.value === 'default' || rdtSelect.value === '')) { - rdtSelect.value = result.suggestedSystemDisk; - rdtSelect.dispatchEvent(new Event('change')); // refresh size hint for the suggested type - } - } - - if (result.isValid) { - statusEl.textContent = '✓ Valid'; - statusEl.style.backgroundColor = '#28a745'; - sectionEl.style.borderColor = '#28a745'; - sectionEl.style.backgroundColor = '#d4edda'; - - let details = []; - // Show main message first - if (result.message) details.push(escapeHtml(result.message)); - if (result.estimatedCost) details.push('Cost: ' + escapeHtml(result.estimatedCost)); - if (result.info && result.info.length > 0) details.push(...result.info.map(escapeHtml)); - let html = details.join(' | '); - if (result.warnings && result.warnings.length > 0) { - html += '
    ⚠ ' + result.warnings.map(escapeHtml).join('
    ⚠ ') + '
    '; - } - detailsEl.innerHTML = html + suggestionLine; - } else { - statusEl.textContent = '✗ Risk Detected'; - statusEl.style.backgroundColor = '#dc3545'; - sectionEl.style.borderColor = '#dc3545'; - sectionEl.style.backgroundColor = '#f8d7da'; - - // Show main message prominently - let content = ''; - if (result.message) { - content += '' + escapeHtml(result.message) + ''; - } - let errors = result.errors || []; - if (errors.length > 0) { - content += '
    ' + errors.map(escapeHtml).join('
    ') + '
    '; - } - detailsEl.innerHTML = content + suggestionLine; - } - } catch (error) { - if (mySeq !== reviewRequestSeq) return; - if (spinnerEl) spinnerEl.style.display = 'none'; - statusEl.textContent = '⚠ Check Failed'; - statusEl.style.backgroundColor = '#ffc107'; - statusEl.style.color = '#212529'; - detailsEl.textContent = 'Could not verify: ' + error.message; - detailsEl.style.color = '#856404'; - } - }; - - // Populate RootDiskType dropdown based on CSP (using common helper) - const imageIdForDisk = () => { - const sel = document.getElementById('osImageSelect'); - if (sel && sel.value) return sel.value; - return createInfraReqVm.imageId || (typeof selectedImage !== 'undefined' && selectedImage ? selectedImage.cspImageName : '') || ''; - }; - const rootDiskPopulatePromise = populateRootDiskTypeSelect('rootDiskTypeSelect', selectedSpec, selectedSpec.rootDiskType || 'default', - { sizeInputId: 'rootDiskSizeCustom', hintId: 'rootDiskSizeHint', imageId: imageIdForDisk }); - const osSelForHint = document.getElementById('osImageSelect'); - if (osSelForHint) osSelForHint.addEventListener('change', () => { - // Image changed: re-query (OS / minimum root size differ per image), keeping the chosen type. - const cur = document.getElementById('rootDiskTypeSelect'); - populateRootDiskTypeSelect('rootDiskTypeSelect', selectedSpec, cur ? cur.value : 'default', - { sizeInputId: 'rootDiskSizeCustom', hintId: 'rootDiskSizeHint', imageId: imageIdForDisk }); - }); - - // Fetch and populate Zone dropdown using the new availableZonesForSpec API (GET method) - const zonePopulatePromise = populateZoneSelect('zoneSelect', 'zoneLoadingSpinner', selectedSpec.id, '', 'zoneStatusMessage'); - - // Re-fire the pair review whenever the user refines rootDiskType - // or zone, so the suggestion/warning reflects the actual choice. - // Debounced to avoid bursting the API on rapid changes. - let reviewDebounce = null; - const scheduleReview = () => { - if (reviewDebounce) clearTimeout(reviewDebounce); - reviewDebounce = setTimeout(reviewSpecImagePair, 250); - }; - const rdtEl = document.getElementById('rootDiskTypeSelect'); - if (rdtEl) rdtEl.addEventListener('change', scheduleReview); - const zoneEl = document.getElementById('zoneSelect'); - if (zoneEl) zoneEl.addEventListener('change', scheduleReview); - - // Initial review (uses whatever default values the dropdowns have). - reviewSpecImagePair(); - - // populateZoneSelect is async; once zones are loaded the select - // may have a non-empty default value. Re-run the review so the - // first result reflects the actually-selected zone instead of - // the empty placeholder. - if (zonePopulatePromise && typeof zonePopulatePromise.then === 'function') { - zonePopulatePromise.then(() => { - const zSel = document.getElementById('zoneSelect'); - if (zSel && zSel.value) scheduleReview(); - }).catch(() => { /* populateZoneSelect logs its own errors */ }); - } - - // Focus on the Node count input for better user experience - const vmCountInput = document.getElementById('ndCount'); - if (vmCountInput) { - vmCountInput.focus(); - } - - // Add input validation feedback for Node count - if (vmCountInput) { - vmCountInput.addEventListener('input', function() { - const value = parseInt(this.value, 10); - const isValid = !isNaN(value) && value >= 1 && value <= 1000; - - if (isValid) { - this.style.borderColor = '#28a745'; - this.style.backgroundColor = '#f8fff9'; - } else { - this.style.borderColor = '#dc3545'; - this.style.backgroundColor = '#fff5f5'; - } - }); - } - - // Add input validation feedback for root disk size - const rootDiskInput = document.getElementById('rootDiskSizeCustom'); - if (rootDiskInput) { - rootDiskInput.addEventListener('input', function() { - const value = this.value.trim(); - const isValid = value === 'default' || value === '' || /^\d+$/.test(value); - - if (isValid) { - this.style.borderColor = '#28a745'; - this.style.backgroundColor = '#f8fff9'; - } else { - this.style.borderColor = '#dc3545'; - this.style.backgroundColor = '#fff5f5'; - } - }); - } - - // Add input validation feedback for labels - const labelsInput = document.getElementById('vmLabels'); - if (labelsInput) { - // Setup label input listener for chip sync - window.setupLabelInputListener('vmLabels'); - - // Auto-add GPU label if spec has GPU - const hasGpu = selectedSpec.acceleratorType === "gpu" || selectedSpec.acceleratorModel; - if (hasGpu) { - window.autoAddGpuLabel(true, 'vmLabels'); - } - - labelsInput.addEventListener('input', function() { - const value = this.value.trim(); - // Basic validation for key=value,key=value format - const isValid = value === '' || /^[a-zA-Z0-9_-]+=.+?(,[a-zA-Z0-9_-]+=.+?)*$/.test(value); - - if (isValid) { - this.style.borderColor = '#28a745'; - this.style.backgroundColor = '#f8fff9'; - } else { - this.style.borderColor = '#ffc107'; - this.style.backgroundColor = '#fffef5'; - } - - // Sync label suggestion chips with input - window.syncLabelSuggestionChips('vmLabels'); - }); - } - }, - - inputAttributes: { - autocapitalize: "off", - }, - showCancelButton: true, - confirmButtonText: "➕ Add NodeGroup", - confirmButtonColor: '#28a745', - cancelButtonText: "Cancel", - //showLoaderOnConfirm: true, - position: "center", - //back(disabled section)ground color - backdrop: `rgba(0, 0, 0, 0.08)`, - preConfirm: () => { - // ndCount input validation - const vmCountInput = document.getElementById('ndCount'); - let ndCount = parseInt(vmCountInput.value, 10); - if (isNaN(ndCount) || ndCount < 1) { - Swal.showValidationMessage('Enter a valid Node count (1 or more)'); - return false; - } - - // rootDiskType select validation - const rootDiskTypeSelect = document.getElementById('rootDiskTypeSelect'); - let rootDiskTypeValue = rootDiskTypeSelect ? rootDiskTypeSelect.value : "default"; - if (!rootDiskTypeValue) { - rootDiskTypeValue = "default"; - } - - // rootDiskSize input validation (actual value is retrieved after confirmation below) - const rootDiskSizeInput = document.getElementById('rootDiskSizeCustom'); - let rootDiskSizeValue = rootDiskSizeInput.value.trim(); - // Empty or 0 means use CSP default - if (rootDiskSizeValue !== "" && rootDiskSizeValue !== "0") { - if (!/^\d+$/.test(rootDiskSizeValue)) { - Swal.showValidationMessage('Disk size must be empty (default) or a positive number'); - return false; - } - const sizeErr = validateDiskSizeAgainstRule(parseInt(rootDiskSizeValue, 10), getSelectedRootDiskRule(rootDiskTypeSelect)); - if (sizeErr) { - Swal.showValidationMessage(sizeErr); - return false; - } - } - - const osImageSelect = document.getElementById('osImageSelect'); - if (osImageSelect && osImageSelect.value) { - console.log(osImageSelect.value); - createInfraReqVm.imageId = osImageSelect.value; - } - if (!createInfraReqVm.imageId) { - Swal.showValidationMessage('Select an OS image'); - return false; - } - - return ndCount; - }, - - - }).then((result) => { - // result.value is false if result.isDenied or another key such as result.isDismissed - if (result.value) { - - createInfraReqVm.nodeGroupSize = parseInt(result.value, 10) || 1; - if (createInfraReqVm.nodeGroupSize <= 0) { - createInfraReqVm.nodeGroupSize = 1; - } - - const rootDiskTypeSelect = document.getElementById('rootDiskTypeSelect'); - const rootDiskTypeValue = rootDiskTypeSelect ? rootDiskTypeSelect.value : "default"; - console.log("RootDiskType:", rootDiskTypeValue); - createInfraReqVm.rootDiskType = rootDiskTypeValue || "default"; - - const rootDiskSizeInput = document.getElementById('rootDiskSizeCustom').value.trim(); - if (rootDiskSizeInput) { - console.log("RootDiskSize:", rootDiskSizeInput); - createInfraReqVm.rootDiskSize = parseInt(rootDiskSizeInput, 10) || 0; - } else { - createInfraReqVm.rootDiskSize = 0; - } - - // Get selected zone (optional) - const zoneSelect = document.getElementById('zoneSelect'); - const selectedZone = zoneSelect ? zoneSelect.value : ""; - if (selectedZone) { - console.log("Zone:", selectedZone); - createInfraReqVm.zone = selectedZone; - } - - // Distribute Nodes across subnets (per NodeGroup) - const distCb = document.getElementById('distributeSubnetsCheckbox'); - if (distCb) { - createInfraReqVm.distributeSubnets = distCb.checked; - } - - // Parse labels using common helper function - const vmLabelsInput = document.getElementById('vmLabels').value.trim(); - const labels = parseLabelsString(vmLabelsInput); - if (Object.keys(labels).length > 0) { - createInfraReqVm.label = labels; - - // Add used labels to recently used list - Object.entries(labels).forEach(([key, value]) => { - window.addToRecentLabels(`${key}=${value}`); - }); - } - - - console.log( - `${createInfraReqVm.specId}` + - `\t(${createInfraReqVm.nodeGroupSize})` - ); - - // Check if we're editing an existing NodeGroup or adding a new one - if (window.editingNodeGroupIndex >= 0) { - // Update existing NodeGroup - nodeGroupRequestFromSpecList[window.editingNodeGroupIndex] = createInfraReqVm; - recommendedSpecList[window.editingNodeGroupIndex] = recommendedSpec; - console.log(`Updated NodeGroup at index ${window.editingNodeGroupIndex}`); - window.editingNodeGroupIndex = -1; // Reset editing mode - } else { - // Add new NodeGroup - nodeGroupRequestFromSpecList.push(createInfraReqVm); - recommendedSpecList.push(recommendedSpec); - } - - // Update NodeGroup review panel - renderMapFromConfig(); - updateNodeGroupReview(); - - // Activate provision-tab after successful configuration - try { - // Remove active class from all tabs - document.querySelectorAll('.nav-link').forEach(tab => { - tab.classList.remove('active'); - }); - document.querySelectorAll('.tab-pane').forEach(pane => { - pane.classList.remove('show', 'active'); - }); - - // Activate provision-tab - const provisionTab = document.getElementById('provision-tab'); - const provisionPane = document.getElementById('provision'); - - if (provisionTab && provisionPane) { - provisionTab.classList.add('active'); - provisionPane.classList.add('show', 'active'); - - // Trigger Bootstrap tab shown event if needed - if (typeof $ !== 'undefined' && $.fn.tab) { - $(provisionTab).tab('show'); - } - } - } catch (error) { - console.log('Failed to activate provision tab:', error); - } - } else { - console.log("Node configuration failed for this location"); - latLonInputPairIdx--; - renderMapFromConfig(); - } - }); - // Delay (ms) to ensure previous popup is fully closed before opening new one - }, 100); - } else { - // User canceled image selection - console.log("Image selection canceled"); - window.editingNodeGroupIndex = -1; // Reset editing mode - latLonInputPairIdx--; - renderMapFromConfig(); - } - }); - }).catch(error => { - console.error("Failed to get image information:", error); - }); - } else { - // User canceled spec selection - console.log("Spec selection canceled"); - window.editingNodeGroupIndex = -1; // Reset editing mode - latLonInputPairIdx--; - renderMapFromConfig(); - return; - } - }).catch(function (error) { - console.log(error); - errorAlert("Cannot show spec selection dialog (Check log for details)"); - if (error.response && error.response.data) { - displayJsonData(error.response.data, typeError); - } - }); - }).catch(function (error) { - // Close loading popup on error - Swal.close(); - - console.log(error); - - // Check if it's a connection error (network issue or server down) - if (error.code === 'ECONNREFUSED' || error.code === 'ERR_NETWORK' || - error.message?.includes('Network Error') || !error.response) { - // Connection error - re-check CB-Tumblebug connection status - Swal.fire({ - icon: 'error', - title: 'Cannot recommend a spec', - html: 'Connection to CB-Tumblebug server may have been lost.
    Would you like to check the connection status?', - showCancelButton: true, - confirmButtonText: 'Check Connection', - cancelButtonText: 'Close' - }).then((result) => { - if (result.isConfirmed) { - checkConnectionWithRetry(); - } - }); - } else { - errorAlert("Cannot recommend a spec (Check log for details)"); - } - - if (error.response && error.response.data) { - displayJsonData(error.response.data, typeError); - } - }); -} -window.getRecommendedSpec = getRecommendedSpec; - -// Global variable for NodeGroup editing mode (-1 means new, >= 0 means editing existing index) -window.editingNodeGroupIndex = -1; - -// ========== Common Helper Functions for Spec Configuration Popup ========== - -// Cache of diskOptions responses keyed by specId|imageId (plain object: `Map` here is OpenLayers' Map). -const diskOptionsCache = {}; - -/** - * Fetch GET /ns/system/resources/spec/{specId}/diskOptions (CSP-native disk types usable with the spec). - * @returns {Promise} response body, or null on failure - */ -async function fetchSpecDiskOptions(specId, imageId = '') { - if (!specId) return null; - const cacheKey = `${specId}|${imageId}`; - if (cacheKey in diskOptionsCache) return diskOptionsCache[cacheKey]; - try { - // imageId lets the server pick OS-specific root size rules and apply the image's minimum OS disk size. - const qs = imageId ? `?imageId=${encodeURIComponent(imageId)}` : ''; - const resp = await fetch(`${tbApiBase()}/ns/system/resources/spec/${encodeURIComponent(specId)}/diskOptions${qs}`, { - headers: { 'Authorization': 'Basic ' + btoa(configUsername + ':' + configPassword) } - }); - if (!resp.ok) return null; - const data = await resp.json(); - diskOptionsCache[cacheKey] = data; - return data; - } catch (e) { - console.warn('diskOptions fetch failed:', e); - return null; - } -} - -/** Human-readable size rule, e.g. "10–65536 GB", "50 / 100 GB", "≥ 50 GB". */ -function formatDiskSizeRule(rule) { - if (!rule) return ''; - if (rule.allowed && rule.allowed.length) return rule.allowed.join(' / ') + ' GB'; - let txt = ''; - if (rule.min && rule.max) txt = `${rule.min}–${rule.max} GB`; - else if (rule.min) txt = `≥ ${rule.min} GB`; - else if (rule.max) txt = `≤ ${rule.max} GB`; - if (rule.step && rule.step > 1) txt += ` (step ${rule.step})`; - return txt; -} - -/** Validate a size (GB) against a rule; returns '' if ok, else a message. 0/empty = CSP default, always ok. */ -function validateDiskSizeAgainstRule(size, rule) { - if (!size || !rule) return ''; - if (rule.allowed && rule.allowed.length && !rule.allowed.includes(size)) { - return `Disk size must be one of ${rule.allowed.join(', ')} GB`; - } - if (rule.min && size < rule.min) return `Disk size must be at least ${rule.min} GB`; - if (rule.max && size > rule.max) return `Disk size must be at most ${rule.max} GB`; - if (rule.step && rule.step > 1 && size % rule.step !== 0) return `Disk size must be a multiple of ${rule.step} GB`; - return ''; -} - -/** - * Root disk size rule (rootDiskSizeGB, already resolved for the image's OS by the API) of the selected option. - * @param {HTMLSelectElement} select - the root disk type select populated by populateRootDiskTypeSelect - */ -function getSelectedRootDiskRule(select) { - if (!select) return null; - const opt = select.options[select.selectedIndex]; - if (!opt || !opt.dataset.rule) return null; - return JSON.parse(opt.dataset.rule); -} - -/** Update the size placeholder and the tooltip (ⓘ icon + input title) next to a root disk type select. */ -function refreshRootDiskSizeHint(selectId, sizeInputId, hintId) { - const select = document.getElementById(selectId); - const sizeInput = document.getElementById(sizeInputId); - const hint = document.getElementById(hintId); - const rule = getSelectedRootDiskRule(select); - const txt = formatDiskSizeRule(rule); - if (sizeInput) sizeInput.placeholder = txt ? `Default (${txt})` : 'Default'; - // Details stay hidden behind a tooltip (ⓘ icon / input hover) to keep the form compact. - const opt = select && select.options[select.selectedIndex]; - const note = opt && opt.dataset.note ? opt.dataset.note : ''; - const tip = [txt ? `Allowed: ${txt}` : '', note].filter(Boolean).join('\n'); - if (sizeInput) sizeInput.title = tip; - if (hint) { - hint.title = tip; - hint.style.display = tip ? 'inline' : 'none'; - } -} - -/** - * Populate RootDiskType dropdown from GET .../spec/{specId}/diskOptions?imageId=. - * Option labels show the CSP-native identifier plus size range; each option carries its - * rootDiskSizeGB rule (resolved for the image's OS / minimum size) in data-rule. - * On API failure only "default" is offered. - * @param {string} selectId - DOM element ID of the select dropdown - * @param {object} spec - spec object ({id, providerName}) - * @param {string} currentValue - Currently selected disk type value - * @param {object} [opts] - {sizeInputId, hintId, imageId}; imageId may be a string or a function - */ -async function populateRootDiskTypeSelect(selectId, spec, currentValue, opts = {}) { - const select = document.getElementById(selectId); - if (!select) return; - const specId = spec && spec.id ? spec.id : ''; - select.innerHTML = ''; - - const addOption = (value, label, rule, note) => { - const option = document.createElement('option'); - option.value = value; - option.textContent = label; - if (rule) option.dataset.rule = JSON.stringify(rule); - if (note) option.dataset.note = note; - if (value === currentValue) option.selected = true; - select.appendChild(option); - return option; - }; - - const imageId = typeof opts.imageId === 'function' ? opts.imageId() : (opts.imageId || ''); - const data = await fetchSpecDiskOptions(specId, imageId); - if (data && data.supported && Array.isArray(data.diskTypes)) { - const defaultLabel = data.defaultRootDiskType ? `default (${data.defaultRootDiskType})` : 'default'; - const imgNote = data.imageMinRootDiskSizeGB ? `Image needs ≥ ${data.imageMinRootDiskSizeGB} GB` : ''; - addOption('default', defaultLabel, null, [imgNote, data.note || ''].filter(Boolean).join(' · ')); - data.diskTypes.filter(t => t.rootDisk && t.available !== false).forEach(t => { - const range = formatDiskSizeRule(t.rootDiskSizeGB); - const label = `${t.diskType}${t.displayName && t.displayName !== t.diskType ? ' — ' + t.displayName : ''}${range ? ' (' + range + ')' : ''}`; - addOption(t.diskType, label, t.rootDiskSizeGB || null, t.note || (t.availability && t.availability.note) || ''); - }); - if (!data.rootDiskSelectable) select.disabled = true; - } else { - addOption('default', 'default', null, 'Disk options unavailable (diskOptions API failed)'); - } - // Keep a previously chosen value even if it is not in the list (e.g. legacy CB-Spider alias). - if (currentValue && currentValue !== 'default' && !Array.from(select.options).some(o => o.value === currentValue)) { - addOption(currentValue, `${currentValue} (custom)`).selected = true; - } - if (opts.sizeInputId) { - const refresh = () => refreshRootDiskSizeHint(selectId, opts.sizeInputId, opts.hintId); - select.addEventListener('change', refresh); - refresh(); - } -} - -/** - * Fetch available zones from API and populate Zone dropdown. - * @param {string} selectId - DOM element ID of the zone select dropdown - * @param {string} spinnerId - DOM element ID of the loading spinner - * @param {string} specId - Spec ID to query zones for - * @param {string} currentZone - Currently selected zone value - * @param {string|null} statusMessageId - DOM element ID for status message display - */ -async function populateZoneSelect(selectId, spinnerId, specId, currentZone, statusMessageId) { - const zoneSelect = document.getElementById(selectId); - const spinner = document.getElementById(spinnerId); - const statusMessage = statusMessageId ? document.getElementById(statusMessageId) : null; - - if (!zoneSelect || !specId) { - if (spinner) spinner.style.display = 'none'; - return; - } - - // Use same protocol as current page for API calls - const apiProtocol = window.location.protocol === 'https:' ? 'https' : 'http'; - - try { - const response = await fetch(`${tbApiBase()}/availableZonesForSpec?specId=${encodeURIComponent(specId)}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Basic ' + btoa(configUsername + ':' + configPassword) - } - }); - - if (spinner) spinner.style.display = 'none'; - const result = await response.json(); - - if (response.ok && result.availableZones && result.availableZones.length > 0) { - result.availableZones.forEach(zone => { - const option = document.createElement('option'); - option.value = zone; - option.textContent = zone; - if (zone === currentZone) option.selected = true; - zoneSelect.appendChild(option); - }); - - if (statusMessage) { - statusMessage.textContent = `${result.availableZones.length} verified zone(s) available`; - statusMessage.style.color = '#28a745'; - } - } else if (result.hasZoneConcept === false) { - if (statusMessage) { - statusMessage.textContent = 'Zone not applicable, auto-selection will be used'; - statusMessage.style.color = '#666'; - } - } else { - if (statusMessage) { - statusMessage.textContent = result.errorMessage || 'No verified zones available'; - statusMessage.style.color = '#856404'; - } - } - } catch (error) { - if (spinner) spinner.style.display = 'none'; - if (statusMessage) { - const errorDetails = error && error.message ? ` (${error.message})` : ''; - statusMessage.textContent = 'Failed to fetch zones. Please check your connection.' + errorDetails; - statusMessage.style.color = '#dc3545'; - } - console.warn('Zone fetch failed:', error); - } -} - -/** - * Parse labels from comma-separated key=value string format. - * @param {string} labelsText - Comma-separated labels (e.g., "role=worker, env=prod") - * @returns {Object} Parsed labels as key-value object - */ -function parseLabelsString(labelsText) { - const labels = {}; - if (labelsText) { - labelsText.split(',').forEach(pair => { - const [key, value] = pair.trim().split('='); - if (key && value) { - labels[key.trim().toLowerCase()] = value.trim(); - } - }); - } - return labels; -} - -/** - * Convert labels object to comma-separated key=value string. - * @param {Object} labelsObj - Labels as key-value object - * @returns {string} Comma-separated string (e.g., "role=worker, env=prod") - */ -function labelsToString(labelsObj) { - if (!labelsObj) return ''; - return Object.entries(labelsObj).map(([k, v]) => `${k}=${v}`).join(', '); -} - -// ========== Common Popup Styles ========== -const POPUP_STYLES = ` - -`; - -/** - * Build HTML for NodeGroup Configuration popup with spec, image, and Node settings. - * Creates a modern, compact layout with grouped sections. - * @param {Object} spec - Node specification object with provider, region, CPU, memory, etc. - * @param {Object} nodeConf - Node configuration object with imageId, zone, labels, etc. - * @param {Object} options - Optional configuration parameters - * @param {boolean} options.isEdit - Whether this is edit mode (vs new spec selection) - * @param {string} options.imageSelectHTML - Custom HTML for image selection input - * @param {boolean} options.showValidation - Show validation section (for new specs) - * @param {Object} options.validationResult - Validation result object - * @returns {string} Complete HTML string for the popup content - */ -// Returns a CSP-specific hint describing how "distribute Nodes across subnets" behaves for the -// given provider, based on how CB-Tumblebug provisions subnets per CSP and each CSP's subnet model. -function subnetDistributionCspHint(providerName) { - const p = resolveCloudPlatform(providerName); - if (p === 'ibm') - return "⚠ IBM uses a single subnet (VPC constraint) — distribution has no effect; all Nodes share one subnet."; - if (p === 'ncp') - return "⚠ NCP places all subnets in the same zone — Nodes spread across subnets but stay in one AZ."; - if (p === 'gcp' || p === 'azure') - return "ℹ " + p.toUpperCase() + " subnets are regional (not per-AZ): this spreads Nodes across subnets, but AZ placement is independent — limited AZ-HA benefit."; - if (p === 'aws' || p === 'alibaba' || p === 'tencent') - return "✅ " + p.toUpperCase() + " subnets are per-AZ: distribution spreads Nodes across AZs (higher availability). Note: cross-AZ traffic may incur extra cost/latency."; - return "ℹ Effect depends on this CSP's subnet/zone model; best-effort across zones where the spec is available."; -} - -function buildSpecConfigPopupHtml(spec, nodeConf, options = {}) { - const isEdit = options.isEdit || false; - const imageSelectHTML = options.imageSelectHTML || `${nodeConf.imageId || 'N/A'}`; - const costPerHour = spec.costPerHour || 'N/A'; - const hasGpu = spec.acceleratorType === "gpu" || spec.acceleratorModel; - - let html = POPUP_STYLES + ''; - return html; -} - -// NodeGroup Management Functions -function updateNodeGroupReview() { - const reviewCard = document.getElementById('infra-review-card'); - const nodegroupList = document.getElementById('nodegroup-list'); - const noNodeGroups = document.getElementById('no-nodegroups'); - - // Clear existing items - nodegroupList.innerHTML = ''; - - if (nodeGroupRequestFromSpecList.length === 0) { - reviewCard.style.display = 'none'; - return; - } - - // Show review card - reviewCard.style.display = 'block'; - noNodeGroups.style.display = 'none'; - - // Add each NodeGroup item - nodeGroupRequestFromSpecList.forEach((nodeConf, index) => { - const spec = recommendedSpecList[index]; - const nodegroupItem = createNodeGroupItem(nodeConf, spec, index); - nodegroupList.appendChild(nodegroupItem); - }); - - // Add action buttons at the bottom of the NodeGroup list - const actionButtonsContainer = document.createElement('div'); - actionButtonsContainer.className = 'mt-3 pt-3 border-top'; - - // Check if NodeGroups exist for K8s operations (now supports multi-cluster) - const hasNodeGroups = nodeGroupRequestFromSpecList.length >= 1; - const hasOneNodeGroup = nodeGroupRequestFromSpecList.length === 1; - - // Get current workload type - const workloadType = getCurrentWorkloadType(); - console.log('Current workload type:', workloadType); - console.log('Node radio:', document.getElementById('nodeMode')); - console.log('K8s radio:', document.getElementById('k8sMode')); - console.log('Node checked:', document.getElementById('nodeMode')?.checked); - console.log('K8s checked:', document.getElementById('k8sMode')?.checked); - - // Generate buttons based on workload type - let buttonsHtml = '
    '; - - if (workloadType === 'node') { - console.log('Generating Node buttons...'); - // Node workload buttons - buttonsHtml += ` - -
    - - -
    - `; - } else if (workloadType === 'k8s') { - console.log('Generating K8s buttons...'); - // K8s workload buttons - supports both single and multi-cluster creation - buttonsHtml += ` -
    - Kubernetes Cluster - -
    - - -
    -
    - `; - } else { - console.log('Generating default Node buttons (fallback)...'); - // Default fallback to Node buttons - buttonsHtml += ` - -
    - - -
    - `; - } - - if (nodeGroupRequestFromSpecList.length >= 2) { - buttonsHtml += ` - `; - } - buttonsHtml += '
    '; - actionButtonsContainer.innerHTML = buttonsHtml; - nodegroupList.appendChild(actionButtonsContainer); - - // Auto-scroll to bottom when new items are added (with safety checks) - setTimeout(() => { - try { - const scrollableColumn = document.querySelector('.scrollable-column'); - if (scrollableColumn && scrollableColumn.scrollHeight > scrollableColumn.clientHeight) { - scrollableColumn.scrollTo({ - top: scrollableColumn.scrollHeight, - behavior: 'smooth' - }); - } - } catch (error) { - console.log('Auto-scroll failed:', error); - } - }, 100); -} - -function createNodeGroupItem(nodeConf, spec, index) { - const item = document.createElement('div'); - item.className = 'list-group-item p-2 mb-2 border rounded'; - item.style.backgroundColor = '#f8f9fa'; - - const providerColor = getProviderColor(spec?.providerName); - // Lightening factors for hierarchical badge colors (provider > region > zone) - const REGION_LIGHTEN_FACTOR = 0.4; // 40% lighter for region - const ZONE_LIGHTEN_FACTOR = 0.6; // 60% lighter for zone - const regionColor = lightenColor(providerColor, REGION_LIGHTEN_FACTOR); - const zoneColor = lightenColor(providerColor, ZONE_LIGHTEN_FACTOR); - // Use contrast calculation for text colors to ensure accessibility - const providerTextColor = getContrastTextColor(providerColor); - const regionTextColor = getContrastTextColor(regionColor); - const zoneTextColor = getContrastTextColor(zoneColor); - - // Build zone badge HTML if zone is specified - const zoneBadge = nodeConf.zone - ? `${nodeConf.zone}` - : ''; - - item.innerHTML = ` -
    -
    -
    - 💻 ${nodeConf.name || `NodeGroup-${index + 1}`} ⨉ ${nodeConf.nodeGroupSize} - - ${cspIconMode === 'datacenter' ? 'DC' : cspIconMode === 'cloud' ? 'Cloud' : (spec?.providerName || 'Unknown').toUpperCase()} - - - ${spec?.regionName || 'Unknown Region'} - - ${zoneBadge} -
    -
    -
    Spec: ${spec?.cspSpecName || nodeConf.specId}
    -
    Image: ${nodeConf.imageId}
    -
    vCPU: ${spec?.vCPU || 'N/A'} | Memory: ${spec?.memoryGiB || 'N/A'}GB | Cost: $${spec?.costPerHour || 'N/A'}/h
    - ${(spec?.acceleratorModel && spec.acceleratorModel !== '' && spec.acceleratorModel !== 'N/A') ? `
    ⚡GPU: ${spec.acceleratorModel} (${spec.acceleratorCount || 'N/A'}, ${spec.acceleratorMemoryGB || 'N/A'}GB)
    ` : ''} -
    -
    -
    - - - -
    -
    - `; - - return item; -} - -function getProviderColor(provider) { - const definedColors = { - 'aws': '#FF9900', - 'azure': '#0078D4', - 'gcp': '#4285F4', - 'alibaba': '#FF6A00', - 'ibm': '#1261FE', - 'tencent': '#006EFF', - 'ncp': '#03C75A', - 'kt': '#E31837', - 'nhn': '#FF6B35', - 'openstack': '#ED1944' - }; - - if (!provider) return '#6c757d'; - - const providerKey = provider.toLowerCase(); - - if (definedColors[providerKey]) { - return definedColors[providerKey]; - } - - // Platform-based fallback: e.g., "openstack-new01" → "openstack" color - const platform = resolveCloudPlatform(providerKey); - if (platform !== providerKey && definedColors[platform]) { - return definedColors[platform]; - } - - return generateProviderColor(provider); -} - -// Generate a consistent color for unknown providers based on provider name -function generateProviderColor(provider) { - if (!provider) return '#6c757d'; - - // Simple hash function to generate consistent colors - let hash = 0; - const str = provider.toLowerCase(); - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - - // Generate a color from the hash - const hue = Math.abs(hash) % 360; - const saturation = 60 + (Math.abs(hash >> 8) % 40); // 60-100% - const lightness = 40 + (Math.abs(hash >> 16) % 20); // 40-60% - - return `hsl(${hue}, ${saturation}%, ${lightness}%)`; -} - -/** - * Lighten a color by mixing with white. - * Supports HSL, HEX, and RGB color formats. - * @param {string} color - Color string in HSL, HEX, or RGB format - * @param {number} amount - Lightening amount (0-1, where 1 is fully white) - * @returns {string} Lightened color in HSL or RGB format - */ -function lightenColor(color, amount) { - // HSL lightness thresholds - const MAX_LIGHTNESS = 95; // Maximum lightness to prevent pure white - const LIGHTNESS_MULTIPLIER = 40; // How much lightness increases per amount unit - - // Handle HSL colors - if (color.startsWith('hsl')) { - const match = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/); - if (match) { - const h = parseInt(match[1]); - const s = parseInt(match[2]); - const l = Math.min(MAX_LIGHTNESS, parseInt(match[3]) + (amount * LIGHTNESS_MULTIPLIER)); - return `hsl(${h}, ${s}%, ${l}%)`; - } - } - - // Handle HEX colors - let hex = color.replace('#', ''); - if (hex.length === 3) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - - // Mix with white - const newR = Math.round(r + (255 - r) * amount); - const newG = Math.round(g + (255 - g) * amount); - const newB = Math.round(b + (255 - b) * amount); - - return `rgb(${newR}, ${newG}, ${newB})`; -} - -/** - * Get contrasting text color (dark or light) based on background color. - * Uses luminance calculation for accessibility compliance. - * @param {string} color - Background color in HSL, HEX, or RGB format - * @returns {string} Contrasting text color ('#333' for dark text, 'white' for light text) - */ -function getContrastTextColor(color) { - // Thresholds for determining text color contrast - // Based on WCAG accessibility guidelines for readable text - const HSL_LIGHTNESS_THRESHOLD = 60; // HSL lightness above this gets dark text - const LUMINANCE_THRESHOLD = 0.6; // Relative luminance above this gets dark text - - let r, g, b; - - if (color.startsWith('hsl')) { - // For HSL, check lightness directly - const match = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/); - if (match) { - const l = parseInt(match[3]); - return l > HSL_LIGHTNESS_THRESHOLD ? '#333' : 'white'; - } - } else if (color.startsWith('rgb')) { - const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (match) { - r = parseInt(match[1]); - g = parseInt(match[2]); - b = parseInt(match[3]); - } - } else { - // HEX color - let hex = color.replace('#', ''); - if (hex.length === 3) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - r = parseInt(hex.substring(0, 2), 16); - g = parseInt(hex.substring(2, 4), 16); - b = parseInt(hex.substring(4, 6), 16); - } - - // Calculate relative luminance using ITU-R BT.601 luma coefficients - // Formula: Y = 0.299*R + 0.587*G + 0.114*B (normalized to 0-1) - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance > LUMINANCE_THRESHOLD ? '#333' : 'white'; -} - -// ==== Bulk edit: set one key/path across ALL NodeGroups at once ========================= -// Flatten a NodeGroup config to scalar leaf paths (dot notation), so a key that appears at -// different nesting levels stays distinguishable by its full path (e.g. "rootDiskType" vs -// "label.rootDiskType"). Arrays are skipped — not a single scalar to bulk-set. -function bulkFlattenNodeConf(obj, prefix, out) { - out = out || {}; prefix = prefix || ''; - for (const k in obj) { - if (!Object.prototype.hasOwnProperty.call(obj, k)) continue; - const v = obj[k]; - const path = prefix ? prefix + '.' + k : k; - if (v !== null && typeof v === 'object' && !Array.isArray(v)) bulkFlattenNodeConf(v, path, out); - else if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out[path] = v; - } - return out; -} -function bulkSetByPath(obj, path, value) { - const parts = path.split('.'); let o = obj; - for (let i = 0; i < parts.length - 1; i++) { - if (o[parts[i]] == null || typeof o[parts[i]] !== 'object') o[parts[i]] = {}; - o = o[parts[i]]; - } - o[parts[parts.length - 1]] = value; -} -// Analyse every key-path across all NodeGroups: distinct values, uniform?, missing-in-some?, type. -function bulkCollectKeys() { - const groups = nodeGroupRequestFromSpecList || []; - const flats = groups.map(g => bulkFlattenNodeConf(g)); - const paths = new Set(); flats.forEach(f => Object.keys(f).forEach(p => paths.add(p))); - const info = {}; - paths.forEach(p => { - const present = flats.filter(f => p in f).map(f => f[p]); - const distinct = Array.from(new Set(present.map(v => JSON.stringify(v)))).map(s => JSON.parse(s)); - info[p] = { - uniform: distinct.length <= 1 && present.length === groups.length, // same value & set on every group - partial: present.length !== groups.length, // absent on some groups - distinct: distinct, - type: typeof present.find(v => v !== null && v !== undefined), - }; - }); - return { groups, flats, info, paths: Array.from(paths).sort() }; -} -// Render a single value for display: empty string / null / undefined show as a muted "(empty)". -function bulkFmtVal(v) { - if (v === '' || v === null || v === undefined) - return '(empty)'; - return '' + escapeHtml(String(v)) + ''; -} - -// Step 1: list every key as a color-coded button (green = same across all, orange = differs/partial). -window.bulkEditNodeGroups = function () { - const { groups, info, paths } = bulkCollectKeys(); - if (!groups.length) { if (typeof infoAlert === 'function') infoAlert('No NodeGroups configured yet.'); return; } - const rows = paths.map(p => { - const d = info[p]; - let color, disp; - if (d.uniform) { - color = '#28a745'; // green: identical everywhere - disp = bulkFmtVal(d.distinct[0]); - } else { - color = '#fd7e14'; // orange: mixed or missing on some - const vals = d.distinct.slice(0, 3).map(v => bulkFmtVal(v)).join(', ') + (d.distinct.length > 3 ? ', …' : ''); - disp = '' + vals + (d.partial ? ' (missing on some)' : '') + ''; - } - return ``; - }).join(''); - Swal.fire({ - title: `✏️ Bulk Edit — ${groups.length} NodeGroups`, - width: 660, - html: `
    - same across all   - differs / missing on some -  — click a key to set it on all NodeGroups.
    -
    ${rows}
    `, - showConfirmButton: false, - showCloseButton: true, - }); -}; - -// Step 2: show current per-group values for the chosen key + one input applied to every group. -window.bulkEditKey = function (path) { - const { groups, flats, info } = bulkCollectKeys(); - const d = info[path]; if (!d) return; - const isBool = d.type === 'boolean'; - const isNum = d.type === 'number'; - const seed = d.distinct.length === 1 ? d.distinct[0] : ''; - const table = groups.map((g, i) => { - const has = path in flats[i]; const v = flats[i][path]; - return `${escapeHtml(g.name || ('group ' + i))} - ${has ? bulkFmtVal(v) : '(none)'}`; - }).join(''); - const inputHtml = isBool - ? `` - : ``; - Swal.fire({ - title: `Set "${path}"`, - width: 560, - html: `
    -
    applied to all ${groups.length} NodeGroups
    -
    ${inputHtml}
    -
    current values per NodeGroup -
    ${table}
    -
    `, - showCancelButton: true, - confirmButtonText: 'Apply to all', - confirmButtonColor: '#28a745', - cancelButtonText: '← Back', - focusConfirm: false, - preConfirm: () => { - const el = document.getElementById('bulkVal'); - if (isBool) return el.checked; - const raw = el.value; - if (isNum) { const n = Number(raw); if (raw.trim() === '' || isNaN(n)) { Swal.showValidationMessage('Enter a number'); return false; } return n; } - return raw; - } - }).then(res => { - if (res.isConfirmed) { - groups.forEach(g => bulkSetByPath(g, path, res.value)); - if (typeof renderMapFromConfig === 'function') renderMapFromConfig(); - updateNodeGroupReview(); - if (typeof successAlert === 'function') successAlert(`Set "${path}" = ${JSON.stringify(res.value)} on all ${groups.length} NodeGroups.`); - window.bulkEditNodeGroups(); // reopen the key list with refreshed colors - } else if (res.dismiss === Swal.DismissReason.cancel) { - window.bulkEditNodeGroups(); // ← Back to the key list - } - }); -}; - -function editNodeGroup(index) { - const nodeConf = nodeGroupRequestFromSpecList[index]; - const spec = recommendedSpecList[index]; - - if (!spec || !nodeConf) { - console.error('Spec or Node config not found for index:', index); - return; - } - - // Set editing mode - window.editingNodeGroupIndex = index; - - // Build zone options (will be populated after dialog opens) - const currentZone = nodeConf.zone || ''; - - // Parse current labels using common helper - const currentLabels = labelsToString(nodeConf.label); - - Swal.fire({ - title: "✏️ Edit NodeGroup Configuration", - width: 650, - html: buildSpecConfigPopupHtml(spec, nodeConf, { - isEdit: true, - currentLabels: currentLabels - }), - didOpen: () => { - // Use common helpers for dropdown population - populateRootDiskTypeSelect('editRootDiskTypeSelect', spec, nodeConf.rootDiskType || 'default', - { sizeInputId: 'editRootDiskSize', hintId: 'editRootDiskSizeHint', imageId: nodeConf.imageId || '' }); - populateZoneSelect('editZoneSelect', 'editZoneLoadingSpinner', spec.id, currentZone, null); - - // Setup label input listener for chip sync - window.setupLabelInputListener('editVmLabels'); - - // Sync initial chip states - window.syncLabelSuggestionChips('editVmLabels'); - }, - showCancelButton: true, - confirmButtonText: '💾 Save Changes', - cancelButtonText: 'Cancel', - confirmButtonColor: '#28a745', - preConfirm: () => { - const name = document.getElementById('editNodeGroupName').value.trim(); - const count = parseInt(document.getElementById('editVmCount').value); - const diskType = document.getElementById('editRootDiskTypeSelect').value; - const diskSize = document.getElementById('editRootDiskSize').value.trim(); - const zone = document.getElementById('editZoneSelect').value; - const distCb = document.getElementById('editDistributeSubnetsCheckbox'); - const distributeSubnets = distCb ? distCb.checked : true; - const labelsText = document.getElementById('editVmLabels').value.trim(); - - if (isNaN(count) || count < 1) { - Swal.showValidationMessage('Please provide valid Node count'); - return false; - } - if (diskSize && !/^\d+$/.test(diskSize)) { - Swal.showValidationMessage('Disk size must be empty (default) or a positive number'); - return false; - } - const sizeErr = validateDiskSizeAgainstRule(parseInt(diskSize, 10) || 0, - getSelectedRootDiskRule(document.getElementById('editRootDiskTypeSelect'))); - if (sizeErr) { - Swal.showValidationMessage(sizeErr); - return false; - } - - // Use common helper for label parsing - const labels = parseLabelsString(labelsText); - - return { name, count, diskType, diskSize: parseInt(diskSize, 10) || 0, zone, labels, distributeSubnets }; - } - }).then((result) => { - window.editingNodeGroupIndex = -1; // Reset editing mode - - if (result.isConfirmed) { - // Update the Node configuration - nodeGroupRequestFromSpecList[index].name = result.value.name; - nodeGroupRequestFromSpecList[index].nodeGroupSize = result.value.count; - nodeGroupRequestFromSpecList[index].rootDiskType = result.value.diskType; - nodeGroupRequestFromSpecList[index].rootDiskSize = result.value.diskSize; - nodeGroupRequestFromSpecList[index].distributeSubnets = result.value.distributeSubnets; - - if (result.value.zone) { - nodeGroupRequestFromSpecList[index].zone = result.value.zone; - } else { - delete nodeGroupRequestFromSpecList[index].zone; - } - - if (Object.keys(result.value.labels).length > 0) { - nodeGroupRequestFromSpecList[index].label = result.value.labels; - - // Add used labels to recently used list - Object.entries(result.value.labels).forEach(([key, value]) => { - window.addToRecentLabels(`${key}=${value}`); - }); - } else { - delete nodeGroupRequestFromSpecList[index].label; - } - - updateNodeGroupReview(); - successAlert('NodeGroup updated successfully!'); - } - }); -} - -// ─── Find Alternative NodeGroup ── - -async function findAlternativeNodeConfig(index) { - const hostname = configHostname; - const port = configPort; - const username = configUsername; - const password = configPassword; - const nodeConf = nodeGroupRequestFromSpecList[index]; - const spec = recommendedSpecList[index]; - if (!spec || !nodeConf) return; - - const isGPU = spec.acceleratorType === 'gpu'; - const esc = window.escapeHtml; - - // Helper: build a colour-coded diff badge - function diffBadge(val, unit = '', positiveIsGood = false) { - if (val == null) return ''; - const num = Number(val); - if (!Number.isFinite(num) || num === 0) return ''; - const sign = num > 0 ? '+' : ''; - const colour = positiveIsGood - ? (num > 0 ? '#27ae60' : '#e74c3c') - : (num > 0 ? '#e74c3c' : '#27ae60'); - return `${sign}${esc(String(num))}${esc(unit)}`; - } - - // ── Step 1: Target CSP / Region / Options ─ - const providerOptions = knownPlatforms - .map(p => ``) - .join(''); - - const sourceAccelHtml = isGPU - ? `
    - GPU: ${esc(spec.acceleratorModel || 'N/A')} ×${spec.acceleratorCount || '?'} (${spec.acceleratorMemoryGB || '?'} GB/ea) -
    ` : ''; - - const step1Result = await Swal.fire({ - title: 'Find Alternative Node Config', - width: 700, - html: ` -
    - - -
    -
    Source NodeGroup: ${esc(nodeConf.name || `NodeGroup-${index+1}`)}
    -
    ${esc(spec.id)}
    -
    - ${esc((spec.providerName || '').toUpperCase())} ${esc(spec.regionName || '')} -  |  vCPU: ${spec.vCPU} -  |  Mem: ${spec.memoryGiB} GiB -  |  Arch: ${esc(spec.architecture || 'N/A')} -  |  ${spec.costPerHour > 0 ? '$' + parseFloat(spec.costPerHour).toFixed(5) + '/h' : 'cost N/A'} -
    - ${sourceAccelHtml} -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - Advanced: per-field match policy -
    - ${buildMatchCriteriaRow('architecture', 'Architecture', 'required', false)} - ${buildMatchCriteriaRow('vCPU', 'vCPU', 'preferred', false)} - ${buildMatchCriteriaRow('memoryGiB', 'Memory', 'preferred', false)} - ${buildMatchCriteriaRow('acceleratorType', 'Accel Type', 'required', false)} - ${isGPU ? buildMatchCriteriaRow('acceleratorModel','Accel Model','open', true) : ''} - ${isGPU ? buildMatchCriteriaRow('acceleratorCount','Accel Count','preferred', false) : ''} - ${isGPU ? buildMatchCriteriaRow('acceleratorMemoryGB','Accel Mem GB','preferred', false) : ''} - ${buildMatchCriteriaRow('costPerHour', 'Cost/h', 'open', false)} -
    -
    -
    - `, - didOpen: () => { - const provSel = document.getElementById('eq-target-provider'); - const regSel = document.getElementById('eq-target-region'); - provSel.addEventListener('change', async () => { - const prov = provSel.value; - regSel.innerHTML = ''; - regSel.disabled = !prov; - if (!prov) return; - try { - const res = await axios.get( - `${tbApiBase()}/provider/${prov}/region`, - { auth: { username, password } }); - const regions = (res.data?.regions || []) - .filter(r => r.regionName) - .sort((a, b) => a.regionName.localeCompare(b.regionName)); - regions.forEach(r => { - const opt = document.createElement('option'); - opt.value = r.regionName; - const display = r.location?.display; - opt.textContent = display ? `${r.regionName} (${display})` : r.regionName; - regSel.appendChild(opt); - }); - } catch (e) { - // Region list unavailable; keep "All regions" only. - } - }); - }, - showCancelButton: true, - confirmButtonText: 'Search →', - confirmButtonColor: '#1565c0', - preConfirm: () => { - const provider = document.getElementById('eq-target-provider').value; - if (!provider) { - Swal.showValidationMessage('Please select a target CSP'); - return false; - } - return { - provider, - region: document.getElementById('eq-target-region').value, - tolerance: (() => { const v = parseInt(document.getElementById('eq-tolerance').value, 10); return Number.isNaN(v) ? 20 : v; })(), - limit: (() => { const v = parseInt(document.getElementById('eq-limit').value, 10); return Number.isNaN(v) ? 5 : v; })(), - criteria: readMatchCriteriaFromForm(), - }; - } - }); - - if (!step1Result.isConfirmed) return; - const { provider, region, tolerance, limit, criteria } = step1Result.value; - - // ── Call API ── - Swal.fire({ title: 'Searching…', didOpen: () => Swal.showLoading(), allowOutsideClick: false }); - - let apiResp; - try { - const reqBody = { - sourceSpecId: spec.id, - sourceImageId: nodeConf.imageId || '', - targetProviderName: provider, - targetRegionName: region || '', - tolerancePercent: tolerance, - specCandidateLimit: limit, - imageAlternativeLimit: 3, - matchCriteria: criteria, - }; - const res = await axios.post( - `${tbApiBase()}/recommendAlternativeNodeConfig`, - reqBody, - { auth: { username, password } }); - apiResp = res.data; - } catch (e) { - Swal.fire('Error', e?.response?.data?.message || e.message, 'error'); - return; - } - - if (!apiResp?.candidates?.length) { - Swal.fire('No Results', - 'No alternative specs found in the target CSP/region with the given criteria. ' - + 'Try relaxing the tolerance or match policies.', 'info'); - return; - } - - // ── Step 2: Show candidates - const src = apiResp.sourceSpec; - - const candidateRows = apiResp.candidates.map((c, i) => { - const s = c.spec; - const d = c.specDiff; - const imgName = c.primaryImage - ? `${esc((c.primaryImage.cspImageName || '').substring(0, 30))}${(c.primaryImage.cspImageName||'').length > 30 ? '…' : ''}` - : 'none'; - const gpuInfo = s.acceleratorModel - ? `
    ${esc(s.acceleratorModel)} ×${s.acceleratorCount}
    ` : ''; - const scoreColour = c.similarityScore >= 80 ? '#27ae60' : c.similarityScore >= 50 ? '#f39c12' : '#e74c3c'; - const archWarn = d.architectureMatch === false - ? '
    ⚠ arch
    ' : ''; - - return ` - - ○ - -
    ${esc(s.cspSpecName || s.id)}
    -
    ${esc((s.providerName || '').toUpperCase())} ${esc(s.regionName || '')}
    - - ${esc(String(s.vCPU ?? ''))}
    ${diffBadge(d.vCPUDiff)} - ${esc(String(s.memoryGiB ?? ''))}G
    ${diffBadge(d.memoryGiBDiff, 'G')} - - ${gpuInfo} - ${archWarn} - - - ${s.costPerHour > 0 ? '$' + parseFloat(s.costPerHour).toFixed(4) : 'N/A'}
    - ${s.costPerHour > 0 && src.costPerHour > 0 ? diffBadge(parseFloat(d.costPerHourDiff.toFixed(4)), '', false) : ''} - - ${imgName} - - ${c.similarityScore.toFixed(1)}% - - `; - }).join(''); - - const step2Result = await Swal.fire({ - title: `Alternative Configs in ${esc(provider.toUpperCase())}${region ? ' / ' + esc(region) : ''}`, - width: 1100, - html: ` - -
    - -
    - ▶ Source - ${esc(src.cspSpecName || src.id)} - ${esc((src.providerName || '').toUpperCase())} ${esc(src.regionName || '')} - vCPU: ${src.vCPU}   Mem: ${src.memoryGiB}G - ${src.acceleratorModel ? `GPU: ${esc(src.acceleratorModel)} ×${src.acceleratorCount}` : ''} - ${src.costPerHour > 0 ? '$' + parseFloat(src.costPerHour).toFixed(5) + '/h' : 'cost N/A'} -
    - - - - - - - - - - - - - - - - ${candidateRows} - -
    Spec / LocationvCPUMemGPU / ArchCost/hPrimary ImageMatch %
    -
    - Click a row to select  |  Green diff = improvement over source, red = regression -
    - -
    - `, - didOpen: () => { - const tbody = document.getElementById('eq-cand-tbody'); - const idxInput = document.getElementById('eq-selected-idx'); - - function selectRow(tr) { - tbody.querySelectorAll('tr.eq-cand-row').forEach(r => { - r.classList.remove('eq-selected'); - const ind = r.querySelector('.eq-sel-indicator'); - if (ind) ind.textContent = '○'; - }); - tr.classList.add('eq-selected'); - const ind = tr.querySelector('.eq-sel-indicator'); - if (ind) ind.textContent = '◉'; - idxInput.value = tr.dataset.index; - } - - tbody.querySelectorAll('tr.eq-cand-row').forEach(tr => { - tr.addEventListener('click', () => selectRow(tr)); - }); - - // Auto-select first row - const firstRow = tbody.querySelector('tr.eq-cand-row'); - if (firstRow) selectRow(firstRow); - }, - showCancelButton: true, - showDenyButton: true, - confirmButtonText: '➕ Add as New NodeGroup', - denyButtonText: '🔄 Replace This NodeGroup', - cancelButtonText: 'Cancel', - confirmButtonColor: '#28a745', - denyButtonColor: '#1565c0', - preConfirm: () => parseInt(document.getElementById('eq-selected-idx').value), - preDeny: () => { - Swal.resetValidationMessage(); - return parseInt(document.getElementById('eq-selected-idx').value); - } - }); - - if (!step2Result.isConfirmed && !step2Result.isDenied) return; - - const selectedCandidateIdx = step2Result.value; - const candidate = apiResp.candidates[selectedCandidateIdx]; - const isReplace = step2Result.isDenied; - - // ── Step 3: Image selection (reuse existing image-selection flow) ── - // Build image list from candidate's primary + alternatives - const candImages = [ - ...(candidate.primaryImage ? [candidate.primaryImage] : []), - ...(candidate.alternativeImages || []), - ].map(img => ({ - id: img.id || img.cspImageName, - cspImageName: img.cspImageName || img.id, - osType: img.osType || 'N/A', - osDistribution: img.osDistribution || '', - osArchitecture: img.osArchitecture || img.osArch || 'N/A', - creationDate: img.creationDate || '', - description: img.description || img.osDistribution || '', - imageStatus: img.imageStatus || 'Available', - osPlatform: img.osPlatform || '', - osDiskType: img.osDiskType || '', - osDiskSizeGB: img.osDiskSizeGB || '', - providerName: img.providerName || '', - connectionName: img.connectionName || '', - infraType: img.infraType || '', - isGPUImage: img.isGPUImage || false, - isKubernetesImage: img.isKubernetesImage || false, - isBasicImage: img.isBasicImage || false, - isBasicGpuImage: img.isBasicGpuImage || false, - isCustomImage: false, - details: img.details || [], - })); - - if (!candImages.length) { - errorAlert('No images available for the selected spec.'); - return; - } - - const candSpec = candidate.spec; - const candIsGPU = (candSpec.acceleratorType || '').toLowerCase() === 'gpu'; - - // Sort: basic GPU first if GPU spec, else basic OS first - if (candIsGPU) { - const gpuScore = img => img.isBasicGpuImage ? 3 : img.isBasicImage ? 2 : img.isGPUImage ? 1 : 0; - candImages.sort((a, b) => gpuScore(b) - gpuScore(a)); - } - - const escL = window.escapeHtml; - const candSpecCost = candSpec.costPerHour > 0 - ? `$${parseFloat(candSpec.costPerHour).toFixed(5)}/h` : 'N/A'; - const candAccel = (candSpec.acceleratorType === 'gpu' && candSpec.acceleratorModel) - ? ` | GPU: ${escL(candSpec.acceleratorModel)} ×${escL(String(candSpec.acceleratorCount||'?'))} (${escL(String(candSpec.acceleratorMemoryGB||'?'))}GB/ea)` : ''; - - const truncate = (t, n) => (!t || t.length <= n) ? (t || '') : t.substring(0, n) + '…'; - - const imgRows = candImages.map((image, idx) => { - const isRecGpu = candIsGPU && image.isBasicGpuImage; - const rowBg = isRecGpu ? 'rgba(231,76,60,0.07)' : (image.isBasicImage ? 'rgba(40,167,69,0.06)' : ''); - const basicIcon = image.isBasicImage ? ' ⭐' : ''; - const gpuIcon = image.isBasicGpuImage ? ' ⭐🧮' : (image.isGPUImage ? ' 🧮' : ''); - const k8sIcon = image.isKubernetesImage ? ' ☸️' : ''; - return ` - - ○ - ${escL(image.osType || '')} - ${escL(truncate(image.cspImageName, 55))} - ${escL(truncate(image.osDistribution, 55))} - ${gpuIcon}${k8sIcon}${basicIcon} - ${escL(image.osArchitecture || '')} - `; - }).join(''); - - const step3Result = await Swal.fire({ - title: `Select Image — ${escL(candSpec.cspSpecName || candSpec.id || '')}`, - width: 1100, - html: ` - -
    -
    - Spec - ${escL(candSpec.id||'')} - | ${escL((candSpec.providerName||'').toUpperCase())} ${escL(candSpec.regionName||'')} - | vCPU: ${escL(String(candSpec.vCPU||''))} | Mem: ${escL(String(candSpec.memoryGiB||''))} GiB | Arch: ${escL(candSpec.architecture||'N/A')} - | ${escL(candSpecCost)}${candAccel} -
    - ${candIsGPU ? `
    - ⚡ GPU Spec⭐🧮 Basic GPU images (GPU drivers pre-installed) are listed first. -
    ` : ''} -
    - - - - - - - - - - - - ${imgRows} -
    OS TypeImage NameDistributionSupportArch
    -
    -
    - ⭐ Basic OS   ⭐🧮 Basic GPU   🧮 GPU-enabled   ☸️ Kubernetes -
    - -
    `, - showCancelButton: true, - confirmButtonText: isReplace ? '🔄 Replace NodeGroup (spec + image)' : '➕ Add NodeGroup', - confirmButtonColor: isReplace ? '#1565c0' : '#28a745', - didOpen: () => { - const tbody = document.getElementById('eq-img-tbody'); - const idxInput = document.getElementById('eq-selected-image-idx'); - - function selectImgRow(tr) { - tbody.querySelectorAll('tr.eq-img-row').forEach(r => { - r.classList.remove('eq-img-selected'); - const ind = r.querySelector('.eq-img-sel-indicator'); - if (ind) ind.textContent = '○'; - }); - tr.classList.add('eq-img-selected'); - const ind = tr.querySelector('.eq-img-sel-indicator'); - if (ind) ind.textContent = '◉'; - idxInput.value = tr.dataset.index; - } - - tbody.querySelectorAll('tr.eq-img-row').forEach(tr => { - tr.addEventListener('click', () => selectImgRow(tr)); - }); - - const firstRow = tbody.querySelector('tr.eq-img-row'); - if (firstRow) selectImgRow(firstRow); - }, - preConfirm: () => parseInt(document.getElementById('eq-selected-image-idx').value), - }); - - if (!step3Result.isConfirmed) return; - - const selectedImage = candImages[step3Result.value]; - - // ── Apply result ─── - // Preserve rootDiskType only when the CSP is unchanged; cross-CSP disk type - // names are incompatible so fall back to the candidate spec's default. - const originalProvider = (spec.providerName || '').toLowerCase(); - const candidateProvider = (candSpec.providerName || '').toLowerCase(); - const resolvedDiskType = (originalProvider && originalProvider === candidateProvider) - ? (nodeConf.rootDiskType || 'default') - : (candSpec.rootDiskType || 'default'); - - const newNodeConf = { - name: isReplace - ? nodeConf.name - : 'g' + (nodeGroupRequestFromSpecList.length + 1), - specId: candSpec.id, - imageId: selectedImage.cspImageName || selectedImage.id, - rootDiskType: resolvedDiskType, - rootDiskSize: nodeConf.rootDiskSize || 0, - nodeGroupSize: nodeConf.nodeGroupSize, - }; - // Labels are CSP-agnostic — carry them over unconditionally. - if (nodeConf.label && Object.keys(nodeConf.label).length > 0) { - newNodeConf.label = { ...nodeConf.label }; - } - - if (isReplace) { - nodeGroupRequestFromSpecList[index] = newNodeConf; - recommendedSpecList[index] = candSpec; - successAlert(`NodeGroup "${newNodeConf.name}" replaced with alternative config in ${provider.toUpperCase()}.`); - } else { - nodeGroupRequestFromSpecList.push(newNodeConf); - recommendedSpecList.push(candSpec); - successAlert(`Added alternative NodeGroup "${newNodeConf.name}" for ${provider.toUpperCase()}.`); - } - - renderMapFromConfig(); - updateNodeGroupReview(); -} - -// buildMatchCriteriaRow generates a policy selector row for the advanced section -function buildMatchCriteriaRow(field, label, defaultPolicy, modelOnlyWarning) { - const opts = ['required','preferred','open'].map(p => - `` - ).join(''); - return ` -
    - - -
    `; -} - -// readMatchCriteriaFromForm collects the per-field policy values from the Step 1 form -function readMatchCriteriaFromForm() { - const fields = ['architecture','vCPU','memoryGiB','acceleratorType', - 'acceleratorModel','acceleratorCount','acceleratorMemoryGB','costPerHour']; - const result = {}; - fields.forEach(f => { - const el = document.getElementById(`eq-criteria-${f}`); - if (el && el.value) result[f] = el.value; - }); - return result; -} - -// Removes a single NodeGroup from the configuration immediately (no confirm: -// it only edits the local, not-yet-provisioned configuration). -function removeNodeGroup(index) { - // Guard against a stale UI index: splicing would be a no-op but the - // counter decrement below would still desynchronize the configuration. - if (!Number.isInteger(index) || index < 0 || index >= nodeGroupRequestFromSpecList.length) { - return; - } - - nodeGroupRequestFromSpecList.splice(index, 1); - recommendedSpecList.splice(index, 1); - - // Decrease the index counter - if (latLonInputPairIdx > 0) { - latLonInputPairIdx--; - } - - renderMapFromConfig(); - updateNodeGroupReview(); -} - -// Make functions available globally -window.updateNodeGroupReview = updateNodeGroupReview; -window.editNodeGroup = editNodeGroup; -window.removeNodeGroup = removeNodeGroup; -window.findAlternativeNodeConfig = findAlternativeNodeConfig; - -function range_change(obj) { - document.getElementById("myvalue").value = obj.value; -} -window.range_change = range_change; - -(function () { - const parentS = document.querySelectorAll(".range-slider"); - - if (!parentS) { - return; - } - - parentS.forEach((parent) => { - const rangeS = parent.querySelectorAll('input[type="range"]'), - numberS = parent.querySelectorAll('input[type="number"]'); - - rangeS.forEach((el) => { - el.oninput = () => { - let slide1 = parseFloat(rangeS[0].value), - slide2 = parseFloat(rangeS[1].value); - - if (slide1 > slide2) { - [slide1, slide2] = [slide2, slide1]; - } - - numberS[0].value = slide1; - numberS[1].value = slide2; - }; - }); - - numberS.forEach((el) => { - el.oninput = () => { - let number1 = parseFloat(numberS[0].value), - number2 = parseFloat(numberS[1].value); - - if (number1 > number2) { - let tmp = number1; - numberS[0].value = number2; - numberS[1].value = tmp; - } - - rangeS[0].value = number1; - rangeS[1].value = number2; - }; - }); - }); -})(); - -function renderMapFromConfig() { - cspPointsCircle = recommendedSpecList - .map(s => [parseFloat(s?.regionLongitude), parseFloat(s?.regionLatitude)]) - .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat)); - geoCspPointsCircle = cspPointsCircle.length - ? [new MultiPoint(cspPointsCircle)] - : []; - map.render(); -} - -function controlInfra(action) { - switch (action) { - case "refine": - case "suspend": - case "resume": - case "reboot": - case "terminate": - case "continue": - case "withdraw": - case "reconcile": - case "abort": - break; - default: - console.log( - `The action ${action} is not supported. Supported actions: refine, continue, withdraw, reconcile, abort, suspend, resume, reboot, terminate.` - ); - return; - } - //console.log("[Infra " +action +"]"); - - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = getSelectedInfraId(); - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - if (!infraid) { - errorAlert("Please select an Infra first"); - return; - } - - var spinnerId = addSpinnerTask(action + ": " + infraid); - infoAlert(action + ": " + infraid); - - var url = `${tbApiBase()}/ns/${namespace}/control/infra/${infraid}?action=${action}`; - - console.log("Infra control:[" + action + "]"); - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - if (res.data != null) { - console.log(res.data); - displayJsonData(res.data, typeInfo); - switch (action) { - case "refine": - case "suspend": - case "resume": - case "reboot": - case "terminate": - case "continue": - case "withdraw": - case "reconcile": - case "abort": - infoAlert( - JSON.stringify(res.data.message, null, 2).replace(/['",]+/g, "") - ); - break; - default: - console.log( - `The action ${action} is not supported. Supported actions: refine, continue, withdraw, reconcile, abort, suspend, resume, reboot, terminate.` - ); - } - } - }) - .catch(function (error) { - if (error.response) { - // status code is not 2xx - console.log(error.response.data); - console.log(error.response.status); - console.log(error.response.headers); - } else { - console.log("Error", error.message); - } - console.log(error.config); - errorAlert( - JSON.stringify(error.response.data, null, 2).replace(/['",]+/g, "") - ); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} -window.controlInfra = controlInfra; - -function hideInfra() { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - var url = `${tbApiBase()}/ns/${namespace}/infra?option=id`; - - var hideListString = ""; - for (i = 0; i < infraHideList.length; i++) { - var html = "
    [" + i + "]" + ": " + infraHideList[i] + " (hidden)"; - - hideListString = hideListString + html; - } - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - if (res.data.output != null) { - infraList = res.data.output; - - Swal.fire({ - title: "Hide/Show a Infra from the Map", - html: "" + hideListString, - showCancelButton: true, - confirmButtonText: "Show", - showDenyButton: true, - denyButtonText: "Hide", - }).then((result) => { - hideListString = ""; - - if (result.isConfirmed) { - if (infraHideList.length != 0) { - Swal.fire({ - title: "Show a Infra from the Map", - html: "" + hideListString, - input: "select", - inputOptions: infraHideList, - inputPlaceholder: "Select from dropdown", - inputAttributes: { - autocapitalize: "off", - }, - showCancelButton: true, - confirmButtonText: "Show", - }).then((result) => { - if (result.isConfirmed) { - infraHideList = infraHideList.filter( - (a) => a !== infraHideList[result.value] - ); - - for (i = 0; i < infraHideList.length; i++) { - var html = - "
    [" + - i + - "]" + - ": " + - infraHideList[i] + - " (hidden)"; - hideListString = hideListString + html; - } - infoAlert( - "Show: " + - infraHideList[result.value] + - "
    " + - hideListString - ); - } - }); - } else { - infoAlert("There is no hidden Infra yet"); - } - } else if (result.isDenied) { - if (infraList.length != 0) { - Swal.fire({ - title: "Hide a Infra from the Map", - html: "" + hideListString, - input: "select", - inputOptions: infraList.filter( - (val) => !infraHideList.includes(val) - ), - inputPlaceholder: "Select from dropdown", - inputAttributes: { - autocapitalize: "off", - }, - showCancelButton: true, - confirmButtonText: "Hide", - }).then((result) => { - if (result.isConfirmed) { - infraHideList.push(infraList[result.value]); - // remove duplicated items - infraHideList = [...new Set(infraHideList)]; - - for (i = 0; i < infraHideList.length; i++) { - var html = - "
    [" + - i + - "]" + - ": " + - infraHideList[i] + - " (hidden)"; - hideListString = hideListString + html; - } - infoAlert( - "Hide: " + infraList[result.value] + "
    " + hideListString - ); - } - }); - } else { - infoAlert("There is no Infra yet"); - } - } - }); - } - }); -} -window.hideInfra = hideInfra; - -function statusInfra() { - console.log("[Get Infra status]"); - - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = getSelectedInfraId(); - - // Validate required parameters - if (!namespace || namespace === "") { - errorAlert("Namespace is not selected. Please select a namespace first or switch to Control tab and back."); - return; - } - if (!infraid || infraid === "") { - errorAlert("Infra ID is not selected. Please select an Infra first."); - return; - } - - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000, - }) - .then((res) => { - console.log("[Status Infra]"); - displayInfraStatusGui(res.data); - }) - .catch(function (error) { - if (error.response) { - // status code is not 2xx - console.log(error.response.data); - console.log(error.response.status); - console.log(error.response.headers); - // Provide more detailed error message - const errorData = error.response.data; - const status = error.response.status; - let errorMsg = ""; - if (status === 404) { - errorMsg = `Infra '${infraid}' not found in namespace '${namespace}'.\n\nThis may happen if:\n- The Infra was deleted\n- The namespace is incorrect\n- The Infra creation failed completely`; - } else { - errorMsg = JSON.stringify(errorData, null, 2).replace(/['",]+/g, ""); - } - errorAlert(errorMsg); - } else { - console.log("Error", error.message); - errorAlert("Network error: " + error.message); - } - console.log(error.config); - }); -} -window.statusInfra = statusInfra; - -function deleteInfra() { - console.log("Deleting Infra"); - - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = getSelectedInfraId(); - - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}?option=terminate`; - - var spinnerId = addSpinnerTask("Deleting Infra: " + infraid); - infoAlert("Delete: " + infraid + " (option=terminate)"); - - axios({ - method: "delete", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - console.log(res); - displayJsonData(res.data, typeInfo); - // Targeted removal: only remove the deleted Infra from the render map - infraRenderMap.delete(infraid); - map.render(); - updateInfraList(); - }) - .catch(function (error) { - console.log(error); - errorAlert("Failed to delete Infra: " + infraid); - if (error.response && error.response.data) { - displayJsonData(error.response.data, typeError); - } - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} -window.deleteInfra = deleteInfra; - -function releaseResources() { - var spinnerId = addSpinnerTask("Removing associated default resources"); - infoAlert("Removing all associated default resources"); - - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - var url = `${tbApiBase()}/ns/${namespace}/sharedResources`; - - axios({ - method: "delete", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - updateNsList(); - - console.log(res); // for debug - - var data = res.data; - var total = (data && data.total) || 0; - var successCount = (data && data.successCount) || 0; - var failedCount = (data && data.failedCount) || 0; - var icon = failedCount > 0 ? "warning" : "success"; - var title = failedCount > 0 - ? "Release Resources Completed with Failures" - : "Release Resources Completed"; - var summary = "Total: " + total + ", Success: " + successCount + ", Failed: " + failedCount; - - Swal.fire({ - icon: icon, - title: title, - html: - "" + summary + "

    " + - "To retry releasing resources, click 🔄 Retry.
    " + - "If orphaned dependencies are blocking deletion, click 🔧 Recover Dependencies.", - showDenyButton: true, - showCancelButton: true, - confirmButtonText: "🔄 Retry Release Resources", - denyButtonText: "🔧 Recover Dependencies", - cancelButtonText: "OK", - confirmButtonColor: "#e67e22", - denyButtonColor: "#1565c0", - cancelButtonColor: "#6c757d", - }).then(function (result) { - displayJsonData(data, typeInfo); - if (result.isConfirmed) { - releaseResources(); - } else if (result.isDenied) { - recoverSharedResourceDependencies(); - } - }); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} -window.releaseResources = releaseResources; - -function resourceOverview() { - var spinnerId = addSpinnerTask("Inspect all resources and overview"); - infoAlert("Inspect all resources and overview"); - - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - - var url = `${tbApiBase()}/inspectResourcesOverview`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - console.log(res); // for debug - displayJsonData(res.data, typeInfo); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} -window.resourceOverview = resourceOverview; - -// function for registerCspResource by registerCspResource button item -function recoverSharedResourceDependencies() { - var spinnerId = addSpinnerTask("Recovering orphaned dependency resources"); - infoAlert("Scanning and registering orphaned CSP resources..."); - - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - var url = `${tbApiBase()}/ns/${namespace}/sharedResources/recoverDependencies`; - - axios({ - method: "post", - url: url, - headers: { "Content-Type": "application/json" }, - data: JSON.stringify({}), - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then(function (res) { - console.log(res); // for debug - - var data = res.data; - var ov = (data && data.registerationOverview) || {}; - var totalRegistered = - (ov.vNet || 0) + (ov.securityGroup || 0) + (ov.sshKey || 0) + (ov.node || 0); - - if (totalRegistered > 0) { - Swal.fire({ - icon: "success", - title: "Dependency Recovery Complete", - html: - "" + totalRegistered + " orphaned resource(s) registered
    " + - "Node: " + (ov.node || 0) + - ", SSHKey: " + (ov.sshKey || 0) + - ", SecurityGroup: " + (ov.securityGroup || 0) + - ", vNet: " + (ov.vNet || 0) + - "

    " + - "The recovered resources appear as dep-* Infra(s) in the Infra list.
    " + - "Please terminate and delete them, then retry Release Resources.", - confirmButtonText: "OK", - }); - } else { - Swal.fire({ - icon: "info", - title: "No Orphaned Resources Found", - text: "No CSP resources outside CB-Tumblebug were detected. The dependency may be caused by a non-VM resource (e.g., ENI, Lambda, RDS) that requires manual cleanup on the CSP console.", - confirmButtonText: "OK", - }); - } - - displayJsonData(data, typeInfo); - }) - .catch(function (err) { - console.error(err); - var msg = err.response ? JSON.stringify(err.response.data) : err.message; - errorAlert("Dependency recovery failed: " + msg); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} -window.recoverSharedResourceDependencies = recoverSharedResourceDependencies; - -function registerCspResource() { - var spinnerId = addSpinnerTask("Registering all CSP's resources"); - infoAlert("Registering all CSP's resources"); - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - var url = `${tbApiBase()}/registerCspResourcesAll?infraFlag=n`; - - var commandReqTmp = { - infraName: "csp", - nsId: `${namespace}`, - }; - var jsonBody = JSON.stringify(commandReqTmp, undefined, 4); - - axios({ - method: "post", - url: url, - headers: { "Content-Type": "application/json" }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - console.log(res); // for debug - - console.log("[Complete: Registering all CSP's resources]\n"); - displayJsonData(res.data, typeInfo); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} -window.registerCspResource = registerCspResource; - -// ==================== Schedule Resource Registration Functions ==================== - -// Global variables for schedule job auto-refresh -window.scheduleJobAutoRefreshEnabled = false; -window.scheduleJobAutoRefreshInterval = null; - -// Main Schedule Job Management Modal -async function showScheduleJobManagement() { - const config = getConfig(); - const hostname = config.hostname; - const port = config.port; - const username = config.username; - const password = config.password; - - // Generate random Infra name prefix (reg-xxxx) - const randomSuffix = Math.random().toString(36).substring(2, 6).toLowerCase(); - const defaultInfraPrefix = `reg-${randomSuffix}`; - - // Load namespace and connection lists - let namespaces = []; - let connections = []; - - try { - const [nsResponse, connResponse] = await Promise.all([ - axios.get(`${tbApiBase()}/ns?option=id`, { - auth: { username, password } - }), - axios.get(`${tbApiBase()}/connConfig`, { - auth: { username, password } - }) - ]); - - namespaces = nsResponse.data.output || nsResponse.data.ns || []; - connections = connResponse.data.connectionconfig || []; - } catch (error) { - console.error('Error loading namespace/connection list:', error); - Swal.fire('❌ Error', 'Failed to load namespace/connection list', 'error'); - return; - } - - // Build options - const nsOptions = namespaces.map(ns => { - const nsId = typeof ns === 'string' ? ns : (ns.id || ns); - return ``; - }).join(''); - - // Build provider/region/zone hierarchy - const providerMap = {}; - connections.forEach(conn => { - const provider = conn.providerName; - const region = conn.regionZoneInfo?.assignedRegion || ''; - const zone = conn.regionZoneInfo?.assignedZone || ''; - const display = conn.regionDetail?.location?.display || ''; - - if (!providerMap[provider]) { - providerMap[provider] = { regions: {} }; - } - if (region && !providerMap[provider].regions[region]) { - providerMap[provider].regions[region] = { zones: [], display }; - } else if (region && display && !providerMap[provider].regions[region].display) { - providerMap[provider].regions[region].display = display; - } - if (zone && !providerMap[provider].regions[region].zones.includes(zone)) { - providerMap[provider].regions[region].zones.push(zone); - } - }); - - // Build provider options - const providerOptions = '' + - Object.keys(providerMap).sort().map(provider => - `` - ).join(''); - - const connOptions = '' + - connections.map(conn => - `` - ).join(''); - - Swal.fire({ - title: '📅 Schedule Job Management', - html: ` - -
    -
    ➕ Create New Schedule Job
    -
    -
    - - -
    -
    - -
    -
    - 🌐 Provider/Region/Zone (Recommended) -
    -
    - 🔗 Connection Name (Legacy) -
    -
    - -
    -
    -
    - - - Leave empty for all providers -
    -
    -
    -
    - - - Select provider first -
    -
    - - - Select region first -
    -
    -
    - -
    -
    -
    - - - Leave empty for all connections -
    -
    -
    -
    -
    - - - Min: 10s, Recommended: 1800s+ -
    -
    - - - Auto-generated: reg-xxxx -
    -
    -
    -
    - -
    - -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    -
    -
    -
    - Default : All Resources -
    -
    - - -
    -
    - - -
    - -
    -
    📋 Existing Schedule Jobs
    -
    - - -
    -
    -
    - 🟢 Auto-refreshing every 10 seconds | - Last refresh: - -
    -
    -

    Loading schedule jobs...

    -
    -
    - `, - width: '50%', - customClass: { - popup: 'schedule-job-modal-popup' - }, - showConfirmButton: false, - showCancelButton: true, - cancelButtonText: '❌ Close', - didOpen: () => { - // Store providerMap for later use - window.scheduleProviderMap = providerMap; - - // Enable auto-refresh - window.scheduleJobAutoRefreshEnabled = true; - - // Setup filter mode tabs - const filterModeTabs = document.querySelectorAll('.filter-mode-tab'); - const filterModeContents = document.querySelectorAll('.filter-mode-content'); - - filterModeTabs.forEach(tab => { - tab.addEventListener('click', function() { - const mode = this.getAttribute('data-mode'); - - // Update tabs - filterModeTabs.forEach(t => t.classList.remove('active')); - this.classList.add('active'); - - // Update content - filterModeContents.forEach(content => { - content.classList.remove('active'); - }); - document.getElementById(`filter-${mode}`).classList.add('active'); - }); - }); - - // Setup provider/region/zone cascading selects - const providerSelect = document.getElementById('sched-provider'); - const regionSelect = document.getElementById('sched-region'); - const zoneSelect = document.getElementById('sched-zone'); - - if (providerSelect && regionSelect && zoneSelect) { - providerSelect.addEventListener('change', function() { - const selectedProvider = this.value; - - // Reset and disable region/zone - regionSelect.innerHTML = ''; - regionSelect.disabled = !selectedProvider; - zoneSelect.innerHTML = ''; - zoneSelect.disabled = true; - - if (selectedProvider && providerMap[selectedProvider]) { - const regions = Object.keys(providerMap[selectedProvider].regions).sort(); - regions.forEach(region => { - const option = document.createElement('option'); - option.value = region; - const display = providerMap[selectedProvider].regions[region].display; - option.textContent = display ? `${region} (${display})` : region; - regionSelect.appendChild(option); - }); - } - }); - - regionSelect.addEventListener('change', function() { - const selectedProvider = providerSelect.value; - const selectedRegion = this.value; - - // Reset and disable zone - zoneSelect.innerHTML = ''; - zoneSelect.disabled = !selectedRegion; - - if (selectedProvider && selectedRegion && - providerMap[selectedProvider]?.regions[selectedRegion]) { - const zones = providerMap[selectedProvider].regions[selectedRegion].zones.sort(); - zones.forEach(zone => { - const option = document.createElement('option'); - option.value = zone; - option.textContent = zone; - zoneSelect.appendChild(option); - }); - } - }); - } - - // Setup dropdown toggle - const dropdownBtn = document.getElementById('sched-option-btn'); - const dropdownMenu = document.getElementById('sched-option-menu'); - const dropdownText = document.getElementById('sched-option-text'); - - if (dropdownBtn && dropdownMenu) { - dropdownBtn.addEventListener('click', function(e) { - e.stopPropagation(); - dropdownMenu.classList.toggle('show'); - }); - - // Close dropdown when clicking outside - document.addEventListener('click', function(e) { - if (!dropdownMenu.contains(e.target) && e.target !== dropdownBtn) { - dropdownMenu.classList.remove('show'); - } - }); - } - - // Function to update button text based on selections - const updateDropdownText = () => { - const allCheckbox = document.getElementById('sched-option-all'); - if (allCheckbox && allCheckbox.checked) { - dropdownText.textContent = 'All Resources'; - return; - } - - const selected = []; - const checkboxes = [ - { id: 'sched-option-vnet', label: 'vNet' }, - { id: 'sched-option-sg', label: 'securityGroup' }, - { id: 'sched-option-sshkey', label: 'sshKey' }, - { id: 'sched-option-node', label: 'node' }, - { id: 'sched-option-customimage', label: 'customImage' } - ]; - - checkboxes.forEach(cb => { - const checkbox = document.getElementById(cb.id); - if (checkbox && checkbox.checked) { - selected.push(cb.label); - } - }); - - if (selected.length === 0) { - dropdownText.textContent = 'Select options...'; - } else if (selected.length <= 2) { - dropdownText.textContent = selected.join(', '); - } else { - dropdownText.textContent = `${selected.length} options selected`; - } - }; - - // Setup checkbox behavior for Registration Option - const allCheckbox = document.getElementById('sched-option-all'); - const resourceCheckboxes = [ - 'sched-option-vnet', - 'sched-option-sg', - 'sched-option-sshkey', - 'sched-option-node', - 'sched-option-customimage' - ]; - - if (allCheckbox) { - allCheckbox.addEventListener('change', function() { - if (this.checked) { - resourceCheckboxes.forEach(id => { - const cb = document.getElementById(id); - if (cb) cb.checked = false; - }); - } - updateDropdownText(); - }); - } - - resourceCheckboxes.forEach(id => { - const cb = document.getElementById(id); - if (cb) { - cb.addEventListener('change', function() { - if (this.checked && allCheckbox) { - allCheckbox.checked = false; - } - updateDropdownText(); - }); - } - }); - - // Setup refresh now button - const refreshBtn = document.getElementById('refreshJobsBtn'); - if (refreshBtn) { - refreshBtn.addEventListener('click', () => loadScheduleJobsInModal()); - } - - // Setup toggle auto-refresh button - const toggleBtn = document.getElementById('toggleJobAutoRefreshBtn'); - if (toggleBtn) { - toggleBtn.addEventListener('click', () => { - window.scheduleJobAutoRefreshEnabled = !window.scheduleJobAutoRefreshEnabled; - const status = document.getElementById('jobAutoRefreshStatus'); - - if (window.scheduleJobAutoRefreshEnabled) { - toggleBtn.innerHTML = '⏸️ Pause Auto-refresh'; - toggleBtn.className = 'btn btn-success btn-sm'; - if (status) status.innerHTML = '🟢 Auto-refreshing every 10 seconds'; - } else { - toggleBtn.innerHTML = '▶️ Resume Auto-refresh'; - toggleBtn.className = 'btn btn-warning btn-sm'; - if (status) status.innerHTML = '🔴 Auto-refresh paused'; - } - }); - } - - // Initial load - setTimeout(() => loadScheduleJobsInModal(), 100); - - // Start auto-refresh timer (10 seconds) - if (window.scheduleJobAutoRefreshInterval) { - clearInterval(window.scheduleJobAutoRefreshInterval); - } - window.scheduleJobAutoRefreshInterval = setInterval(() => { - if (window.scheduleJobAutoRefreshEnabled && Swal.isVisible()) { - loadScheduleJobsInModal(); - } - }, 10000); - }, - willClose: () => { - // Stop auto-refresh - window.scheduleJobAutoRefreshEnabled = false; - if (window.scheduleJobAutoRefreshInterval) { - clearInterval(window.scheduleJobAutoRefreshInterval); - window.scheduleJobAutoRefreshInterval = null; - } - } - }); -} -window.showScheduleJobManagement = showScheduleJobManagement; - -// Load Schedule Jobs in Modal -async function loadScheduleJobsInModal() { - const config = getConfig(); - const container = document.getElementById('scheduleJobListContainer'); - const lastRefreshTime = document.getElementById('jobLastRefreshTime'); - - if (!container) return; - - try { - const response = await axios.get( - `${tbApiBase()}/registerCspResources/schedule`, - { auth: { username: config.username, password: config.password } } - ); - - const jobs = response.data.jobs || []; - - if (lastRefreshTime) { - lastRefreshTime.innerHTML = `Last refresh: ${new Date().toLocaleTimeString()}`; - } - - if (jobs.length === 0) { - container.innerHTML = '

    No schedule jobs found. Create one above!

    '; - return; - } - - // Build job cards - container.innerHTML = jobs.map(job => { - // Execution State Badge (Scheduled, Executing, Stopped) - let executionStateBadge = ''; - if (job.status === 'Executing') { - executionStateBadge = '⚙️ Executing'; - } else if (job.status === 'Stopped') { - executionStateBadge = '⏹️ Stopped'; - } else { // Default to Scheduled - executionStateBadge = '📅 Scheduled'; - } - - // Enabled/Paused Badge (only for active jobs, not for stopped) - let enabledBadge = ''; - if (job.status !== 'Stopped') { - enabledBadge = job.enabled ? - '✅ Active' : - '⏸️ Paused'; - } - - // Auto-Disabled Warning - const autoDisabledBadge = job.autoDisabled ? - '⚠️ Auto-Disabled' : ''; - - return ` -
    -
    -
    ${job.jobId}
    -
    ${executionStateBadge} ${enabledBadge} ${autoDisabledBadge}
    -
    -
    -
    - NS: ${job.nsId} | - ${job.provider || job.region || job.zone ? - `Filter: ${job.provider || 'All'}${job.region ? `/${job.region}` : ''}${job.zone ? `/${job.zone}` : ''} | ` : - `Conn: ${job.connectionName || 'All'} | ` - } - Interval: ${job.intervalSeconds}s (${Math.round(job.intervalSeconds/60)}m) | - Infra Prefix: ${job.infraNamePrefix || '-'} | - Stats: Exec: ${job.executionCount}, Success: ${job.successCount}, Fail: ${job.failureCount} (Consecutive: ${job.consecutiveFailures}) | - Next: ${new Date(job.nextExecutionAt).toLocaleString()}${job.lastExecutionAt ? ` | Last: ${new Date(job.lastExecutionAt).toLocaleString()}` : ''} -
    -
    - -
    - `; - }).join(''); - - } catch (error) { - console.error('Error loading schedule jobs:', error); - container.innerHTML = '

    Error loading jobs. Please try again.

    '; - } -} -window.loadScheduleJobsInModal = loadScheduleJobsInModal; - -// Create Schedule Job from Modal -async function createScheduleJobFromModal() { - const config = getConfig(); - const nsId = document.getElementById('sched-nsId').value; - const intervalSeconds = parseInt(document.getElementById('sched-interval').value); - const infraNamePrefix = document.getElementById('sched-infraPrefix').value; - - // Determine active filter mode - const activeFilterMode = document.querySelector('.filter-mode-tab.active')?.getAttribute('data-mode') || 'hierarchy'; - - // Get filter values based on mode - let connectionName = ''; - let provider = ''; - let region = ''; - let zone = ''; - - if (activeFilterMode === 'connection') { - connectionName = document.getElementById('sched-connection')?.value || ''; - } else { - // hierarchy mode - provider = document.getElementById('sched-provider')?.value || ''; - region = document.getElementById('sched-region')?.value || ''; - zone = document.getElementById('sched-zone')?.value || ''; - } - - // Get selected options from checkboxes - const allCheckbox = document.getElementById('sched-option-all'); - let option = ''; - if (allCheckbox && allCheckbox.checked) { - option = ''; // All Resources (empty string) - } else { - const selectedOptions = []; - const optionCheckboxes = [ - { id: 'sched-option-vnet', value: 'vNet' }, - { id: 'sched-option-sg', value: 'securityGroup' }, - { id: 'sched-option-sshkey', value: 'sshKey' }, - { id: 'sched-option-node', value: 'node' }, - { id: 'sched-option-customimage', value: 'customImage' } - ]; - optionCheckboxes.forEach(opt => { - const checkbox = document.getElementById(opt.id); - if (checkbox && checkbox.checked) { - selectedOptions.push(opt.value); - } - }); - option = selectedOptions.join(','); - } - - const infraFlag = document.getElementById('sched-infraFlag').value; - - if (!nsId || !intervalSeconds || intervalSeconds < 10) { - Swal.fire('❌ Error', 'Please fill required fields correctly (interval min: 10s)', 'error'); - return; - } - - const spinnerId = addSpinnerTask("Creating schedule job"); - - try { - const requestBody = { - jobType: "registerCspResources", - nsId, - intervalSeconds, - infraNamePrefix, - option, - infraFlag - }; - - // Add filter fields based on mode - if (activeFilterMode === 'connection') { - if (connectionName) requestBody.connectionName = connectionName; - } else { - if (provider) requestBody.provider = provider; - if (region) requestBody.region = region; - if (zone) requestBody.zone = zone; - } - - const response = await axios.post( - `${tbApiBase()}/registerCspResources/schedule`, - requestBody, - { - headers: { "Content-Type": "application/json" }, - auth: { username: config.username, password: config.password } - } - ); - - console.log("Schedule Job Created:", response.data); - displayJsonData(response.data, typeInfo); - - Swal.fire({ - icon: 'success', - title: '✅ Job Created!', - html: `Schedule job created successfully:
    ${response.data.jobId}`, - timer: 2000, - showConfirmButton: false - }).then(() => { - // Refresh job list - loadScheduleJobsInModal(); - // Reopen management modal - showScheduleJobManagement(); - }); - - } catch (error) { - console.error("Error creating schedule job:", error); - const errorMsg = error.response?.data?.message || error.message || 'Unknown error'; - Swal.fire('❌ Error', `Failed to create job: ${errorMsg}`, 'error'); - } finally { - removeSpinnerTask(spinnerId); - } -} -window.createScheduleJobFromModal = createScheduleJobFromModal; - -// View Job Details -async function viewJobDetails(jobId) { - const config = getConfig(); - const spinnerId = addSpinnerTask("Loading job details"); - - try { - const response = await axios.get( - `${tbApiBase()}/registerCspResources/schedule/${jobId}`, - { auth: { username: config.username, password: config.password } } - ); - - const job = response.data; - displayJsonData(response.data, typeInfo); - - const detailsHtml = ` -
    - - - - - ${job.provider || job.region || job.zone ? - `` : - `` - } - - - - - - - - - - - - ${job.lastExecutionAt ? `` : ''} -
    Job ID${job.jobId}
    Job Type${job.jobType}
    Namespace${job.nsId}
    Target Filter - Provider: ${job.provider || 'All'}
    - Region: ${job.region || 'All'}
    - Zone: ${job.zone || 'All'} -
    Connection${job.connectionName || 'All Connections'}
    Infra Prefix${job.infraNamePrefix || '-'}
    Option${job.option || 'All Resources'}
    Infra Flag${job.infraFlag === 'y' ? 'Single Infra' : 'Separate per Node'}
    Interval${job.intervalSeconds} seconds (${Math.round(job.intervalSeconds/60)} minutes)
    Status - ${job.enabled ? '🟢 Enabled' : '⚫ Disabled'} - ${job.autoDisabled ? '⚠️ Auto-Disabled' : ''} -
    Execution Count${job.executionCount}
    Success Count${job.successCount}
    Failure Count${job.failureCount}
    Consecutive Failures${job.consecutiveFailures}
    Next Execution${new Date(job.nextExecutionAt).toLocaleString()}
    Created At${new Date(job.createdAt).toLocaleString()}
    Last Execution${new Date(job.lastExecutionAt).toLocaleString()}
    -
    - `; - - Swal.fire({ - title: '🔍 Job Details', - html: detailsHtml, - width: '650px', - confirmButtonText: '👍 OK' - }).then(() => { - showScheduleJobManagement(); - }); - - } catch (error) { - console.error("Error loading job details:", error); - const errorMsg = error.response?.data?.message || error.message || 'Unknown error'; - Swal.fire('❌ Error', `Failed to load job details: ${errorMsg}`, 'error'); - } finally { - removeSpinnerTask(spinnerId); - } -} -window.viewJobDetails = viewJobDetails; - -// Pause Job from Modal -async function pauseJobFromModal(jobId) { - const config = getConfig(); - const spinnerId = addSpinnerTask("Pausing job"); - - try { - await axios.put( - `${tbApiBase()}/registerCspResources/schedule/${jobId}/pause`, - {}, - { auth: { username: config.username, password: config.password } } - ); - - Swal.fire({ - icon: 'success', - title: '⏸️ Job Paused', - text: `Job paused: ${jobId}`, - timer: 1500, - showConfirmButton: false - }).then(() => { - // Reopen Schedule Job Management modal - showScheduleJobManagement(); - }); - - } catch (error) { - console.error("Error pausing job:", error); - const errorMsg = error.response?.data?.message || error.message || 'Unknown error'; - Swal.fire('❌ Error', `Failed to pause job: ${errorMsg}`, 'error'); - } finally { - removeSpinnerTask(spinnerId); - } -} -window.pauseJobFromModal = pauseJobFromModal; - -// Resume Job from Modal -async function resumeJobFromModal(jobId) { - const config = getConfig(); - const spinnerId = addSpinnerTask("Resuming job"); - - try { - await axios.put( - `${tbApiBase()}/registerCspResources/schedule/${jobId}/resume`, - {}, - { auth: { username: config.username, password: config.password } } - ); - - Swal.fire({ - icon: 'success', - title: '▶️ Job Resumed', - text: `Job resumed: ${jobId}`, - timer: 1500, - showConfirmButton: false - }).then(() => { - // Reopen Schedule Job Management modal - showScheduleJobManagement(); - }); - - } catch (error) { - console.error("Error resuming job:", error); - const errorMsg = error.response?.data?.message || error.message || 'Unknown error'; - Swal.fire('❌ Error', `Failed to resume job: ${errorMsg}`, 'error'); - } finally { - removeSpinnerTask(spinnerId); - } -} -window.resumeJobFromModal = resumeJobFromModal; - -// Delete Job from Modal -async function deleteJobFromModal(jobId) { - const result = await Swal.fire({ - title: '⚠️ Confirm Delete', - html: `Are you sure you want to delete this job?
    ${jobId}`, - icon: 'warning', - showCancelButton: true, - confirmButtonText: '✅ Yes, delete it', - confirmButtonColor: '#dc3545', - cancelButtonText: '❌ Cancel' - }); - - if (!result.isConfirmed) return; - - const config = getConfig(); - const spinnerId = addSpinnerTask("Deleting job"); - - try { - await axios.delete( - `${tbApiBase()}/registerCspResources/schedule/${jobId}`, - { auth: { username: config.username, password: config.password } } - ); - - Swal.fire({ - icon: 'success', - title: '🗑️ Job Deleted', - text: `Job deleted: ${jobId}`, - timer: 1500, - showConfirmButton: false - }).then(() => { - // Reopen Schedule Job Management modal - showScheduleJobManagement(); - }); - - } catch (error) { - console.error("Error deleting job:", error); - const errorMsg = error.response?.data?.message || error.message || 'Unknown error'; - Swal.fire('❌ Error', `Failed to delete job: ${errorMsg}`, 'error'); - } finally { - removeSpinnerTask(spinnerId); - } -} -window.deleteJobFromModal = deleteJobFromModal; - -// ==================== End of Schedule Resource Registration Functions ==================== - -// ==================== Credential Holder Functions ==================== - -// Cached credential holder list (populated at startup) -var cachedCredentialHolderList = []; - -// Load credential holder list from CB-Tumblebug API -function updateCredentialHolderList(callback) { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - - if (hostname && hostname != "" && port && port != "") { - var url = `${tbApiBase()}/credentialHolder`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - cachedCredentialHolderList = res.data.credentialHolderList || []; - console.log('[CredentialHolder] Loaded ' + cachedCredentialHolderList.length + ' holders'); - // Update UI displays - updateHolderStatusDisplays(); - if (callback) callback(cachedCredentialHolderList); - }) - .catch((err) => { - console.warn("[CredentialHolder] Failed to load holder list:", err.message || err); - cachedCredentialHolderList = [{ credentialHolder: "admin", providers: [], verifiedConnectionCount: 0 }]; - updateHolderStatusDisplays(); - if (callback) callback(cachedCredentialHolderList); - }); - } -} - -// Update all UI displays showing current holder -function updateHolderStatusDisplays() { - // Update map controls badge - var holderNameEl = document.getElementById('mapHolderName'); - if (holderNameEl) { - holderNameEl.textContent = configCredentialHolder; - } - -} - -// Update NS ID map badge -function updateNsDisplays() { - var nsName = configNamespace || '—'; - var mapNsNameEl = document.getElementById('mapNsName'); - if (mapNsNameEl) mapNsNameEl.textContent = nsName; -} - -// Change active namespace and refresh dependent lists -function applyNamespace(newNs) { - if (newNs === configNamespace) return; - var oldNs = configNamespace; - configNamespace = newNs; - window.configNamespace = newNs; - console.log('[Namespace] Changed: ' + oldNs + ' → ' + newNs); - updateNsDisplays(); - updateInfraList(); -} - -// Change credential holder and reload connections + map -function applyCredentialHolder(newHolder) { - if (newHolder === configCredentialHolder) return; - - var oldHolder = configCredentialHolder; - configCredentialHolder = newHolder; - console.log('[CredentialHolder] Changed: ' + oldHolder + ' → ' + newHolder); - - // Update all status displays - updateHolderStatusDisplays(); - - // Reload connections filtered by new holder → re-render map - reloadConnectionsForHolder(); -} - -// Reload connections from CB-TB with current holder filter, then refresh map -function reloadConnectionsForHolder() { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - - var url = `${tbApiBase()}/connConfig?filterVerified=true&filterRegionRepresentative=true&filterCredentialHolder=${encodeURIComponent(configCredentialHolder)}`; - - console.log('[CredentialHolder] Reloading connections for holder:', configCredentialHolder); - updateMapConnectionStatus('connecting'); - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 15000, - }) - .then((res) => { - var connData = res.data; - if (connData.connectionconfig) { - console.log('[CredentialHolder] Loaded ' + connData.connectionconfig.length + ' connections for holder: ' + configCredentialHolder); - - // Clear and re-process connection data on the map - // We need to use the same processConnectionData logic - // Since processConnectionData is a closure in checkConnectionWithRetry, - // we replicate the essential logic here: - window.cloudBaristaCentralData.connection = connData.connectionconfig; - - // Clear existing CSP points - Object.keys(cspPoints).forEach(key => { cspPoints[key] = []; }); - Object.keys(geoCspPoints).forEach(key => { geoCspPoints[key] = []; }); - - // Re-populate from new connection data - connData.connectionconfig.forEach((connConfig) => { - var providerName = connConfig.providerName; - if (!providerName) return; - var longitude = connConfig.regionDetail?.location?.longitude; - var latitude = connConfig.regionDetail?.location?.latitude; - if (longitude == null || latitude == null) return; - - if (!cspPoints[providerName]) { - cspPoints[providerName] = []; - } - cspPoints[providerName].push([parseFloat(longitude), parseFloat(latitude)]); - }); - - // Rebuild geoCspPoints with MultiPoint geometries - Object.keys(cspPoints).forEach(providerName => { - if (cspPoints[providerName].length > 0) { - if (!geoCspPoints[providerName]) { - geoCspPoints[providerName] = []; - } - geoCspPoints[providerName][0] = new MultiPoint(cspPoints[providerName]); - } - }); - - // Refresh provider checkboxes to match new connection data - var providerCheckboxContainer = document.getElementById('provider-checkboxes'); - if (providerCheckboxContainer) { - providerCheckboxContainer.innerHTML = ''; - Object.keys(cspPoints).forEach(providerName => { - if (cspPoints[providerName].length === 0) return; - var checkboxDiv = document.createElement('div'); - checkboxDiv.className = 'dropdown-item-text'; - var formCheckDiv = document.createElement('div'); - formCheckDiv.className = 'form-check'; - var checkbox = document.createElement('input'); - checkbox.className = 'form-check-input'; - checkbox.type = 'checkbox'; - checkbox.id = 'provider-' + providerName; - checkbox.value = providerName; - var label = document.createElement('label'); - label.className = 'form-check-label'; - label.setAttribute('for', 'provider-' + providerName); - label.textContent = providerName.toUpperCase(); - formCheckDiv.appendChild(checkbox); - formCheckDiv.appendChild(label); - checkboxDiv.appendChild(formCheckDiv); - providerCheckboxContainer.appendChild(checkboxDiv); - checkbox.addEventListener('change', function() { - var allCb = document.getElementById('provider-all'); - if (this.checked && allCb) { allCb.checked = false; } - updateMapBasedOnProviders(); - updateProviderDropdownText(); - }); - }); - // Reset "ALL" checkbox to checked - var allCb = document.getElementById('provider-all'); - if (allCb) { allCb.checked = true; } - updateProviderDropdownText(); - } - - // Force map re-render - map.render(); - var view = map.getView(); - if (view) view.changed(); - setTimeout(() => { map.render(); }, 100); - setTimeout(() => { map.render(); }, 500); - - updateMapConnectionStatus('connected'); - - // Also refresh Infra data and namespace list for new holder context - updateNsList(); - getInfra(); - } else { - console.log('[CredentialHolder] No connections for holder:', configCredentialHolder); - // Clear map points - Object.keys(cspPoints).forEach(key => { cspPoints[key] = []; }); - Object.keys(geoCspPoints).forEach(key => { geoCspPoints[key] = []; }); - window.cloudBaristaCentralData.connection = []; - map.render(); - updateMapConnectionStatus('connected'); - } - }) - .catch((err) => { - console.error('[CredentialHolder] Failed to reload connections:', err); - updateMapConnectionStatus('disconnected'); - }); -} -window.reloadConnectionsForHolder = reloadConnectionsForHolder; - -// ==================== End of Credential Holder Functions ==================== - -function updateNsList() { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - - if (!hostname || hostname === "" || !port || port === "") return; - - var url = `${tbApiBase()}/ns?option=id`; - - axios({ - method: "get", - url: url, - auth: { username, password }, - }) - .then((res) => { - if (res.data.output != null) { - cachedNamespaceList = res.data.output.filter(item => item && item.trim() !== ""); - - // If no namespace selected yet, pick the first one - if (!configNamespace && cachedNamespaceList.length > 0) { - configNamespace = cachedNamespaceList[0]; - window.configNamespace = configNamespace; - } - // If current selection no longer in list, reset to first - if (configNamespace && !cachedNamespaceList.includes(configNamespace) && cachedNamespaceList.length > 0) { - configNamespace = cachedNamespaceList[0]; - window.configNamespace = configNamespace; - } - - updateNsDisplays(); - - // Update Settings modal NS select if currently open - var settingsNsSelect = document.getElementById('settings-namespace'); - if (settingsNsSelect) { - settingsNsSelect.innerHTML = cachedNamespaceList.map(ns => { - const safeNs = window.escapeHtml(ns); - const selected = ns === configNamespace ? 'selected' : ''; - return ``; - }).join(''); - } - } - }) - .finally(function () { - updateInfraList(); - }); -} - -// (syncNamespaceSelection removed — namespace is now a global configNamespace) - -var infraList = []; -var infraHideList = []; - -function updateInfraList() { - // Clear options in 'select' - var selectElement = document.getElementById("infraid"); - var previousSelection = selectElement.value; - var i, - L = selectElement.options.length - 1; - for (i = L; i >= 0; i--) { - selectElement.remove(i); - } - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (namespace && namespace != "") { - var url = `${tbApiBase()}/ns/${namespace}/infra?option=id`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - if (res.data.output != null) { - // infraList = res.data.output; - for (let item of res.data.output) { - if (item && item.trim() !== "") { - var option = document.createElement("option"); - option.value = item; - option.text = item; - selectElement.appendChild(option); - } - } - for (let i = 0; i < selectElement.options.length; i++) { - if (selectElement.options[i].value == previousSelection) { - selectElement.options[i].selected = true; - break; - } - } - } - }) - .finally(function () { - updateNodeAndIpListsFromInfra(); - updateResourceList(typeStringVNet); - updateResourceList(typeStringSG); - updateResourceList(typeStringSshKey); - // updateResourceList(typeStringSpec); - // updateResourceList(typeStringImage); - }); - } -} -window.updateInfraList = updateInfraList; - -document.getElementById("infraid").onmouseover = function () { - updateInfraList(); -}; -document.getElementById("infraid").onchange = function () { - updateNodeAndIpListsFromInfra(); -}; - -function updateVmList() { - // This function is now deprecated as Node list is updated via updateNodeAndIpListsFromInfra() - // Keeping for backward compatibility, but functionality moved to unified function -} -window.updateVmList = updateVmList; - -document.getElementById("nodeid").addEventListener('change', function () { - // When Node is selected, auto-select corresponding IP - var selectedNodeId = this.value; - var pubipSelect = document.getElementById("pubip"); - - // Find and select the IP option that contains this Node ID - for (let i = 0; i < pubipSelect.options.length; i++) { - var optionText = pubipSelect.options[i].text; - if (optionText.includes(`(${selectedNodeId},`)) { - pubipSelect.options[i].selected = true; - break; - } - } -}); - -function updateIpList() { - // This function is now deprecated as IP list is updated via updateNodeAndIpListsFromInfra() - // Keeping for backward compatibility, but functionality moved to unified function -} -window.updateIpList = updateIpList; - -function updateNodeGroupList() { - // This function is now deprecated as NodeGroup selection is removed from UI - // NodeGroup information is now shown in Node ID dropdown as "node-id (nodegroup-id)" -} -window.updateNodeGroupList = updateNodeGroupList; - -// NodeGroup selection element no longer exists in UI - -// New unified function to update Node and IP lists from Infra data -function updateNodeAndIpListsFromInfra() { - var nodeSelectElement = document.getElementById("nodeid"); - var ipSelectElement = document.getElementById("pubip"); - var previousNodeSelection = nodeSelectElement.value; - var previousIpSelection = ipSelectElement.value; - - // Clear existing options - while (nodeSelectElement.options.length > 0) { - nodeSelectElement.remove(0); - } - while (ipSelectElement.options.length > 0) { - ipSelectElement.remove(0); - } - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = infraidElement.value; - - if (namespace && namespace != "" && infraid && infraid != "") { - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - if (res.data && res.data.node) { - res.data.node.forEach(nd => { - // Add Node option with NodeGroup info - var nodeOption = document.createElement("option"); - nodeOption.value = nd.id; - nodeOption.text = `${nd.id} (${nd.nodeGroupId || 'default'})`; - nodeSelectElement.appendChild(nodeOption); - - // Add IP option with Node and NodeGroup info - if (nd.publicIP && nd.publicIP.trim() !== "") { - var ipOption = document.createElement("option"); - ipOption.value = nd.publicIP; - ipOption.text = `${nd.publicIP} (${nd.id}, ${nd.nodeGroupId || 'default'})`; - ipSelectElement.appendChild(ipOption); - } - }); - - // Restore previous selections if they still exist - for (let i = 0; i < nodeSelectElement.options.length; i++) { - if (nodeSelectElement.options[i].value === previousNodeSelection) { - nodeSelectElement.options[i].selected = true; - break; - } - } - for (let i = 0; i < ipSelectElement.options.length; i++) { - if (ipSelectElement.options[i].value === previousIpSelection) { - ipSelectElement.options[i].selected = true; - break; - } - } - } - }) - .catch(function (error) { - console.error("Error updating Node and IP lists:", error); - }); - } -} -window.updateNodeAndIpListsFromInfra = updateNodeAndIpListsFromInfra; - -// Helper function to extract NodeGroup ID from Node selection text -function getNodeGroupIdFromNodeSelection() { - var nodeSelect = document.getElementById("nodeid"); - var selectedOption = nodeSelect.options[nodeSelect.selectedIndex]; - if (selectedOption && selectedOption.text) { - // Extract NodeGroup ID from text like "node-id (nodegroup-id)" - var match = selectedOption.text.match(/\(([^)]+)\)$/); - if (match) { - return match[1]; - } - } - return ""; -} - -function updateResourceList(resourceType) { - var selectElement = document.getElementById(resourceType); - var previousSelection = selectElement.value; - var i, - L = selectElement.options.length - 1; - for (i = L; i >= 0; i--) { - selectElement.remove(i); - } - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (namespace && namespace != "" && resourceType && resourceType != "") { - var url = `${tbApiBase()}/ns/${namespace}/resources/${resourceType}?option=id`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - if (res.data.output != null) { - for (let item of res.data.output) { - if (item && item.trim() !== "") { - var option = document.createElement("option"); - option.value = item; - option.text = item; - document.getElementById(resourceType).appendChild(option); - } - } - for (let i = 0; i < selectElement.options.length; i++) { - if (selectElement.options[i].value == previousSelection) { - selectElement.options[i].selected = true; - break; - } - } - } - }); - } -} - -// Initialize DOM event handlers when document is ready -document.addEventListener('DOMContentLoaded', function() { - // Initialize map's Last Updated display - initializeMapLastUpdated(); - - // Initialize map's Connection Status - updateMapConnectionStatus('unknown'); - - // Initialize provider dropdown text - updateProviderDropdownText(); - - // Namespace is now managed via Map Settings (configNamespace global) - - // Resource list event handlers - const vNetElement = document.getElementById(typeStringVNet); - if (vNetElement) { - vNetElement.onmouseover = function () { - updateResourceList(typeStringVNet); - }; - } - - const securityGroupElement = document.getElementById(typeStringSG); - if (securityGroupElement) { - securityGroupElement.onmouseover = function () { - updateResourceList(typeStringSG); - }; - } - - const sshKeyElement = document.getElementById(typeStringSshKey); - if (sshKeyElement) { - sshKeyElement.onmouseover = function () { - updateResourceList(typeStringSshKey); - }; - } - - // document.getElementById(typeStringImage).onmouseover = function () { - // //updateResourceList(typeStringImage); - // }; - // document.getElementById(typeStringSpec).onmouseover = function () { - // //updateResourceList(typeStringSpec); - // }; -}); - -function updateConnectionList() { - var selectElement = document.getElementById(typeStringConnection); - var previousSelection = selectElement.value; - var i, - L = selectElement.options.length - 1; - for (i = L; i >= 0; i--) { - selectElement.remove(i); - } - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - - var url = `${tbApiBase()}/connConfig?filterVerified=true&filterRegionRepresentative=true&filterCredentialHolder=${encodeURIComponent(configCredentialHolder)}`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - if (res.data.connectionconfig != null) { - for (let item of res.data.connectionconfig) { - var option = document.createElement("option"); - option.value = item.configName; - option.text = item.configName; - //option.text = item.providerName + "/" + item.regionDetail.regionName; - document.getElementById(typeStringConnection).appendChild(option); - } - for (let i = 0; i < selectElement.options.length; i++) { - if (selectElement.options[i].value == previousSelection) { - selectElement.options[i].selected = true; - break; - } - } - } - }).catch(function (error) { - console.log(error); - //errorAlert("Failed to get connection list"); - if (error.response && error.response.data) { - displayJsonData(error.response.data, typeError); - } - }); -} - -document.getElementById(typeStringConnection).onmouseover = function () { - updateConnectionList(); -}; - -function AddMcNLB() { - var infraid = infraidElement.value; - - if (!infraid) { - errorAlert("You need to specify the ID of Infra"); - return; - } - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/mcSwNlb`; - - Swal.fire({ - title: "Configuration for Global NLB", - width: 600, - html: - "
    " + - "

    Global NLB Configuration:

    " + - "

    Target Infra: " + infraid + "

    " + - "

    Protocol: TCP

    " + - "
    " + - "

    Port (listen/target):

    " + - "
    ", - input: "number", - inputValue: 80, - didOpen: () => { - const input = Swal.getInput(); - if (input) { - input.focus(); - input.select(); - } - }, - inputAttributes: { - autocapitalize: "off", - }, - showCancelButton: true, - confirmButtonText: "Create Global NLB", - confirmButtonColor: "#28a745", - position: "top-end", - backdrop: `rgba(0, 0, 0, 0.08)`, - }).then((result) => { - if (result.value) { - var nlbport = result.value; - if (isNaN(nlbport) || nlbport <= 0) { - nlbport = 80; - } - - var spinnerId = addSpinnerTask("Creating Global NLB"); - - var nlbReqTmp = { - type: "PUBLIC", - scope: "REGION", - listener: { - Protocol: "TCP", - Port: `${nlbport}`, - }, - targetGroup: { - Protocol: "TCP", - Port: `${nlbport}`, - }, - HealthChecker: { - Interval: 10, // default: check every 10 seconds - Timeout: 10, // default: 10 second timeout per check - Threshold: 3, // default: 3 consecutive failures to mark unhealthy - }, - }; - - axios({ - method: "post", - url: url, - headers: { "Content-Type": "application/json" }, - data: JSON.stringify(nlbReqTmp, undefined, 4), - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - successAlert("Global NLB created successfully"); - getInfra(); - }) - .catch(function (error) { - errorAlert("Error creating Global NLB: " + (error.response?.data?.message || error.message)); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); - } - }); -} -window.AddMcNLB = AddMcNLB; - -function AddNLB() { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = infraidElement.value; - - if (!infraid) { - errorAlert("You need to specify the ID of Infra"); - return; - } - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - // Load NodeGroup list for selection - var nodeGroupUrl = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/nodegroup`; - var spinnerId = addSpinnerTask("Loading NodeGroup list"); - - axios({ - method: "get", - url: nodeGroupUrl, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000, - }) - .then((res) => { - var nodeGroupOptions = ''; - - if (res.data.output && res.data.output.length > 0) { - res.data.output.forEach((nodeGroupName) => { - if (nodeGroupName && nodeGroupName.trim() !== "") { - nodeGroupOptions += ``; - } - }); - - // Show NodeGroup selection dialog with port configuration - Swal.fire({ - title: "Create Regional NLB", - width: 600, - html: - "
    " + - "

    Regional NLB Configuration:

    " + - "

    Target Infra: " + infraid + "

    " + - "
    " + - "
    " + - "" + - "" + - "
    " + - "
    " + - "" + - "" + - "TCP protocol will be used" + - "
    " + - "
    ", - showCancelButton: true, - confirmButtonText: "Create Regional NLB", - cancelButtonText: "Cancel", - confirmButtonColor: "#17a2b8", - position: "top-end", - backdrop: `rgba(0, 0, 0, 0.08)`, - didOpen: () => { - // Focus on port input after dialog opens - const portInput = document.getElementById('nlb-port'); - if (portInput) { - portInput.focus(); - portInput.select(); - } - }, - preConfirm: () => { - const selectedNodeGroup = document.getElementById('nodegroup-select').value; - const nlbPort = document.getElementById('nlb-port').value; - - if (!selectedNodeGroup) { - Swal.showValidationMessage('Please select a NodeGroup'); - return false; - } - - if (!nlbPort || isNaN(nlbPort) || nlbPort <= 0 || nlbPort > 65535) { - Swal.showValidationMessage('Please enter a valid port number (1-65535)'); - return false; - } - - return { nodeGroup: selectedNodeGroup, port: parseInt(nlbPort) }; - } - }).then((result) => { - if (result.isConfirmed) { - createRegionalNLB(infraid, result.value.nodeGroup, result.value.port, namespace, hostname, port, username, password); - } - }); - } else { - errorAlert("No NodeGroups found in the selected Infra"); - } - }) - .catch(function (error) { - errorAlert("Error loading NodeGroups: " + (error.response?.data?.message || error.message)); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} - -// Create Regional NLB with selected NodeGroup and port -function createRegionalNLB(infraid, nodegroupid, nlbport, namespace, hostname, port, username, password) { - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/nlb`; - - var nlbReqTmp = { - type: "PUBLIC", - scope: "REGION", - listener: { - Protocol: "TCP", - Port: `${nlbport}`, - }, - targetGroup: { - Protocol: "TCP", - Port: `${nlbport}`, - nodeGroupId: `${nodegroupid}`, - }, - HealthChecker: { - Interval: 10, // default: check every 10 seconds - Timeout: 10, // default: 10 second timeout per check - Threshold: 3, // default: 3 consecutive failures to mark unhealthy - }, - }; - - var spinnerId = addSpinnerTask("Creating Regional NLB"); - - axios({ - method: "post", - url: url, - headers: { "Content-Type": "application/json" }, - data: JSON.stringify(nlbReqTmp, undefined, 4), - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - successAlert("Regional NLB created successfully"); - getInfra(); - }) - .catch(function (error) { - errorAlert("Error creating Regional NLB: " + (error.response?.data?.message || error.message)); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} - -window.AddNLB = AddNLB; - -function DelNLB() { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = infraidElement.value; - - if (!infraid) { - errorAlert("You need to specify the ID of Infra"); - return; - } - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - // Load NodeGroup list for selection - var nodeGroupUrl = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/nodegroup`; - var spinnerId = addSpinnerTask("Loading NodeGroup list"); - - axios({ - method: "get", - url: nodeGroupUrl, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000, - }) - .then((res) => { - var nodeGroupOptions = ''; - - if (res.data.output && res.data.output.length > 0) { - res.data.output.forEach((nodeGroupName) => { - if (nodeGroupName && nodeGroupName.trim() !== "") { - nodeGroupOptions += ``; - } - }); - - // Show NodeGroup selection dialog with deletion confirmation - Swal.fire({ - title: "Delete Regional NLB", - width: 600, - html: - "
    " + - "

    ⚠️ Warning: This action cannot be undone.

    " + - "

    Target Infra: " + infraid + "

    " + - "
    " + - "
    " + - "" + - "" + - "
    " + - "
    " + - "Confirmation: The Regional NLB for the selected NodeGroup will be permanently deleted." + - "
    " + - "
    ", - icon: 'warning', - showCancelButton: true, - confirmButtonText: "Delete Regional NLB", - cancelButtonText: "Cancel", - confirmButtonColor: "#dc3545", - position: "top-end", - backdrop: `rgba(0, 0, 0, 0.08)`, - preConfirm: () => { - const selectedNodeGroup = document.getElementById('nodegroup-select').value; - if (!selectedNodeGroup) { - Swal.showValidationMessage('Please select a NodeGroup'); - return false; - } - return selectedNodeGroup; - } - }).then((result) => { - if (result.isConfirmed) { - deleteRegionalNLB(infraid, result.value, namespace, hostname, port, username, password); - } - }); - } else { - errorAlert("No NodeGroups found in the selected Infra"); - } - }) - .catch(function (error) { - errorAlert("Error loading NodeGroups: " + (error.response?.data?.message || error.message)); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} - -// Show deletion confirmation dialog after NodeGroup selection -// Separate function to handle the actual deletion -function deleteRegionalNLB(infraid, nodegroupid, namespace, hostname, port, username, password) { - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/nlb/${nodegroupid}`; - var spinnerId = addSpinnerTask("Deleting Regional NLB"); - - axios({ - method: "delete", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }) - .then((res) => { - successAlert("Regional NLB deleted successfully"); - getInfra(); - }) - .catch(function (error) { - errorAlert("Error deleting Regional NLB: " + (error.response?.data?.message || error.message)); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} -window.DelNLB = DelNLB; - -// ============================================================================ -// NLB (Regional CSP NLB) — rich manager: list / status / health / targets / -// create / delete. Loads the Infra's NLBs live and shows one tab per NLB plus a -// Create tab. Per-NLB actions run via window.nlb* handlers that reuse window._nlbCtx. -// ============================================================================ -// TB does not populate NLBInfo.Status; derive a display status from the CSP -// State carried in keyValueList (e.g. "{Code:provisioning,Reason:null}"). -function nlbDisplayStatus(nlb) { - if (nlb && nlb.status) return nlb.status; - const kv = ((nlb && nlb.keyValueList) || []).find(k => (k.Key || k.key) === 'State'); - if (kv) { - const m = /Code:\s*([A-Za-z_-]+)/.exec(kv.Value || kv.value || ''); - if (m) return m[1]; - } - return 'Unknown'; -} - -function nlbStatusBadge(status) { - const s = (status || '').toLowerCase(); - let bg = '#6c757d'; - if (s.includes('available') || s.includes('running') || s.includes('active')) bg = '#28a745'; - else if (s.includes('creat') || s.includes('pending') || s.includes('progress')) bg = '#f0ad4e'; - else if (s.includes('fail') || s.includes('error')) bg = '#dc3545'; - return `${window.escapeHtml(status || 'Unknown')}`; -} - -async function manageNLB(opts) { - const config = getConfig(); - const { hostname, port, username, password } = config; - const namespace = configNamespace; - // opts.infraId / opts.preselectNlbId let the Net-graph NLB node right-click open - // this manager scoped to that NLB's Infra with its tab pre-selected. - const infraid = (opts && opts.infraId) || infraidElement.value; - const preselectNlbId = (opts && opts.preselectNlbId) || ''; - if (!namespace) { errorAlert("Please select a namespace first"); return; } - if (!infraid) { errorAlert("Please select an Infra first"); return; } - window._nlbCtx = { hostname, port, username, password, namespace, infraid }; - const base = `${tbApiBase()}/ns/${namespace}/infra/${infraid}`; - const esc = (v) => window.escapeHtml(String(v == null ? '' : v)); - - const spinnerId = addSpinnerTask("Loading NLBs"); - let nlbs = [], nodeGroups = []; - try { - const [nlbRes, ngRes] = await Promise.all([ - axios({ method: 'get', url: `${base}/nlb`, auth: { username, password } }).catch(() => ({ data: {} })), - axios({ method: 'get', url: `${base}/nodegroup`, auth: { username, password } }).catch(() => ({ data: {} })), - ]); - nlbs = (nlbRes.data && nlbRes.data.nlb) || []; - nodeGroups = (ngRes.data && ngRes.data.output) || []; - } finally { removeSpinnerTask(spinnerId); } - - // Which NLB tab to open first (preselect from the Net graph, else the first). - const activeIdx = Math.max(0, nlbs.findIndex(n => n.id === preselectNlbId)); - - const tabBtn = (id, target, label, color, active) => - ``; - - const tabButtons = nlbs.map((nlb, idx) => tabBtn(`nlb-tab-${idx}`, `#nlb-content-${idx}`, esc(nlb.name || nlb.id), '#0d6efd', idx === activeIdx)).join('') - + tabBtn('nlb-tab-create', '#nlb-content-create', '➕ Create', '#28a745', nlbs.length === 0); - - const nlbPanes = nlbs.map((nlb, idx) => { - const li = nlb.listener || {}, tg = nlb.targetGroup || {}, hc = nlb.healthChecker || {}; - const nodes = tg.nodes || []; - const lHost = li.ip || li.dnsName || ''; - const endpoint = lHost || '—'; - const lScheme = String(li.port) === '443' ? 'https' : 'http'; - const openUrl = lHost ? `${lScheme}://${lHost}${li.port ? ':' + li.port : ''}` : ''; - return ` -
    - - - - - -
    `; - }).join(''); - - const ngOptions = nodeGroups.map(ng => ``).join(''); - const createPane = ` -
    - - -
    `; - - Swal.fire({ - title: `⚖️ NLB Management — ${esc(infraid)}`, - width: 920, - html: `${POPUP_STYLES} - `, - showConfirmButton: false, - showCancelButton: true, - cancelButtonText: 'Close', - willClose: () => stopAllNlbPolls(), - didOpen: () => { - // Scoped tab switching — only this modal's panes/buttons (never the map's). - const btns = document.querySelectorAll('#nlbTabs button[data-bs-toggle="tab"]'); - btns.forEach(b => b.addEventListener('click', function (e) { - e.preventDefault(); - btns.forEach(x => { x.classList.remove('active'); x.style.color = '#6c757d'; x.style.borderBottomColor = 'transparent'; }); - document.querySelectorAll('#nlbTabContent .tab-pane').forEach(p => p.classList.remove('show', 'active')); - const color = this.id === 'nlb-tab-create' ? '#28a745' : '#0d6efd'; - this.classList.add('active'); this.style.color = color; this.style.borderBottomColor = color; - const t = document.querySelector(this.getAttribute('data-bs-target')); - if (t) t.classList.add('show', 'active'); - })); - // Auto-poll health (3s) for NLBs not yet active; stop each when its targets - // are all healthy (readiness), flipping its badge to "active". - nlbs.forEach((nlb, idx) => { - if ((nlbDisplayStatus(nlb) || '').toLowerCase() !== 'active') startNlbHealthPoll(nlb.id, idx); - }); - }, - }); -} -window.manageNLB = manageNLB; - -window.nlbManagerRefresh = () => { Swal.close(); setTimeout(manageNLB, 150); }; - -window.nlbDoCreate = async () => { - const c = window._nlbCtx; if (!c) return; - const ng = document.getElementById('nlb-c-ng').value; - if (!ng) { errorAlert("Select a target NodeGroup"); return; } - const body = { - type: "PUBLIC", scope: "REGION", - listener: { Protocol: document.getElementById('nlb-c-lproto').value, Port: String(document.getElementById('nlb-c-lport').value || 80) }, - targetGroup: { Protocol: document.getElementById('nlb-c-tproto').value, Port: String(document.getElementById('nlb-c-tport').value || 80), nodeGroupId: ng }, - HealthChecker: { - Interval: Number(document.getElementById('nlb-c-hcint').value || 10), - Timeout: Number(document.getElementById('nlb-c-hcto').value || 10), - Threshold: Number(document.getElementById('nlb-c-hcth').value || 3), - }, - }; - const s = addSpinnerTask("Creating Regional NLB"); - try { - await axios({ method: 'post', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/nlb`, headers: { 'Content-Type': 'application/json' }, data: body, auth: { username: c.username, password: c.password } }); - successAlert("Regional NLB created"); - getInfra(); - window.nlbManagerRefresh(); - } catch (e) { errorAlert("Create NLB failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(s); } -}; - -window.nlbDoDelete = async (nlbId) => { - const c = window._nlbCtx; if (!c) return; - const r = await Swal.fire({ icon: 'warning', title: 'Delete NLB?', text: nlbId, showCancelButton: true, confirmButtonText: 'Delete', confirmButtonColor: '#d33' }); - if (!r.isConfirmed) return; - const s = addSpinnerTask("Deleting NLB"); - try { - await axios({ method: 'delete', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/nlb/${nlbId}`, auth: { username: c.username, password: c.password } }); - successAlert("NLB deleted"); - getInfra(); - window.nlbManagerRefresh(); - } catch (e) { errorAlert("Delete NLB failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(s); } -}; - -// Fetch live health (Spider call) for an NLB → {healthy, unhealthy, all}. -async function nlbFetchHealth(c, nlbId) { - const res = await axios({ method: 'get', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/nlb/${nlbId}/healthz`, auth: { username: c.username, password: c.password } }); - const h = res.data || {}; - return { - healthy: h.healthyNodes || h.HealthyNodes || [], - unhealthy: h.unHealthyNodes || h.unhealthyNodes || h.UnHealthyNodes || [], - all: h.allNodes || h.AllNodes || [], - }; -} - -function nlbRenderHealth(idx, r, polling) { - const div = document.getElementById(`nlb-health-${idx}`); - if (!div) return; - const esc = (v) => window.escapeHtml(String(v == null ? '' : v)); - const note = polling ? ' 🔄 auto-updating every 3s…' : ''; - div.innerHTML = `
    -
    ✅ Healthy (${r.healthy.length}): ${r.healthy.map(esc).join(', ') || '—'}
    -
    ❌ Unhealthy (${r.unhealthy.length}): ${r.unhealthy.map(esc).join(', ') || '—'}
    -
    Total nodes: ${r.all.length}${note}
    `; -} - -window.nlbCheckHealth = async (nlbId, idx) => { - const c = window._nlbCtx; if (!c) return; - const div = document.getElementById(`nlb-health-${idx}`); - if (div) div.innerHTML = 'Checking…'; - try { nlbRenderHealth(idx, await nlbFetchHealth(c, nlbId), false); } - catch (e) { - if (div) div.innerHTML = `Health check failed: ${window.escapeHtml(e.response?.data?.message || e.message)}`; - } -}; - -// Auto-poll health every 3s until an NLB is ready (all targets healthy), then flip -// its status badge to "active" and stop. TB never re-syncs NLB state from the CSP, -// so "all targets healthy" (a live Spider signal) is the practical readiness stop -// condition. Capped so a genuinely unhealthy target cannot poll forever. -window._nlbPollers = window._nlbPollers || {}; -function stopNlbPoll(idx) { - if (window._nlbPollers[idx]) { clearInterval(window._nlbPollers[idx]); delete window._nlbPollers[idx]; } -} -function stopAllNlbPolls() { Object.keys(window._nlbPollers || {}).forEach(stopNlbPoll); } -function markNlbActive(idx) { - const el = document.getElementById(`nlb-status-${idx}`); - if (el) el.innerHTML = nlbStatusBadge('active'); -} -function startNlbHealthPoll(nlbId, idx) { - const c = window._nlbCtx; if (!c) return; - stopNlbPoll(idx); - let attempts = 0; - const MAX = 60; // ~3 min at 3s - const tick = async () => { - attempts++; - if (!document.getElementById(`nlb-health-${idx}`)) { stopNlbPoll(idx); return; } // modal closed - let r = null; - try { r = await nlbFetchHealth(c, nlbId); } catch (e) { /* transient — keep polling */ } - if (r) { - const ready = r.all.length > 0 && r.unhealthy.length === 0; - nlbRenderHealth(idx, r, !ready); - if (ready) { markNlbActive(idx); stopNlbPoll(idx); return; } - } - if (attempts >= MAX) stopNlbPoll(idx); - }; - window._nlbPollers[idx] = setInterval(tick, 3000); - tick(); // immediate first check -} - -window.nlbAddNode = async (nlbId) => { - const c = window._nlbCtx; if (!c) return; - let nodes = []; - try { - const res = await axios({ method: 'get', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}`, auth: { username: c.username, password: c.password } }); - nodes = (res.data && res.data.node || []).map(n => n.id); - } catch (e) { /* ignore */ } - if (!nodes.length) { errorAlert("No nodes available in this Infra"); return; } - const { value: nodeId } = await Swal.fire({ - title: 'Add target node to NLB', input: 'select', - inputOptions: Object.fromEntries(nodes.map(n => [n, n])), inputPlaceholder: 'Select a node', - showCancelButton: true, confirmButtonText: 'Add', - }); - if (!nodeId) return; - const s = addSpinnerTask("Adding node to NLB"); - try { - await axios({ method: 'post', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/nlb/${nlbId}/node`, headers: { 'Content-Type': 'application/json' }, data: { targetGroup: { nodes: [nodeId] } }, auth: { username: c.username, password: c.password } }); - successAlert("Node added to NLB"); - window.nlbManagerRefresh(); - } catch (e) { errorAlert("Add node failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(s); } -}; - -window.nlbRemoveNode = async (nlbId, nodeId) => { - const c = window._nlbCtx; if (!c) return; - const r = await Swal.fire({ icon: 'warning', title: 'Remove node from NLB?', text: nodeId, showCancelButton: true, confirmButtonText: 'Remove', confirmButtonColor: '#d33' }); - if (!r.isConfirmed) return; - const s = addSpinnerTask("Removing node from NLB"); - try { - await axios({ method: 'delete', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/nlb/${nlbId}/node`, headers: { 'Content-Type': 'application/json' }, data: { targetGroup: { nodes: [nodeId] } }, auth: { username: c.username, password: c.password } }); - successAlert("Node removed from NLB"); - window.nlbManagerRefresh(); - } catch (e) { errorAlert("Remove node failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(s); } -}; - -// ============================================================================ -// Site-to-site VPN — rich manager: list / status / sites / health / create / -// delete / reconcile. VPN status is derived by TB (from conditions), so it is -// populated; a per-VPN "Refresh from CSP" re-syncs live via Terrarium. -// ============================================================================ -function vpnStatusBadge(status) { - const s = (status || '').toLowerCase(); - let bg = '#6c757d'; - if (s.includes('available')) bg = '#28a745'; - else if (s.includes('creat') || s.includes('regist')) bg = '#f0ad4e'; - else if (s.includes('delet') || s.includes('deregist')) bg = '#e67e22'; - else if (s.includes('fail') || s.includes('error')) bg = '#dc3545'; - return `${window.escapeHtml(status || 'Unknown')}`; -} - -// Best-effort scan of the raw terrarium cspResourceDetail for gateway public IPs. -function vpnGatewayIps(resourceDetails) { - const out = []; - const scan = (o) => { - if (!o || typeof o !== 'object') return; - Object.keys(o).forEach((k) => { - const val = o[k]; - if (typeof val === 'string' && /ip/i.test(k) && /^\d{1,3}(\.\d{1,3}){3}$/.test(val)) out.push(val); - else if (val && typeof val === 'object') scan(val); - }); - }; - (resourceDetails || []).forEach((r) => { - const d = r.cspResourceDetail; - if (Array.isArray(d)) d.forEach(scan); else scan(d); - }); - return [...new Set(out)]; -} - -async function manageVPN(opts) { - const config = getConfig(); - const { hostname, port, username, password } = config; - const namespace = configNamespace; - const infraid = (opts && opts.infraId) || infraidElement.value; - const preselectVpnId = (opts && opts.preselectVpnId) || ''; - if (!namespace) { errorAlert("Please select a namespace first"); return; } - if (!infraid) { errorAlert("Please select an Infra first"); return; } - window._vpnCtx = { hostname, port, username, password, namespace, infraid }; - const base = `${tbApiBase()}/ns/${namespace}/infra/${infraid}`; - const esc = (v) => window.escapeHtml(String(v == null ? '' : v)); - - const spinnerId = addSpinnerTask("Loading VPNs"); - let vpns = [], sites = {}; - try { - const [vpnRes, siteRes] = await Promise.all([ - axios({ method: 'get', url: `${base}/vpn?option=InfoList`, auth: { username, password } }).catch(() => ({ data: {} })), - axios({ method: 'get', url: `${base}/site`, auth: { username, password } }).catch(() => ({ data: {} })), - ]); - vpns = (vpnRes.data && vpnRes.data.vpnInfoList) || []; - sites = (siteRes.data && siteRes.data.sites) || {}; - } finally { removeSpinnerTask(spinnerId); } - - const activeIdx = Math.max(0, vpns.findIndex(v => v.id === preselectVpnId)); - - const tabBtn = (id, target, label, color, active) => - ``; - - const tabButtons = vpns.map((v, idx) => tabBtn(`vpn-tab-${idx}`, `#vpn-content-${idx}`, esc(v.name || v.id), '#0d6efd', idx === activeIdx)).join('') - + tabBtn('vpn-tab-create', '#vpn-content-create', '➕ Create', '#28a745', vpns.length === 0); - - const vpnPanes = vpns.map((v, idx) => { - const sitesHtml = (v.vpnSites || []).map((site) => { - const cc = site.connectionConfig || {}; - const region = (cc.regionDetail && cc.regionDetail.regionName) || cc.regionZoneInfoName || ''; - const provider = cc.providerName || ''; - const rd = site.resourceDetails || []; - const ips = vpnGatewayIps(rd); - return `
    -
    ${esc(site.connectionName)} ${esc(provider)} ${esc(region)}
    - ${ips.length ? `
    Gateway IP: ${ips.map(esc).join(', ')}
    ` : ''} -
    ${rd.map(r => `${esc(r.cspResourceId || r.cspResourceName || '')}${r.status ? ' (' + esc(r.status) + ')' : ''}`).join('
    ') || '—'}
    -
    `; - }).join(''); - return `
    - - - - -
    `; - }).join(''); - - const siteOptions = []; - Object.keys(sites).forEach((csp) => (sites[csp] || []).forEach((s) => siteOptions.push({ csp, ...s }))); - window._vpnSiteOptions = siteOptions; - const siteOptHtml = siteOptions.map((s, i) => ``).join(''); - const createPane = `
    - -
    `; - - Swal.fire({ - title: `🔒 Site-to-site VPN — ${esc(infraid)}`, - width: 920, - html: `${POPUP_STYLES}`, - showConfirmButton: false, showCancelButton: true, cancelButtonText: 'Close', - didOpen: () => { - const btns = document.querySelectorAll('#vpnTabs button[data-bs-toggle="tab"]'); - btns.forEach(b => b.addEventListener('click', function (e) { - e.preventDefault(); - btns.forEach(x => { x.classList.remove('active'); x.style.color = '#6c757d'; x.style.borderBottomColor = 'transparent'; }); - document.querySelectorAll('#vpnTabContent .tab-pane').forEach(p => p.classList.remove('show', 'active')); - const color = this.id === 'vpn-tab-create' ? '#28a745' : '#0d6efd'; - this.classList.add('active'); this.style.color = color; this.style.borderBottomColor = color; - const t = document.querySelector(this.getAttribute('data-bs-target')); - if (t) t.classList.add('show', 'active'); - })); - }, - }); -} -window.manageVPN = manageVPN; -window.vpnManagerRefresh = () => { Swal.close(); setTimeout(manageVPN, 150); }; - -function vpnSiteBody(opt, asn) { - const csp = (opt.csp || '').toLowerCase(); - const prop = {}; - const inner = {}; - if (asn) inner.bgpAsn = String(asn); - if (csp === 'azure' && opt.gatewaySubnetCidr) inner.gatewaySubnetCidr = opt.gatewaySubnetCidr; - prop[csp] = inner; - return { vNetId: opt.vnet, cspSpecificProperty: prop }; -} - -window.vpnCreate = async () => { - const c = window._vpnCtx; if (!c) return; - const name = document.getElementById('vpn-c-name').value.trim(); - const i1 = document.getElementById('vpn-c-site1').value, i2 = document.getElementById('vpn-c-site2').value; - const asn1 = document.getElementById('vpn-c-asn1').value.trim(), asn2 = document.getElementById('vpn-c-asn2').value.trim(); - if (!name) { errorAlert("Enter a VPN name"); return; } - if (i1 === '' || i2 === '' || i1 === i2) { errorAlert("Select two different sites"); return; } - const opts = window._vpnSiteOptions || []; - const s1 = opts[Number(i1)], s2 = opts[Number(i2)]; - if (![s1.csp, s2.csp].map(x => (x || '').toLowerCase()).includes('aws')) { errorAlert("One site must be AWS"); return; } - const body = { name, site1: vpnSiteBody(s1, asn1), site2: vpnSiteBody(s2, asn2) }; - Swal.close(); - const sp = addSpinnerTask(`Creating VPN '${name}' (15–45 min)…`); - try { - await axios({ method: 'post', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/vpn`, headers: { 'Content-Type': 'application/json' }, data: body, auth: { username: c.username, password: c.password }, timeout: 3600000 }); - successAlert(`VPN '${name}' created`); - getInfra(); - } catch (e) { errorAlert("Create VPN failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(sp); } -}; - -window.vpnDelete = async (vpnId, reconcile) => { - const c = window._vpnCtx; if (!c) return; - const r = await Swal.fire({ icon: 'warning', title: reconcile ? 'Reconcile VPN metadata?' : 'Delete VPN?', text: vpnId, showCancelButton: true, confirmButtonText: reconcile ? 'Reconcile' : 'Delete', confirmButtonColor: '#d33' }); - if (!r.isConfirmed) return; - const sp = addSpinnerTask(reconcile ? "Reconciling VPN…" : "Deleting VPN (may take minutes)…"); - try { - const url = `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/vpn/${vpnId}${reconcile ? '?option=reconcile' : ''}`; - await axios({ method: 'delete', url, auth: { username: c.username, password: c.password }, timeout: 3600000 }); - successAlert(reconcile ? "VPN reconciled" : "VPN deleted"); - getInfra(); - window.vpnManagerRefresh(); - } catch (e) { errorAlert("VPN delete failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(sp); } -}; - -window.vpnHealth = async (vpnId, idx) => { - const c = window._vpnCtx; if (!c) return; - const div = document.getElementById(`vpn-health-${idx}`); - if (div) div.innerHTML = 'Running ping test through the tunnel…'; - try { - const res = await axios({ method: 'post', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/vpn/${vpnId}/health`, headers: { 'Content-Type': 'application/json' }, data: {}, auth: { username: c.username, password: c.password }, timeout: 300000 }); - const h = res.data || {}; - const e2 = (v) => window.escapeHtml(String(v == null ? '' : v)); - const rows = (h.results || []).map(r => `
    ${r.reachable ? '✅' : '❌'} ${e2(r.direction)}: ${e2(r.message || '')}
    `).join(''); - if (div) div.innerHTML = `
    ${h.reachable ? '✅ Reachable' : '❌ Not reachable'} — ${e2(h.message || '')}
    ${rows}
    `; - } catch (e) { - if (div) div.innerHTML = `Health check failed: ${window.escapeHtml(e.response?.data?.message || e.message)}`; - } -}; - -window.vpnRefresh = async (vpnId) => { - const c = window._vpnCtx; if (!c) return; - const sp = addSpinnerTask("Refreshing VPN from CSP…"); - try { - await axios({ method: 'get', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.infraid}/vpn/${vpnId}?refresh=true`, auth: { username: c.username, password: c.password }, timeout: 120000 }); - window.vpnManagerRefresh(); - } catch (e) { errorAlert("Refresh failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(sp); } -}; - -// ============================================================================ -// Global NLB (MCNLB, multi-cloud HAProxy) — manager. -// IMPORTANT: an MCNLB has no dedicated resource endpoints; it is deployed as its -// OWN Infra named "{targetInfraId}-nlb" (see nlbPostfix) and is created/queried/ -// deleted as an Infra. So this manager works against that host Infra: create it, -// view the HAProxy host + stats, or delete it. -// ============================================================================ -async function manageMCNLB(opts) { - const config = getConfig(); - const { hostname, port, username, password } = config; - const namespace = configNamespace; - let infraid = (opts && opts.infraId) || infraidElement.value; - if (!namespace) { errorAlert("Please select a namespace first"); return; } - if (!infraid) { errorAlert("Please select an Infra first"); return; } - // Accept being opened from either the target Infra or its "-nlb" host. - const targetInfraId = infraid.endsWith('-nlb') ? infraid.slice(0, -4) : infraid; - const hostInfraId = `${targetInfraId}-nlb`; - window._mcnlbCtx = { hostname, port, username, password, namespace, targetInfraId, hostInfraId }; - const esc = (v) => window.escapeHtml(String(v == null ? '' : v)); - - const spinnerId = addSpinnerTask("Loading Global NLB"); - let host = null; - try { - const res = await axios({ method: 'get', url: `${tbApiBase()}/ns/${namespace}/infra/${hostInfraId}`, auth: { username, password } }).catch(() => null); - if (res && res.data && (res.data.id || (res.data.node && res.data.node.length))) host = res.data; - } finally { removeSpinnerTask(spinnerId); } - - let bodyHtml; - if (host) { - const nodes = host.node || []; - const nodeRows = nodes.map((n) => { - const ip = n.publicIP || ''; - const stats = ip ? `🔗 HAProxy stats` : ''; - const running = /running/i.test(n.status || ''); - return `
    - ${esc(n.id)} ${esc(n.status || '')} -
    public: ${esc(n.publicIP || '—')} · private: ${esc(n.privateIP || '—')}${stats}
    -
    `; - }).join(''); - bodyHtml = ` - - - `; - } else { - bodyHtml = ` - `; - } - - Swal.fire({ - title: `🌐 Global NLB — ${esc(targetInfraId)}`, - width: 760, - html: `${POPUP_STYLES}`, - showConfirmButton: false, showCancelButton: true, cancelButtonText: 'Close', - }); -} -window.manageMCNLB = manageMCNLB; -window.mcnlbManagerRefresh = () => { Swal.close(); setTimeout(manageMCNLB, 200); }; - -window.mcnlbCreate = async () => { - const c = window._mcnlbCtx; if (!c) return; - const proto = document.getElementById('mcnlb-c-proto').value; - let p = parseInt(document.getElementById('mcnlb-c-port').value, 10); - if (isNaN(p) || p <= 0) p = 80; - const body = { - type: "PUBLIC", scope: "GLOBAL", - listener: { Protocol: proto, Port: `${p}` }, - targetGroup: { Protocol: proto, Port: `${p}` }, - HealthChecker: { Interval: 10, Timeout: 10, Threshold: 3 }, - }; - Swal.close(); - const sp = addSpinnerTask("Creating Global NLB (VM cluster + HAProxy)…"); - try { - await axios({ method: 'post', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.targetInfraId}/mcSwNlb`, headers: { 'Content-Type': 'application/json' }, data: body, auth: { username: c.username, password: c.password }, timeout: 3600000 }); - successAlert("Global NLB created"); - getInfra(); - } catch (e) { errorAlert("Create Global NLB failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(sp); } -}; - -window.mcnlbDelete = async () => { - const c = window._mcnlbCtx; if (!c) return; - const r = await Swal.fire({ icon: 'warning', title: 'Delete Global NLB?', html: `Deletes the host Infra ${window.escapeHtml(c.hostInfraId)} (terminates its VMs).`, showCancelButton: true, confirmButtonText: 'Delete', confirmButtonColor: '#d33' }); - if (!r.isConfirmed) return; - const sp = addSpinnerTask("Deleting Global NLB host…"); - try { - await axios({ method: 'delete', url: `${tbApiBase()}/ns/${c.namespace}/infra/${c.hostInfraId}?option=terminate`, auth: { username: c.username, password: c.password }, timeout: 1800000 }); - successAlert("Global NLB deleted"); - getInfra(); - window.mcnlbManagerRefresh(); - } catch (e) { errorAlert("Delete Global NLB failed: " + (e.response?.data?.message || e.message)); } - finally { removeSpinnerTask(sp); } -}; - -// Function to execute selected NLB action and close SweetAlert -function executeNLBAction(action) { - Swal.close(); // Close the current SweetAlert - - switch(action) { - case 'addGlobal': - AddMcNLB(); - break; - case 'addRegional': - AddNLB(); - break; - case 'delete': - DelNLB(); - break; - default: - console.log('Unknown NLB action:', action); - } -} -window.executeNLBAction = executeNLBAction; -window.manageNLB = manageNLB; - -// function for sleep -function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); -} - -var defaultRemoteCommand = []; -defaultRemoteCommand.push("hostname -I"); -defaultRemoteCommand.push("echo $SSH_CLIENT"); -defaultRemoteCommand.push(""); - -// Recommended timeout (minutes) for the selected usecase. A few of them run far past -// the 30-minute popup default -- a DevStack install takes 20-40 minutes, and hitting -// the timeout mid-install leaves the node in a half-configured state. -// 0 means "leave the popup value alone". -var defaultRemoteCommandTimeout = 0; - -/** - * Sets default remote commands based on application type - * - * @param {string} appName - The name of the application to configure commands for - * @returns {void} - Modifies the defaultRemoteCommand array directly - */ -function setDefaultRemoteCommandsByApp(appName) { - // Reset array to ensure clean state (prevent leftover elements from previous selections) - defaultRemoteCommand.length = 0; - defaultRemoteCommandTimeout = 0; - - switch (appName) { - case "Xonotic": - defaultRemoteCommand[0] = "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/xonotic/startServer.sh; chmod +x ~/startServer.sh"; - defaultRemoteCommand[1] = "sudo ~/startServer.sh " + "Cloud-Barista-$$Func(GetInfraId())" + " 26000" + " 8" + " 8"; - defaultRemoteCommand[2] = "echo '$$Func(GetPublicIP(target=this,postfix=:26000))'"; - break; - case "ELK": - defaultRemoteCommand[0] = "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/elastic-stack/startELK.sh"; - defaultRemoteCommand[1] = "chmod +x ~/startServer.sh"; - defaultRemoteCommand[2] = "sudo ~/startServer.sh "; - break; - case "vLLM": - // Install vLLM on the GPU node - // --hf-token: required for gated models (meta-llama/*, mistralai/*); leave blank for public models - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deployvLLM.sh -o /tmp/deployvLLM.sh && HF_TOKEN=\"\"; if [ -n \"$HF_TOKEN\" ]; then bash /tmp/deployvLLM.sh --hf-token \"$HF_TOKEN\"; else bash /tmp/deployvLLM.sh; fi"; - defaultRemoteCommand[1] = "echo 'vLLM installed. Next step: Serve LLM Model.'"; - defaultRemoteCommand[2] = ""; - break; - case "vLLMServe": - // Serve an LLM model with vLLM (must install vLLM first via Deploy vLLM step) - // Optional flags (hf-token, gpu-util, ctx-len) are only passed when non-empty - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/servevLLM.sh -o /tmp/servevLLM.sh && MODEL=\"\"; HF_TOKEN=\"\"; GPU_UTIL=\"\"; CTX_LEN=\"\"; ARGS=(--model \"$MODEL\"); [ -n \"$HF_TOKEN\" ] && ARGS+=(--hf-token \"$HF_TOKEN\"); [ -n \"$GPU_UTIL\" ] && ARGS+=(--gpu-util \"$GPU_UTIL\"); [ -n \"$CTX_LEN\" ] && ARGS+=(--ctx-len \"$CTX_LEN\"); bash /tmp/servevLLM.sh \"${ARGS[@]}\""; - defaultRemoteCommand[1] = "echo 'API: $$Func(GetPublicIP(target=this, prefix=http://, postfix=:8000/v1))'"; - defaultRemoteCommand[2] = ""; - break; - case "Nvidia": - // Install GPU driver — auto-detects NVIDIA or AMD at runtime - // Note: System will automatically reboot after installation - // Use download-then-execute pattern (not curl|bash) to prevent truncated script execution - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/installGpuDriver.sh -o /tmp/installGpuDriver.sh && bash /tmp/installGpuDriver.sh"; - defaultRemoteCommand[1] = "echo '[INFO] GPU driver installation started (NVIDIA or AMD auto-detected). System will reboot automatically in ~5 seconds after completion.'"; - defaultRemoteCommand[2] = "echo '[INFO] After reboot, verify with: nvidia-smi (NVIDIA) or rocm-smi (AMD)'"; - break; - case "NvidiaVgpu": - // Install NVIDIA driver for fractional/vGPU instances (e.g., AWS g6f, Azure NCas fractional) - // --vgpu flag forces proprietary driver (open kernel modules do NOT support vGPU) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/installGpuDriver.sh -o /tmp/installGpuDriver.sh && bash /tmp/installGpuDriver.sh --vgpu"; - defaultRemoteCommand[1] = "echo '[INFO] NVIDIA GPU driver (vGPU/proprietary) installation started. System will reboot automatically.'"; - defaultRemoteCommand[2] = "echo '[INFO] After reboot, verify with: nvidia-smi'"; - break; - case "RebootVM": - // Reboot Node - useful after GPU driver installation - defaultRemoteCommand[0] = "sudo reboot"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Nvidia-Status": - // Check GPU driver status — tries NVIDIA first, then AMD ROCm - // rocm-smi exits with code 1 on some ROCm versions due to a Python 'violation' attribute bug - // even when GPU info is shown correctly — suppress with || true - defaultRemoteCommand[0] = "if command -v nvidia-smi &>/dev/null; then nvidia-smi; elif command -v rocm-smi &>/dev/null; then rocm-smi || true; else echo '[WARN] No GPU CLI tool found (nvidia-smi / rocm-smi). Is the driver installed and system rebooted?'; sudo lspci | grep -i -E 'vga|3d|display' || true; fi"; - // NVIDIA: show Fabric Manager on multi-GPU NVSwitch systems; AMD: show amd-smi monitor - defaultRemoteCommand[1] = "if command -v nvidia-smi &>/dev/null; then GPU_COUNT=$(nvidia-smi -L 2>/dev/null | grep -c '^GPU') || GPU_COUNT=0; if [ \"$GPU_COUNT\" -ge 4 ]; then echo '=== Fabric Manager (required for NVSwitch multi-GPU) ==='; systemctl is-active nvidia-fabricmanager 2>/dev/null && echo 'Status: RUNNING' || echo 'Status: NOT RUNNING (multi-GPU may not work!)'; echo '=== nvidia-persistenced ==='; systemctl is-active nvidia-persistenced 2>/dev/null && echo 'Status: RUNNING' || echo 'Status: not running'; fi; elif command -v amd-smi &>/dev/null; then amd-smi monitor || true; fi"; - defaultRemoteCommand[2] = ""; - break; - case "Netdata": - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deployNetdataMonitor.sh | sh"; - defaultRemoteCommand[1] = "echo '$$Func(GetPublicIP(target=this, prefix=http://, postfix=:19999))'"; - defaultRemoteCommand[2] = ""; - break; - case "Netdata-Status": - defaultRemoteCommand[0] = "sudo systemctl status netdata --no-pager"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Setup-CrossNAT": - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/setup-cross-cloud-nat.sh -o ~/setup-cross-cloud-nat.sh && chmod +x ~/setup-cross-cloud-nat.sh"; - defaultRemoteCommand[1] = "sudo ~/setup-cross-cloud-nat.sh pub=$$Func(GetPublicIPs(separator=,)) priv=$$Func(GetPrivateIPs(separator=,))"; - defaultRemoteCommand[2] = ""; - break; - case "Setup-WireGuard": - // WireGuard mesh VPN setup - run on all nodes with same parameters - // Format: public_ip:wireguard_ip pairs (e.g., 54.1.1.1:10.200.0.1,35.2.2.2:10.200.0.2) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/k8s/setup-wireguard-mesh.sh -o ~/setup-wireguard-mesh.sh && chmod +x ~/setup-wireguard-mesh.sh"; - defaultRemoteCommand[1] = "sudo ~/setup-wireguard-mesh.sh --nodes \"\""; - defaultRemoteCommand[2] = ""; - break; - case "PortForward-Add": - // Forward an external port on this Node to a target IP:port (e.g., OpenStack floating IP) - // Fill in FLOATING_IP, EXT_PORT, TARGET_PORT in the parameter fields below the command - defaultRemoteCommand[0] = "IFACE=$(ip route show 0.0.0.0/0 | grep -oE \"dev [^ ]+\" | cut -c5-) && sudo sysctl -w net.ipv4.ip_forward=1 && sudo iptables -t nat -A PREROUTING -i $IFACE -p tcp --dport -j DNAT --to-destination : && sudo iptables -A FORWARD -p tcp -d --dport -j ACCEPT && sudo iptables -t nat -A POSTROUTING -j MASQUERADE && echo \"✅ Port forwarding :: activated\" && echo \"🌐 Access: http://$$Func(GetPublicIP(target=this)):\""; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "PortForward-List": - // List current port forwarding (DNAT) rules with line numbers - defaultRemoteCommand[0] = "echo '=== PREROUTING (DNAT) ===' && sudo iptables -t nat -L PREROUTING -n --line-numbers -v"; - defaultRemoteCommand[1] = "echo '=== FORWARD ===' && sudo iptables -L FORWARD -n --line-numbers -v"; - defaultRemoteCommand[2] = ""; - break; - case "PortForward-Del": - // Delete a port forwarding rule by line number (run PortForward-List first to see line numbers) - defaultRemoteCommand[0] = "sudo iptables -t nat -D PREROUTING && echo '✅ PREROUTING rule # deleted'"; - defaultRemoteCommand[1] = "sudo iptables -t nat -L PREROUTING -n --line-numbers"; - defaultRemoteCommand[2] = ""; - break; - case "PortForward-Save": - // Persist iptables rules across reboots (Debian/Ubuntu) - defaultRemoteCommand[0] = "sudo apt-get install -y iptables-persistent && sudo netfilter-persistent save && echo '✅ iptables rules saved (will persist across reboots)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Ollama": - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deployOllama.sh | sh"; - defaultRemoteCommand[1] = "echo '$$Func(GetPublicIP(target=this, prefix=http://, postfix=:3000))'"; - defaultRemoteCommand[2] = ""; - break; - case "OllamaPull": - defaultRemoteCommand[0] = "OLLAMA_HOST=0.0.0.0:3000 ollama pull $$Func(AssignTask(task=''))"; - defaultRemoteCommand[1] = "echo '$$Func(GetPublicIP(target=this, prefix=http://, postfix=:3000))'"; - defaultRemoteCommand[2] = "OLLAMA_HOST=0.0.0.0:3000 ollama list"; - break; - case "OpenWebUI": - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deployOpenWebUI.sh | bash -s -- ollama \"$$Func(GetPublicIPs(target=this, label='accelerator=gpu', separator=;, prefix=http://, postfix=:3000))\""; - defaultRemoteCommand[1] = "echo 'Access to $$Func(GetPublicIP(target=this, prefix=http://))'"; - defaultRemoteCommand[2] = ""; - break; - case "OpenWebUI-vLLM": - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deployOpenWebUI.sh | bash -s -- vllm \"$$Func(GetPublicIPs(target=this, label='accelerator=gpu', separator=;, prefix=http://, postfix=:8000/v1))\""; - defaultRemoteCommand[1] = "echo 'Access to $$Func(GetPublicIP(target=this, prefix=http://))'"; - defaultRemoteCommand[2] = ""; - break; - case "TelemetrySensor": - // Setup GPU telemetry sensor (Node Exporter + GPU Exporter + Telegraf) - // Run on each GPU Node to expose aggregated metrics on port 9101 - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/telemetry/setup_gpu_sensor.sh | bash"; - defaultRemoteCommand[1] = "echo 'Telegraf gateway: $$Func(GetPublicIP(target=this)):9101'"; - defaultRemoteCommand[2] = ""; - break; - case "TelemetryMonitor": - // Setup central monitoring (Prometheus + Grafana) on a monitoring Node - // Uses $$Func(GetPublicIPs(label='accelerator=gpu')) to auto-resolve GPU Node IPs - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/telemetry/setup_monitoring.sh | bash -s -- $$Func(GetPublicIPs(separator=' ', label='accelerator=gpu'))"; - defaultRemoteCommand[1] = "echo 'Prometheus: $$Func(GetPublicIP(target=this, prefix=http://, postfix=:9090/targets))'"; - defaultRemoteCommand[2] = "echo 'Grafana: $$Func(GetPublicIP(target=this, prefix=http://, postfix=:3000))'"; - break; - case "TelemetryExport": - case "BenchmarkTelemetryExport": - // Export Prometheus metrics to CSV (run on the Node that hosts Prometheus, e.g., monitoring Node or benchmark manager) - // Uses $$Func(GetPublicIPs(label='accelerator=gpu')) to auto-resolve GPU Node IPs - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/telemetry/export_metrics.sh | bash -s -- --minutes --ips \"$$Func(GetPublicIPs(separator=',', label='accelerator=gpu'))\""; - defaultRemoteCommand[1] = "ls -la ./metrics_export/"; - defaultRemoteCommand[2] = ""; - break; - case "BenchmarkTarget": - // All-in-one setup for benchmark target GPU VMs: vLLM + Model Serving + Telemetry Sensor - // Assumes GPU driver is already installed (use 'Install GPU Driver' step first) - // --hf-token: only passed when non-empty (required for gated models; omit for public models) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/telemetry/setupBenchmarkTarget.sh -o /tmp/setupBenchmarkTarget.sh && MODEL=\"\"; HF_TOKEN=\"\"; ARGS=(--model \"$MODEL\"); [ -n \"$HF_TOKEN\" ] && ARGS+=(--hf-token \"$HF_TOKEN\"); bash /tmp/setupBenchmarkTarget.sh \"${ARGS[@]}\""; - defaultRemoteCommand[1] = "echo 'API: $$Func(GetPublicIP(target=this, prefix=http://, postfix=:8000/v1))'"; - defaultRemoteCommand[2] = "echo 'Metrics: $$Func(GetPublicIP(target=this, prefix=http://, postfix=:9101/metrics))'"; - break; - case "BenchmarkManager": - // All-in-one setup for benchmark manager Node: Prometheus + Grafana + GuideLLM + Export Tools - // Uses $$Func(GetPublicIPs(label='accelerator=gpu')) to auto-resolve GPU Node IPs - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/telemetry/setupBenchmarkManager.sh | bash -s -- $$Func(GetPublicIPs(separator=' ', label='accelerator=gpu'))"; - defaultRemoteCommand[1] = "echo 'Prometheus: $$Func(GetPublicIP(target=this, prefix=http://, postfix=:9090/targets))'"; - defaultRemoteCommand[2] = "echo 'Grafana: $$Func(GetPublicIP(target=this, prefix=http://, postfix=:3000))'"; - break; - case "RunBenchmark": - // Run GuideLLM benchmark against target GPU Nodes (runs on benchmark manager Node) - // run_guidellm.sh supports multiple IPs natively: --ip ... - // Uses $$Func(GetPublicIPs(label='accelerator=gpu')) to auto-resolve GPU Node IPs - // Optional flags (rate, data, data-column-mapper) are only passed when non-empty - // COLMAP uses single-quote assignment to safely embed JSON double quotes - // --ids pairs Node names with the IPs (same order) so results are labeled by Node, not IP - // --concurrency-sweep runs the concurrent profile once per step; each step lasts MAX_SECONDS - defaultRemoteCommandTimeout = 120; - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/telemetry/run_guidellm.sh -o /tmp/run_guidellm.sh && PROFILE=\"\"; MAX_SECONDS=\"\"; RATE=\"\"; SWEEP=\"\"; DATA=\"\"; COLMAP=''; ARGS=(--ip $$Func(GetPublicIPs(separator=' ', label='accelerator=gpu')) --ids $$Func(GetNodeIds(separator=' ', label='accelerator=gpu')) --profile \"$PROFILE\" --max-seconds \"$MAX_SECONDS\"); [ -n \"$RATE\" ] && ARGS+=(--rate \"$RATE\"); [ -n \"$SWEEP\" ] && ARGS+=(--concurrency-sweep \"$SWEEP\"); [ -n \"$DATA\" ] && ARGS+=(--data \"$DATA\"); [ -n \"$COLMAP\" ] && ARGS+=(--data-column-mapper \"$COLMAP\"); bash /tmp/run_guidellm.sh \"${ARGS[@]}\""; - defaultRemoteCommand[1] = "ls -la ~/guidellm_bench/bench_*_summary.csv"; - defaultRemoteCommand[2] = ""; - break; - case "HermesAgent": - // All-in-one Hermes Agent deployment: vLLM + Hermes Gateway/Dashboard + nginx reverse proxy - // Dashboard accessible via nginx on port 9120 after deployment - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deployHermesAgent.sh -o /tmp/deployHermesAgent.sh && VLLM_VER=\"\"; bash /tmp/deployHermesAgent.sh --run-as-user cb-user ${VLLM_VER:+--vllm-version \"$VLLM_VER\"} --model \"\" --ctx-len \"\" --hermes-api-key \"\" --hf-token \"\" --discord-token \"\" --discord-home-channel \"\" --discord-home-channel-name \"\" --ntfy-topic \"\" --tavily-api-key \"\""; - defaultRemoteCommand[1] = "echo 'Hermes Dashboard: $$Func(GetPublicIP(target=this, prefix=http://, postfix=:9120))'"; - defaultRemoteCommand[2] = ""; - break; - case "RayHead-Deploy": - defaultRemoteCommand[0] = "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/ray/ray-head-setup.sh"; - defaultRemoteCommand[1] = "chmod +x ~/ray-head-setup.sh"; - defaultRemoteCommand[2] = "~/ray-head-setup.sh -i $$Func(GetPublicIP(target=this))"; - break; - case "RayWorker-Deploy": - defaultRemoteCommand[0] = "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/ray/ray-worker-setup.sh"; - defaultRemoteCommand[1] = "chmod +x ~/ray-worker-setup.sh"; - defaultRemoteCommand[2] = "~/ray-worker-setup.sh -i $$Func(GetPublicIP(target=this)) -h $$Func(GetPublicIP(target=mc-ray.g1-1))"; - break; - case "K8sControlPlane-Deploy": - // Deploys K8s control plane with auto-detected IPs - // Output includes: [K8S_JOIN_COMMAND], [K8S_KUBECONFIG_BASE64] for easy parsing - defaultRemoteCommand[0] = "CNI=$(echo \"\" | tr 'A-Z' 'a-z' | xargs); [ -z \"$CNI\" ] && CNI=flannel; curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/k8s/k8s-control-plane-setup.sh | bash -s -- --cni \"$CNI\""; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sWorker-Deploy": - // Deploys K8s worker node - // IMPORTANT: Replace with actual join command from control plane - // Get join command: [K8S_JOIN_COMMAND] section in control plane output - // Example: kubeadm join 10.0.0.1:6443 --token abc.123 --discovery-token-ca-cert-hash sha256:xyz - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/k8s/k8s-worker-setup.sh | bash -s -- -j \"\""; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sGetKubeconfig": - // Get kubeconfig from control plane for external kubectl access - // Output: [K8S_KUBECONFIG_BASE64] section contains base64-encoded kubeconfig - defaultRemoteCommand[0] = "echo '[K8S_KUBECONFIG_BASE64]' && base64 -w 0 ~/kubeconfig-external.yaml && echo ''"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sGetKubeconfigExternal": - // Kubeconfig for an address the cluster never saw (nested cloud / NAT / port-forward). - // kubeadm bakes the SAN list at init time, so an address added later needs the - // apiserver cert re-issued; the CA is unchanged, so nothing else has to be touched. - defaultRemoteCommand[0] = "ADDR=\"\"; PORT=\"\"; [ -z \"$PORT\" ] && PORT=6443; if [ -z \"$ADDR\" ]; then echo 'ERROR: K8S_EXTERNAL_IP is required'; exit 1; fi; SANS=$(sudo openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -ext subjectAltName | tr ',' '\\n' | sed -n -e 's/.*DNS://p' -e 's/.*IP Address://p' | tr -d ' ' | paste -sd, -); case \",$SANS,\" in *\",$ADDR,\"*) echo \"API server cert already covers $ADDR\";; *) echo \"Re-issuing API server cert with SAN $ADDR ...\"; ADV=$(sudo sed -n 's/.*--advertise-address=\\([0-9.]*\\).*/\\1/p' /etc/kubernetes/manifests/kube-apiserver.yaml | head -1); TS=$(date +%s); sudo mv /etc/kubernetes/pki/apiserver.crt /etc/kubernetes/pki/apiserver.crt.bak.$TS; sudo mv /etc/kubernetes/pki/apiserver.key /etc/kubernetes/pki/apiserver.key.bak.$TS; sudo kubeadm init phase certs apiserver --apiserver-advertise-address \"$ADV\" --apiserver-cert-extra-sans \"$SANS,$ADDR\" || { echo 'ERROR: cert re-issue failed'; exit 1; }; sudo mv /etc/kubernetes/manifests/kube-apiserver.yaml /tmp/kube-apiserver.yaml; sleep 8; sudo mv /tmp/kube-apiserver.yaml /etc/kubernetes/manifests/kube-apiserver.yaml; for i in $(seq 1 60); do kubectl get --raw /healthz >/dev/null 2>&1 && break; sleep 3; done; echo 'API server restarted with the new cert';; esac; KC=$HOME/kubeconfig-external.yaml; cp $HOME/.kube/config $KC; kubectl --kubeconfig=$KC config set-cluster \"$(kubectl --kubeconfig=$KC config view -o jsonpath='{.clusters[0].name}')\" --server=\"https://$ADDR:$PORT\" >/dev/null; chmod 600 $KC; kubectl --kubeconfig=$KC get nodes >/dev/null 2>&1 && echo \"Verified: https://$ADDR:$PORT is reachable from this node\" || echo \"NOTE: https://$ADDR:$PORT not reachable from this node - expected when the address is a NAT/port-forward entry point; forward $PORT to this node's :6443 and open it in the security group\"; echo ''; echo '[K8S_KUBECONFIG_BASE64]'; base64 -w 0 $KC; echo ''; printf '$$FILEPATH[Kubeconfig for %s:%s](%s)\\n' \"$ADDR\" \"$PORT\" \"$KC\""; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sClusterStatus": - // Check K8s cluster status (run on control plane) - defaultRemoteCommand[0] = "echo '=== Nodes ===' && kubectl get nodes -o wide && echo '' && echo '=== Pods ===' && kubectl get pods -A"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sGetJoinCommand": - // Get join command for adding new workers (run on control plane) - // Useful when original token expired (tokens expire after 24h) - defaultRemoteCommand[0] = "echo '[K8S_JOIN_COMMAND]' && sudo kubeadm token create --print-join-command"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sLlmdControlPlane": - // Deploy K8s control plane with llm-d infrastructure components - // Installs: Gateway API CRDs v1.4.0, Inference Extension CRDs v1.3.0, - // LeaderWorkerSet v0.7.0, Istio Gateway, GPU Operator, helmfile, yq - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/k8s/k8s-control-plane-setup.sh | bash -s -- --llm-d"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - - case "LlmdDeploy": - // Deploy llm-d on K8s cluster via helmfile (run on control plane) - // Prerequisites: K8s with --llm-d mode, GPU workers joined - // --hf-token required for gated models (Llama, Mistral, etc.) - // --nodeport 30080 exposes gateway externally via NodePort on Node public IP - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deploy-llm-d.sh | bash -s -- --hf-token --nodeport 30080"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "LlmdDeployWithModel": - // Deploy llm-d with specific model via helmfile (run on control plane) - // --replicas 1 --tp 1 for minimal single-GPU; adjust for multi-GPU - // --nodeport 30080 exposes gateway externally via NodePort on Node public IP - // Replace with your Hugging Face token (required for gated models like Llama, Mistral) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deploy-llm-d.sh | bash -s -- --hf-token --replicas 1 --tp 1 --nodeport 30080 --model $$Func(AssignTask(task='Qwen/Qwen3-32B, meta-llama/Llama-3.3-8B-Instruct, Qwen/Qwen3-8B, mistralai/Mistral-Small-3.2-24B-Instruct-2503'))"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "LlmdCheck": - // Check llm-d prerequisites (run on control plane) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deploy-llm-d.sh | bash -s -- --check"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "LlmdStatus": - // Check llm-d deployment status (run on control plane) - // Shows pods, helm releases, InferencePool, Gateway, and GPU resources - // Uses ; instead of && so each section runs even if previous ones fail - defaultRemoteCommand[0] = "echo '=== Helm Releases ==='; helm list -n llm-d 2>/dev/null || echo ' (helm not installed or namespace llm-d missing)'; echo ''; echo '=== Pods ==='; kubectl get pods -n llm-d -o wide 2>/dev/null || echo ' (no pods found or namespace llm-d missing)'; echo ''; echo '=== InferencePool ==='; kubectl get inferencepool -n llm-d 2>/dev/null || echo ' (InferencePool CRD not installed or resources not found)'; echo ''; echo '=== Gateway ==='; kubectl get gateway -n llm-d 2>/dev/null || echo ' (Gateway CRD not installed or resources not found)'; echo ''; echo '=== Services ==='; kubectl get svc -n llm-d 2>/dev/null || echo ' (services not found or namespace llm-d missing)'; echo ''; echo '=== GPU Resources ==='; kubectl get nodes -o custom-columns='NAME:.metadata.name,GPU:.status.allocatable.nvidia\\.com/gpu' 2>/dev/null || echo ' (no GPU resources detected)'; echo ''; echo '=== External Access ==='; SVC=$(kubectl get svc -n llm-d -o name 2>/dev/null | grep gateway | head -1 | sed 's|service/||'); NP=$(kubectl get svc $SVC -n llm-d -o jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null); NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type==\"ExternalIP\")].address}' 2>/dev/null); if [ -n \"$NP\" ]; then echo \" NodePort: $NP\"; echo \" Endpoint: http://${NODE_IP:-}:$NP\"; else echo ' Service type: ClusterIP (use --nodeport to expose externally)'; fi"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "LlmdUninstall": - // Uninstall llm-d deployment via helmfile destroy (run on control plane) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deploy-llm-d.sh | bash -s -- --uninstall"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sGpuStatus": - // Check GPU status on K8s cluster (run on control plane) - defaultRemoteCommand[0] = "echo '=== GPU Operator Pods ==='; kubectl get pods -n gpu-operator 2>/dev/null || echo ' (GPU Operator not installed or namespace not found)'; echo ''; echo '=== GPU Resources per Node ==='; kubectl get nodes -o custom-columns='NAME:.metadata.name,GPU:.status.allocatable.nvidia\\.com/gpu' 2>/dev/null || echo ' (no GPU resources detected)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sDemoApp": - // Deploy hello-kubernetes demo app (shows pod name per request — great for scaling demo) - // Exposes via NodePort; refresh browser to see load balanced across pods - // $$Func(GetPublicIP(target=this)) is resolved by cb-tumblebug to the VM's actual public IP before SSH - defaultRemoteCommand[0] = "kubectl create deployment hello-kubernetes --image=paulbouwer/hello-kubernetes:1.10 --replicas=2 2>/dev/null || kubectl scale deployment/hello-kubernetes --replicas=2; kubectl expose deployment hello-kubernetes --type=NodePort --port=8080 --name=hello-svc 2>/dev/null || true; kubectl rollout status deployment/hello-kubernetes --timeout=120s; NODE_PORT=$(kubectl get svc hello-svc -o jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null); echo ''; echo '[K8S_DEMO_APP_URL]'; echo \"http://$$Func(GetPublicIP(target=this)):${NODE_PORT}\"; echo '(Refresh browser to see different pod names handling each request)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sScaleApp": - // Scale hello-kubernetes deployment to demonstrate K8s replication - // Set REPLICA_COUNT; after scaling, refresh demo app URL to see different pods respond - defaultRemoteCommand[0] = "kubectl scale deployment hello-kubernetes --replicas=; kubectl rollout status deployment/hello-kubernetes --timeout=60s; echo ''; echo '=== Pods ==='; kubectl get pods -l app=hello-kubernetes -o wide"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sLoadTest": - // Create K8s Batch Job that sends HTTP requests to the demo app service - // Demonstrates K8s Job feature; run K8sScaleApp first for visible load distribution - defaultRemoteCommand[0] = "kubectl delete job http-load-test 2>/dev/null; kubectl create job http-load-test --image=busybox -- sh -c 'i=0; while [ $i -lt 300 ]; do wget -q -O /dev/null http://hello-svc:8080 2>/dev/null; i=$((i+1)); done; echo LOAD_TEST_DONE_300_REQUESTS'; echo 'Job created. Watching pod status...'; sleep 3; kubectl get pods -l job-name=http-load-test -o wide; echo ''; kubectl top pods 2>/dev/null || echo '(install metrics-server to see CPU/memory stats)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sDashboard": - // Install official Kubernetes Dashboard with NodePort access - // Outputs access URL (https) and login token; accept self-signed cert in browser - defaultRemoteCommand[0] = "kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.7.0/aio/deploy/recommended.yaml; kubectl create serviceaccount dashboard-admin -n kubernetes-dashboard 2>/dev/null; kubectl create clusterrolebinding dashboard-admin --clusterrole=cluster-admin --serviceaccount=kubernetes-dashboard:dashboard-admin 2>/dev/null; kubectl patch svc kubernetes-dashboard -n kubernetes-dashboard -p '{\"spec\":{\"type\":\"NodePort\",\"ports\":[{\"port\":443,\"targetPort\":8443,\"nodePort\":30443}]}}'; kubectl rollout status deployment/kubernetes-dashboard -n kubernetes-dashboard --timeout=120s; TOKEN=$(kubectl -n kubernetes-dashboard create token dashboard-admin --duration=24h 2>/dev/null); echo ''; echo '[K8S_DASHBOARD_URL]'; echo 'https://$$Func(GetPublicIP(target=this)):30443'; echo ''; echo '[K8S_DASHBOARD_TOKEN]'; echo \"$TOKEN\"; echo ''; echo '(1) Open URL in browser and accept the self-signed cert warning'; echo '(2) Select Token login and paste the token above'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sPortainer": - // Deploy Portainer CE for K8s visual cluster management and monitoring - // LTS release auto-configures NodePort 30779 (HTTPS) and 30777 (HTTP) - // First login: set admin password (min 12 chars), then select K8s environment - defaultRemoteCommand[0] = "kubectl create namespace portainer 2>/dev/null; kubectl apply -n portainer -f https://downloads.portainer.io/ce-lts/portainer.yaml; kubectl rollout status deployment/portainer -n portainer --timeout=180s; echo ''; echo '[PORTAINER_URL (HTTPS)]'; echo 'https://$$Func(GetPublicIP(target=this)):30779'; echo '[PORTAINER_URL (HTTP)]'; echo 'http://$$Func(GetPublicIP(target=this)):30777'; echo ''; echo '(1) Open URL → set admin password (12+ chars)'; echo '(2) Choose \"Get Started\" → K8s environment is auto-detected'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "K8sHubbleUI": - // Enable the Cilium Hubble UI service map (requires a cluster deployed with CNI=cilium) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/k8s/enable-hubble-ui.sh | bash; echo ''; echo '[HUBBLE_UI_URL]'; echo 'http://$$Func(GetPublicIP(target=this)):30012'; echo '(open SG port 30012 for YOUR IP only)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "WeaveScopeK8s": - // Live cluster topology map (archived project — demo only; UI is unauthenticated with exec controls) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/weavescope/deploy-weavescope-k8s.sh | bash; echo ''; echo '[WEAVESCOPE_URL]'; echo 'http://$$Func(GetPublicIP(target=this)):30040'; echo '(open SG port 30040 for YOUR IP only — UI has no auth and can exec into containers)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeDeploy": - // Deploy KServe stack on K8s: default StorageClass, GPU Operator, cert-manager, KServe (RawDeployment) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/deploy-kserve-stack.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeVllmServe": - // Serve a HuggingFace model via KServe InferenceService (vLLM backend, OpenAI-compatible API) - // Multiple LLMs: run once per model with a unique name/port (llm/30800, llm2/30801, ...) - // On time-sliced (shared) GPUs also set GPU memory fraction, e.g. 0.45 for 2 models per GPU - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/serve-vllm-model.sh -o /tmp/serve-vllm-model.sh && MODEL=\"\"; HF_TOKEN=\"\"; CTX_LEN=\"\"; NAME=\"\"; PORT=\"\"; GPU_UTIL=\"\"; NODE=\"\"; [ -z \"$NAME\" ] && NAME=llm; [ -z \"$PORT\" ] && PORT=30800; ARGS=(--name \"$NAME\" --nodeport \"$PORT\"); [ -n \"$MODEL\" ] && ARGS+=(--model \"$MODEL\"); [ -n \"$HF_TOKEN\" ] && ARGS+=(--hf-token \"$HF_TOKEN\"); [ -n \"$CTX_LEN\" ] && ARGS+=(--ctx-len \"$CTX_LEN\"); [ -n \"$GPU_UTIL\" ] && ARGS+=(--gpu-mem-util \"$GPU_UTIL\"); [ -n \"$NODE\" ] && ARGS+=(--node \"$NODE\"); bash /tmp/serve-vllm-model.sh \"${ARGS[@]}\""; - defaultRemoteCommand[1] = "echo 'External API: http://$$Func(GetPublicIP(target=this)):/openai/v1'"; - defaultRemoteCommand[2] = ""; - break; - case "KServeGpuTimeslice": - // Optional GPU sharing: N pods per physical GPU (no VRAM isolation — set GPU_UTIL per model) - // On mixed-GPU clusters set the node name to slice only that node (e.g. slice L40S, MIG the A100) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/config-gpu-timeslicing.sh -o /tmp/config-gpu-timeslicing.sh && NODE=\"\"; ARGS=(--replicas ); [ -n \"$NODE\" ] && ARGS+=(--node \"$NODE\"); bash /tmp/config-gpu-timeslicing.sh \"${ARGS[@]}\""; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeGpuMig": - // Optional MIG partitioning (A100/H100 only): hardware-isolated slices, each seen as one GPU - // WARNING: applying a profile resets the GPU (model pods on the node restart) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/config-gpu-mig.sh | bash -s -- --profile "; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeStatus": - // Check KServe serving status (run on control plane) - defaultRemoteCommand[0] = "echo '=== InferenceServices ==='; kubectl get isvc 2>/dev/null || echo ' (KServe not installed)'; echo ''; echo '=== Predictor Pods ==='; kubectl get pods -l serving.kserve.io/inferenceservice -o wide 2>/dev/null; echo ''; echo '=== GPU Resources ==='; kubectl get nodes -o custom-columns='NAME:.metadata.name,GPU:.status.allocatable.nvidia\\.com/gpu' 2>/dev/null; echo ''; echo '=== External APIs (per served LLM) ==='; APIS=$(kubectl get svc -o jsonpath='{range .items[*]}{.metadata.name}{\" \"}{.spec.ports[0].nodePort}{\"\\n\"}{end}' 2>/dev/null | grep -- '-api '); if [ -n \"$APIS\" ]; then echo \"$APIS\" | sed 's|\\(.*\\)-api \\(.*\\)| \\1: http://$$Func(GetPublicIP(target=this)):\\2/openai/v1|'; else echo ' (not exposed; serve with --nodeport to expose)'; fi; echo ''; echo '=== Model Logs (tail) ==='; kubectl logs -l serving.kserve.io/inferenceservice --tail=5 2>/dev/null || echo ' (no model pods yet)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeOpenWebUI": - // Deploy Open WebUI connected to the KServe endpoint (NodePort 30080; open it in the SG) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/deploy-open-webui-kserve.sh | bash -s -- --nodeport 30080; echo ''; echo '[OPEN_WEBUI_URL]'; echo 'http://$$Func(GetPublicIP(target=this)):30080'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeExampleA": - // Serve a standard-format sklearn model via KServe runtime — no image build needed - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/examples/a-sklearn-isvc.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeExampleC": - // Serve the same model as a plain Deployment + Service (no KServe) for comparison - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/examples/c-plain-deployment.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeRegistryDeploy": - // In-cluster private registry on NodePort 30500 (vNet-internal; keep the port closed in the SG) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/deploy-private-registry.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeRegistryAccess": - // Configure containerd on every node to pull from the plain-HTTP registry (target: Infra / all nodes) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/config-registry-access.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeExampleB": - // Build a custom model image, push to the private registry, and serve via KServe - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/examples/build-serve-custom-model.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "KServeMonitoring": - // Prometheus + Grafana with DCGM (GPU) and vLLM (LLM) dashboards on NodePort 30300 - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/kserve/deploy-monitoring.sh | bash; echo ''; echo '[GRAFANA_URL]'; echo 'http://$$Func(GetPublicIP(target=this)):30300 (admin / admin)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "HermesAgent-KServe": - // Deploy Hermes Agent connected to the KServe model API (hermes-only mode; no local vLLM) - // Requires KServeVllmServe with NodePort 30800 (localhost works on any cluster node) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/llm/deployHermesAgent.sh -o /tmp/deployHermesAgent.sh && MODEL=\"\"; [ -z \"$MODEL\" ] && MODEL=\"Qwen/Qwen2.5-7B-Instruct\"; bash /tmp/deployHermesAgent.sh --run-as-user cb-user --mode hermes-only --skip-vllm --vllm-base-url http://localhost:30800/openai/v1 --model \"${MODEL##*/}\" --hermes-api-key \"\" --discord-token \"\" --discord-home-channel \"\" --discord-home-channel-name \"\" --ntfy-topic \"\" --tavily-api-key \"\""; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpRegistryDb": - // Deploy Model Registry DB (PostgreSQL seeded with a HF-style model catalog) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/mcp/deploy-registry-db.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpRegistryBackend": - // Deploy Model Registry backend (FastAPI: search/get/register/delete models) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/mcp/deploy-registry-backend.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpRegistryWeb": - // Deploy the registry web catalog (NodePort 30902; open it in the SG) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/mcp/deploy-registry-web.sh | bash; echo ''; echo '[MODEL_REGISTRY_WEB]'; echo 'http://$$Func(GetPublicIP(target=this)):30902'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpRegistryWebScaleOut": - // Traffic-burst demo: scale out the registry web and watch pods spread (e.g., in Headlamp) - defaultRemoteCommand[0] = "R=\"\"; [ -z \"$R\" ] && R=30; kubectl -n mcp-demo scale deployment model-registry-web --replicas=$R; kubectl -n mcp-demo rollout status deployment/model-registry-web --timeout=180s; echo ''; echo '=== Pods ==='; kubectl -n mcp-demo get pods -l app=model-registry-web -o wide"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpRegistryWebScaleIn": - // Scale the registry web back to a single replica after the demo - defaultRemoteCommand[0] = "kubectl -n mcp-demo scale deployment model-registry-web --replicas=1; kubectl -n mcp-demo rollout status deployment/model-registry-web --timeout=180s; echo ''; echo '=== Pods ==='; kubectl -n mcp-demo get pods -l app=model-registry-web -o wide"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpServers": - // Deploy two MCP adapters: curated catalog tools (write path) + read-only SQL (analysis path) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/mcp/deploy-mcp-servers.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpServingAdapter": - // Optional: live KServe InferenceService list as a 3rd MCP target (same-cluster KServe; re-run step 9 after) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/mcp/deploy-mcp-serving-adapter.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpAgentgateway": - // agentgateway federates the MCP adapters behind one endpoint (NodePort 30900; open it in the SG) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/mcp/deploy-agentgateway.sh | bash; echo ''; echo '[MCP_ENDPOINT (all tools)]'; echo 'http://$$Func(GetPublicIP(target=this)):30900/mcp'; echo '[MCP_ENDPOINT_REGISTRY (catalog view)]'; echo 'http://$$Func(GetPublicIP(target=this)):30900/mcp-registry'; echo '[MCP_ENDPOINT_SERVING (KServe view; needs step 8-opt)]'; echo 'http://$$Func(GetPublicIP(target=this)):30900/mcp-serving'; echo '[AGENTGATEWAY_UI]'; echo 'http://$$Func(GetPublicIP(target=this)):30901/ui/'; echo ''; echo 'Register in Claude Code:'; echo 'claude mcp add --transport http model-registry http://$$Func(GetPublicIP(target=this)):30900/mcp'; echo ''; echo 'Register in VS Code Copilot:'; echo 'Ctrl+Shift+P -> MCP: Add Server... -> HTTP -> paste an endpoint URL above, then use Copilot Chat in Agent mode'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpE2eTest": - // Scripted demo through the gateway: federated tools, SQL analytics, register/delete governance - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/mcp/test-mcp-e2e.sh | bash"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "McpStatus": - // Check MCP demo stack status (run on control plane) - defaultRemoteCommand[0] = "echo '=== MCP demo stack (namespace mcp-demo) ==='; kubectl -n mcp-demo get pods,svc 2>/dev/null || echo ' (mcp-demo namespace not found)'; echo ''; echo '=== External endpoints ==='; echo ' Web: http://$$Func(GetPublicIP(target=this)):30902'; echo ' MCP: http://$$Func(GetPublicIP(target=this)):30900/mcp (views: /mcp-registry, /mcp-serving)'; echo ' UI: http://$$Func(GetPublicIP(target=this)):30901/ui/'; echo ''; echo '=== agentgateway logs (tail) ==='; kubectl -n mcp-demo logs deploy/agentgateway --tail=5 2>/dev/null || echo ' (agentgateway not deployed yet)'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Westward": - defaultRemoteCommand[0] = "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/setgame.sh"; - defaultRemoteCommand[1] = "chmod +x ~/setgame.sh; sudo ~/setgame.sh"; - defaultRemoteCommand[2] = ""; - break; - case "WeaveScope": - defaultRemoteCommand[0] = "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/weavescope/startServer.sh"; - defaultRemoteCommand[1] = "chmod +x ~/startServer.sh"; - defaultRemoteCommand[2] = "sudo ~/startServer.sh " + "$$Func(GetPublicIPs(separator=' '))" + " " + "$$Func(GetPrivateIPs(separator=' '))"; - break; - case "Nginx": - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/nginx/startServer.sh | bash -s -- --ip $$Func(GetPublicIP(target=this))"; - defaultRemoteCommand[1] = "which unzip || sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y unzip; f=$(ls /home/cb-user/*.zip /home/cb-user/*.tar.gz /home/cb-user/*.tgz /home/cb-user/*.tar.bz2 2>/dev/null | head -1); [ -n \"$f\" ] && case \"$f\" in *.zip) sudo unzip -o \"$f\" -d /var/www/html/ ;; *.tar.gz|*.tgz) sudo tar -xzf \"$f\" -C /var/www/html/ ;; *.tar.bz2) sudo tar -xjf \"$f\" -C /var/www/html/ ;; esac || echo 'No archive found in /home/cb-user/, skipping extraction.'"; - defaultRemoteCommand[2] = "echo 'Access to $$Func(GetPublicIP(target=this, prefix=http://))'"; - break; - case "MvToWebRoot": - defaultRemoteCommand[0] = "sudo mv /home/cb-user/* /var/www/html/"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "ExtractToWebRoot": - defaultRemoteCommand[0] = "which unzip || sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y unzip; f=$(ls /home/cb-user/*.zip /home/cb-user/*.tar.gz /home/cb-user/*.tgz /home/cb-user/*.tar.bz2 2>/dev/null | head -1); [ -n \"$f\" ] && case \"$f\" in *.zip) sudo unzip -o \"$f\" -d /var/www/html/ ;; *.tar.gz|*.tgz) sudo tar -xzf \"$f\" -C /var/www/html/ ;; *.tar.bz2) sudo tar -xjf \"$f\" -C /var/www/html/ ;; esac || echo 'No archive found in /home/cb-user/, skipping extraction.'"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Jitsi": - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/jitsi/startServer.sh | sudo bash -s -- "; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Stress": - defaultRemoteCommand[0] = "sudo apt install -y stress > /dev/null; stress -c 16 -t 60"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "CB-TB-Deploy": - defaultRemoteCommand[0] = "curl -sSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/set-tb.sh | bash"; - defaultRemoteCommand[1] = "echo '$$Func(GetPublicIP(target=this, prefix=http://, postfix=:1324))'"; - defaultRemoteCommand[2] = ""; - break; - case "M-CMP-Install": - // Stage 1: clone repo, configure env/certs, do NOT start containers - defaultRemoteCommand[0] = "curl -sSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/set-tb.sh | bash"; - defaultRemoteCommand[1] = "curl -sSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/set-mcmp.sh | sudo bash -s -- install"; - defaultRemoteCommand[2] = ""; - break; - case "M-CMP-Pull": - // Pre-pull Docker images (optional; run before M-CMP-Run to separate download from startup) - defaultRemoteCommand[0] = "curl -sSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/set-mcmp.sh | sudo bash -s -- pull"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "M-CMP-Run": - // Stage 2: start all M-CMP containers in background (detached) - defaultRemoteCommand[0] = "curl -sSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/set-mcmp.sh | sudo bash -s -- run"; - defaultRemoteCommand[1] = "echo '$$Func(GetPublicIP(target=this, prefix=http://, postfix=:3001))'"; - defaultRemoteCommand[2] = ""; - break; - case "M-CMP-Info": - // Show running container and image status - defaultRemoteCommand[0] = "curl -sSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/set-mcmp.sh | sudo bash -s -- info"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "M-CMP-Stop": - // Stop all M-CMP containers - defaultRemoteCommand[0] = "curl -sSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/set-mcmp.sh | sudo bash -s -- stop"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "DevStack-Install": - // Install DevStack on bare-metal VMs (e.g., AWS m5.metal) - // CSP name is derived from Infra ID + Node ID for unique provider registration - // Location info is automatically populated from the Node's deployment location - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/openstack/1.installDevStack.sh -o /tmp/installDevStack.sh && bash /tmp/installDevStack.sh --csp-name openstack-$$Func(GetInfraId())-$$Func(GetNodeId()) --latitude $$Func(GetLocationLatitude()) --longitude $$Func(GetLocationLongitude()) --location \"$$Func(GetLocationDisplay())\""; - defaultRemoteCommand[1] = "echo 'DevStack installed. Horizon: $$Func(GetPublicIP(target=this, prefix=http://, postfix=/dashboard))'"; - defaultRemoteCommand[2] = ""; - // stack.sh alone takes 20-40 minutes with Octavia/Manila enabled - defaultRemoteCommandTimeout = 120; - break; - case "DevStack-Info": - // Get registration info from installed DevStack - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/openstack/2.getRegistrationInfo.sh -o /tmp/getRegistrationInfo.sh && bash /tmp/getRegistrationInfo.sh --csp-name openstack-$$Func(GetInfraId())-$$Func(GetNodeId()) --latitude $$Func(GetLocationLatitude()) --longitude $$Func(GetLocationLongitude()) --location \"$$Func(GetLocationDisplay())\""; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "DevStack-UpdateEndpoints": - // Update OpenStack service catalog endpoints after public IP change (e.g., suspend/resume) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/openstack/3.updateEndpoints.sh -o /tmp/updateEndpoints.sh && bash /tmp/updateEndpoints.sh --csp-name openstack-$$Func(GetInfraId())-$$Func(GetNodeId())"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "DevStack-Clean": - // Clean up failed or stale DevStack installation for re-install - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/openstack/4.cleanDevStack.sh -o /tmp/cleanDevStack.sh && bash /tmp/cleanDevStack.sh"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Kolla-Install": - // Install OpenStack via Kolla-Ansible (Docker-based, production-grade, survives reboot) - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/openstack/kolla/1.installKolla.sh -o /tmp/installKolla.sh && bash /tmp/installKolla.sh --csp-name openstack-$$Func(GetInfraId())-$$Func(GetNodeId()) --latitude $$Func(GetLocationLatitude()) --longitude $$Func(GetLocationLongitude()) --location \"$$Func(GetLocationDisplay())\""; - defaultRemoteCommand[1] = "echo 'Kolla-Ansible installed. Horizon: $$Func(GetPublicIP(target=this, prefix=http://, postfix=/))'"; - defaultRemoteCommand[2] = ""; - break; - case "Kolla-Info": - // Get registration info from Kolla-Ansible deployment - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/openstack/kolla/2.getRegistrationInfo.sh -o /tmp/getKollaInfo.sh && bash /tmp/getKollaInfo.sh --csp-name openstack-$$Func(GetInfraId())-$$Func(GetNodeId()) --latitude $$Func(GetLocationLatitude()) --longitude $$Func(GetLocationLongitude()) --location \"$$Func(GetLocationDisplay())\""; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Kolla-UpdateEndpoints": - // Update OpenStack service catalog endpoints after public IP change - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/openstack/kolla/3.updateEndpoints.sh -o /tmp/updateKollaEndpoints.sh && bash /tmp/updateKollaEndpoints.sh --csp-name openstack-$$Func(GetInfraId())-$$Func(GetNodeId())"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - case "Kolla-Clean": - // Clean up Kolla-Ansible deployment - defaultRemoteCommand[0] = "curl -fsSL https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/openstack/kolla/4.cleanKolla.sh -o /tmp/cleanKolla.sh && bash /tmp/cleanKolla.sh"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - default: - defaultRemoteCommand[0] = "ls -al"; - defaultRemoteCommand[1] = ""; - defaultRemoteCommand[2] = ""; - break; - } -} - - -// function for startApp by startApp button item -function startApp() { - var infraid = infraidElement.value; - if (infraid) { - setDefaultRemoteCommandsByApp(selectApp.value); - executeRemoteCmd(); - } else { - console.log(" Infra ID is not assigned"); - } -} -window.startApp = startApp; - -// function for stopApp by stopApp button item -function stopApp() { - var infraid = infraidElement.value; - if (infraid) { - console.log(" Stopping " + selectApp.value); - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - var url = `${tbApiBase()}/ns/${namespace}/cmd/infra/${infraid}`; - var cmd = []; - if (selectApp.value == "Xonotic") { - cmd.push( - "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/xonotic/stopServer.sh" - ); - cmd.push("chmod +x ~/stopServer.sh"); - cmd.push("sudo ~/stopServer.sh"); - } else if (selectApp.value == "ELK") { - cmd.push( - "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/elastic-stack/stopELK.sh" - ); - cmd.push("chmod +x ~/stopELK.sh"); - cmd.push("sudo ~/stopELK.sh"); - } else if (selectApp.value == "Westward") { - cmd.push( - "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/xonotic/stopServer.sh" - ); - cmd.push("chmod +x ~/stopServer.sh"); - cmd.push("sudo ~/stopServer.sh"); - } else if (selectApp.value == "Nginx") { - cmd.push( - "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/xonotic/stopServer.sh" - ); - cmd.push("chmod +x ~/stopServer.sh"); - cmd.push("sudo ~/stopServer.sh"); - } else if (selectApp.value == "Jitsi") { - cmd.push( - "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/xonotic/stopServer.sh" - ); - cmd.push("chmod +x ~/stopServer.sh"); - cmd.push("sudo ~/stopServer.sh"); - } else { - cmd.push("ls -al"); - } - - var commandReqTmp = { - command: cmd, - }; - var jsonBody = JSON.stringify(commandReqTmp, undefined, 4); - - axios({ - method: "post", - url: url, - headers: { "Content-Type": "application/json" }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - console.log(res); // for debug - - console.log("[Complete: Stopping App]\n"); - displayJsonData(res.data, typeInfo); - }); - } else { - console.log(" Infra ID is not assigned"); - } -} -window.stopApp = stopApp; - -// function for statusApp by statusApp button item -function statusApp() { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = infraidElement.value; - - if (infraid) { - console.log(" Getting status " + selectApp.value); - - var url = `${tbApiBase()}/ns/${namespace}/cmd/infra/${infraid}`; - var cmd = []; - if (selectApp.value == "Xonotic") { - cmd.push( - "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/xonotic/statusServer.sh -O ~/statusServer.sh" - ); - cmd.push("chmod +x ~/statusServer.sh"); - cmd.push("sudo ~/statusServer.sh"); - } else if (selectApp.value == "Westward") { - cmd.push( - "wget wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/setgame.sh -O ~/setgame.sh" - ); - cmd.push("chmod +x ~/setgame.sh"); - cmd.push("sudo ~/setgame.sh"); - } else if (selectApp.value == "Nvidia" || selectApp.value == "NvidiaVgpu") { - cmd.push("nvidia-smi"); - cmd.push(""); - cmd.push(""); - } else if (selectApp.value == "Nginx") { - cmd.push( - "wget wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/setweb.sh -O ~/setweb.sh" - ); - cmd.push("chmod +x ~/setweb.sh"); - cmd.push("sudo ~/setweb.sh"); - } else if (selectApp.value == "Jitsi") { - cmd.push( - "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/jitsi/statusServer.sh -O ~/statusServer.sh" - ); - cmd.push("chmod +x ~/statusServer.sh"); - cmd.push("sudo ~/statusServer.sh"); - } else if (selectApp.value == "ELK") { - cmd.push( - "wget https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/scripts/usecases/elastic-stack/statusELK.sh -O ~/statusServer.sh" - ); - cmd.push("chmod +x ~/statusServer.sh"); - cmd.push("sudo ~/statusServer.sh"); - } else { - cmd.push("ls -al"); - } - - var commandReqTmp = { - command: cmd, - }; - var jsonBody = JSON.stringify(commandReqTmp, undefined, 4); - - axios({ - method: "post", - url: url, - headers: { "Content-Type": "application/json" }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - console.log(res); // for debug - - console.log("[Complete: Getting App status]\n"); - displayJsonData(res.data, typeInfo); - }); - } else { - console.log(" Infra ID is not assigned"); - } -} -window.statusApp = statusApp; - -// Auto-resize textarea based on content -window.autoResizeTextarea = function (textarea) { - if (!textarea) return; - textarea.style.height = 'auto'; - textarea.style.height = Math.max(textarea.scrollHeight, 24) + 'px'; // minimum 24px (approx 1 line) -}; - -// Reset commands to initial state (3 empty fields) -window.resetCommands = function () { - const cmdContainer = document.getElementById('cmdContainer'); - if (!cmdContainer) return; - - // Remove all cmdDiv elements except the button - const cmdDivs = cmdContainer.querySelectorAll('[id^="cmdDiv"]'); - cmdDivs.forEach(div => div.remove()); - - // Recreate 3 empty command fields - const addCmdBtn = cmdContainer.querySelector('#addCmd'); - for (let i = 1; i <= 3; i++) { - const newCmdDiv = document.createElement('div'); - newCmdDiv.id = `cmdDiv${i}`; - newCmdDiv.className = 'cmdRow'; - newCmdDiv.innerHTML = ` - Command ${i}: - - `; - if (addCmdBtn) { - cmdContainer.insertBefore(newCmdDiv, addCmdBtn); - } else { - cmdContainer.appendChild(newCmdDiv); - } - } - - // Reset predefined script dropdown - const scriptSelect = document.getElementById('predefinedScripts'); - if (scriptSelect) { - scriptSelect.selectedIndex = 0; - } - - console.log('Commands reset to 3 empty fields'); - - // Re-render placeholder inputs to clear stale panels - if (typeof window.renderPlaceholderInputs === 'function') { - window.renderPlaceholderInputs(); - } -}; - -// ============================================================ -// Placeholder System for Remote Commands -// ============================================================ -// Detects patterns in commands and renders -// separate input fields for each. Secret placeholders (defined in -// PLACEHOLDER_METADATA with secret:true) use masked password inputs. - -/** - * Metadata for known placeholders: description, input hint, and secret flag. - * Placeholders not listed here still get auto-detected input fields. - */ -window.PLACEHOLDER_METADATA = { - 'PASTE_JOIN_COMMAND_HERE': { - description: 'K8s join command from control plane', - hint: 'kubeadm join 10.0.0.1:6443 --token abc.123 --discovery-token-ca-cert-hash sha256:xyz', - secret: false, - }, - 'REPLICA_COUNT': { - description: 'Number of pod replicas', - hint: '3', - secret: false, - }, - 'REGISTRY_WEB_REPLICAS': { - description: 'Registry web replica count (scale-out demo)', - hint: '30', - secret: false, - }, - 'K8S_CNI': { - description: 'CNI plugin — empty/flannel (default) or cilium (enables the optional Hubble UI step)', - hint: 'flannel', - secret: false, - }, - 'K8S_EXTERNAL_IP': { - description: 'Externally reachable API server address (IP or DNS) — added to the cert SAN and written into the kubeconfig', - hint: '15.161.132.237', - secret: false, - }, - 'K8S_API_PORT': { - description: 'External API server port — must be forwarded to :6443 on the control plane', - hint: '6443', - default: '6443', - secret: false, - }, - 'HF_TOKEN': { - description: 'Hugging Face API token', - hint: 'hf_xxxxxxxxxxxxxxxxxxxxx', - secret: true, - }, - 'DNS_DOMAIN': { - description: 'DNS domain name', - hint: 'meet.example.com', - secret: false, - }, - 'EMAIL_ADDRESS': { - description: 'Admin email address', - hint: 'admin@example.com', - secret: false, - }, - 'TELEMETRY_GPU_VM_IPS': { - description: 'GPU Node public IPs (space-separated)', - hint: '104.42.74.157 3.96.201.235', - secret: false, - }, - 'TELEMETRY_GPU_VM_IPS_CSV': { - description: 'GPU Node public IPs (comma-separated)', - hint: '104.42.74.157,3.96.201.235', - secret: false, - }, - 'EXPORT_MINUTES': { - description: 'Time range in minutes for metrics export', - hint: '60', - secret: false, - }, - 'NODES_MAPPING': { - description: 'public_ip:wg_ip pairs, comma-separated', - hint: '54.1.1.1:10.200.0.1,35.2.2.2:10.200.0.2', - secret: false, - }, - 'FLOATING_IP': { - description: 'Target IP to forward to (e.g. OpenStack floating IP)', - hint: '172.24.4.99', - secret: false, - }, - 'EXT_PORT': { - description: 'External port to expose on this Node', - hint: '80', - secret: false, - }, - 'TARGET_PORT': { - description: 'Target port on the destination Node', - hint: '80', - secret: false, - }, - 'RULE_NUM': { - description: 'Rule line number to delete (from PortForward-List output)', - hint: '1', - secret: false, - }, - // Hermes Agent - 'HERMES_API_KEY': { - description: 'Hermes API key (leave blank to auto-generate)', - hint: '', - secret: true, - }, - 'VLLM_VERSION': { - description: 'vLLM version to install (leave blank for latest)', - hint: '', - default: '', - secret: false, - }, - // Hermes Agent uses vLLM — same VRAM rules as VLLM_MODEL. - // CTX_LEN default 65536 adds significant KV cache; prefer smaller models on 24 GB. - // Best for agents: Qwen2.5/3 (tool calling), Llama3.1/3.3 (built-in tool use), Phi-4-mini (compact agent). - 'HERMES_MODEL': { - description: 'LLM model for Hermes Agent (served via vLLM — pick a model that fits your GPU VRAM)', - hint: 'Qwen/Qwen3-30B-A3B-Instruct-2507-FP8', - default: 'Qwen/Qwen3-30B-A3B-Instruct-2507-FP8', - secret: false, - refs: [ - { label: 'HuggingFace Models', url: 'https://huggingface.co/models?pipeline_tag=text-generation&sort=downloads' } - ], - presets: [ - // ── 24 GB · L4 (Ampere, BF16 only) - { label: '24GB · Phi-4-mini (BF16≈8GB)', value: 'microsoft/Phi-4-mini-instruct' }, - { label: '24GB · Qwen3-8B (BF16≈16GB)', value: 'Qwen/Qwen3-8B' }, - { label: '24GB · Qwen2.5-7B (BF16≈14GB)', value: 'Qwen/Qwen2.5-7B-Instruct' }, - // ── 24 GB · L40S (Ada Lovelace, FP8 supported) - { label: '24GB · Qwen2.5-14B (FP8≈14GB, L40S)', value: 'Qwen/Qwen2.5-14B-Instruct' }, - { label: '24GB · Qwen3-14B (FP8≈14GB, L40S)', value: 'Qwen/Qwen3-14B' }, - // ── 80 GB · A100 / H100 ── - { label: '80GB · Qwen3-30B-A3B (FP8≈30GB)', value: 'Qwen/Qwen3-30B-A3B-Instruct-2507-FP8' }, - { label: '80GB · Qwen2.5-32B (FP8≈32GB)', value: 'Qwen/Qwen2.5-32B-Instruct' }, - { label: '80GB · Llama3.3-70B (FP8≈70GB, H100)', value: 'meta-llama/Llama-3.3-70B-Instruct' }, - { label: '80GB · Qwen2.5-72B (FP8≈72GB, H100)', value: 'Qwen/Qwen2.5-72B-Instruct' } - ] - }, - 'CTX_LEN': { - description: 'Max context length (tokens). Larger = more KV cache VRAM. GQA models (Qwen2.5/3, Llama3) are efficient; non-GQA models cost more.', - hint: '65536', - default: '65536', - secret: false, - presets: [ - { label: '8K · test / very tight 24GB', value: '8192' }, - { label: '16K · 24GB conservative', value: '16384' }, - { label: '32K · 24GB recommended', value: '32768' }, - { label: '64K · 80GB recommended', value: '65536' }, - { label: '128K · H100 large context', value: '131072' } - ] - }, - 'DISCORD_TOKEN': { - description: 'Discord bot token', - hint: 'your-discord-bot-token', - secret: true, - }, - 'DISCORD_HOME_CHANNEL': { - description: 'Discord home channel ID', - hint: '1509132101184913488', - default: '1509132101184913488', - secret: false, - }, - 'DISCORD_HOME_CHANNEL_NAME': { - description: 'Discord home channel name', - hint: 'hermes-bot', - default: 'hermes-bot', - secret: false, - }, - 'NTFY_TOPIC': { - description: 'ntfy notification topic', - hint: 'etri-son-hermes-agent', - default: 'etri-son-hermes-agent', - secret: false, - }, - 'TAVILY_API_KEY': { - description: 'Tavily search API key', - hint: 'tvly-xxxxxxxxxxxxxxxx', - secret: true, - }, - // vLLM — VRAM guide: BF16 ≈ 2 GB/B · FP8 ≈ 1 GB/B - // L4 (Ampere 24GB): BF16 only → ≤12B safe | L40S (Ada 48GB): FP8 → ≤40B - // A100 80GB: BF16 ≤40B | H100 80GB: FP8 W8A8 → ≤72B single card - // Time-sliced (shared) GPU: divide the budget — 2 models on one L40S ≈ 24GB each - 'VLLM_MODEL': { - description: 'HuggingFace model name to serve', - hint: 'meta-llama/Llama-3.1-8B-Instruct', - default: 'meta-llama/Llama-3.1-8B-Instruct', - secret: false, - refs: [ - { label: 'HuggingFace Models', url: 'https://huggingface.co/models?pipeline_tag=text-generation&sort=downloads' } - ], - presets: [ - // ── 24 GB L4 · BF16 only (Ampere, ≤12B safe) — 8 presets ── - { label: '24GB · Phi-4-mini (BF16≈8GB, L4)', value: 'microsoft/Phi-4-mini-instruct' }, - { label: '24GB · Llama3.2-3B (BF16≈6GB, L4)', value: 'meta-llama/Llama-3.2-3B-Instruct' }, - { label: '24GB · Qwen2.5-7B (BF16≈14GB, L4)', value: 'Qwen/Qwen2.5-7B-Instruct' }, - { label: '24GB · Mistral-7B (BF16≈14GB, L4)', value: 'mistralai/Mistral-7B-Instruct-v0.3' }, - { label: '24GB · Qwen2.5-Coder-7B (BF16≈14GB, L4)', value: 'Qwen/Qwen2.5-Coder-7B-Instruct' }, - { label: '24GB · Llama3.1-8B (BF16≈16GB, L4)', value: 'meta-llama/Llama-3.1-8B-Instruct' }, - { label: '24GB · Qwen3-8B (BF16≈16GB, L4)', value: 'Qwen/Qwen3-8B' }, - { label: '24GB · DeepSeek-R1-8B (BF16≈16GB, L4)', value: 'deepseek-ai/DeepSeek-R1-Distill-Llama-8B' }, - // ── 48 GB L40S · FP8 (Ada Lovelace, ≤40B; ≤14B fits a half-sliced GPU) — 10 presets ── - { label: '48GB · Gemma3-12B (FP8≈12GB, L40S)', value: 'google/gemma-3-12b-it' }, - { label: '48GB · Mistral-Nemo-12B (FP8≈12GB, L40S)', value: 'mistralai/Mistral-Nemo-Instruct-2407' }, - { label: '48GB · Phi-4 (FP8≈14GB, L40S)', value: 'microsoft/Phi-4' }, - { label: '48GB · Qwen2.5-14B (FP8≈14GB, L40S)', value: 'Qwen/Qwen2.5-14B-Instruct' }, - { label: '48GB · Qwen3-14B (FP8≈14GB, L40S)', value: 'Qwen/Qwen3-14B' }, - { label: '48GB · DeepSeek-R1-14B (FP8≈14GB, L40S)', value: 'deepseek-ai/DeepSeek-R1-Distill-Qwen-14B' }, - { label: '48GB · Qwen2.5-Coder-14B (FP8≈14GB, L40S)', value: 'Qwen/Qwen2.5-Coder-14B-Instruct' }, - { label: '48GB · Mistral-Small3.1-24B (FP8≈24GB, L40S)', value: 'mistralai/Mistral-Small-3.1-24B-Instruct-2503' }, - { label: '48GB · Qwen3-30B-A3B (FP8≈30GB, L40S)', value: 'Qwen/Qwen3-30B-A3B-Instruct-2507-FP8' }, - { label: '48GB · Qwen2.5-32B (FP8≈32GB, L40S)', value: 'Qwen/Qwen2.5-32B-Instruct' }, - // ── 80 GB A100 · FP8 mid (24-40B, good headroom) — 8 presets ─ - { label: '80GB · Mistral-Small3.1-24B (FP8≈24GB, A100)', value: 'mistralai/Mistral-Small-3.1-24B-Instruct-2503' }, - { label: '80GB · Gemma3-27B (FP8≈27GB, A100)', value: 'google/gemma-3-27b-it' }, - { label: '80GB · Qwen3-30B-A3B (FP8≈30GB, A100)', value: 'Qwen/Qwen3-30B-A3B-Instruct-2507-FP8' }, - { label: '80GB · Qwen3-32B (FP8≈32GB, A100)', value: 'Qwen/Qwen3-32B' }, - { label: '80GB · Qwen2.5-32B (FP8≈32GB, A100)', value: 'Qwen/Qwen2.5-32B-Instruct' }, - { label: '80GB · DeepSeek-R1-32B (FP8≈32GB, A100)', value: 'deepseek-ai/DeepSeek-R1-Distill-Qwen-32B' }, - { label: '80GB · Qwen2.5-Coder-32B (FP8≈32GB, A100)', value: 'Qwen/Qwen2.5-Coder-32B-Instruct' }, - { label: '80GB · Qwen2.5-Math-32B (FP8≈32GB, A100)', value: 'Qwen/Qwen2.5-Math-32B-Instruct' }, - // ── 80 GB H100 · FP8 W8A8 large (70B+) — 8 presets ── - { label: '80GB · Llama3.3-70B (FP8≈70GB, H100)', value: 'meta-llama/Llama-3.3-70B-Instruct' }, - { label: '80GB · Llama3.1-70B (FP8≈70GB, H100)', value: 'meta-llama/Llama-3.1-70B-Instruct' }, - { label: '80GB · Qwen3-72B (FP8≈72GB, H100)', value: 'Qwen/Qwen3-72B' }, - { label: '80GB · Qwen2.5-72B (FP8≈72GB, H100)', value: 'Qwen/Qwen2.5-72B-Instruct' }, - { label: '80GB · DeepSeek-R1-70B (FP8≈70GB, H100)', value: 'deepseek-ai/DeepSeek-R1-Distill-Llama-70B' }, - { label: '80GB · Qwen2.5-Coder-72B (FP8≈72GB, H100)', value: 'Qwen/Qwen2.5-Coder-72B-Instruct' }, - { label: '80GB · Qwen2.5-Math-72B (FP8≈72GB, H100)', value: 'Qwen/Qwen2.5-Math-72B-Instruct' }, - { label: '80GB · Mixtral-8x7B (FP8≈47GB, H100)', value: 'mistralai/Mixtral-8x7B-Instruct-v0.1' } - ] - }, - 'VLLM_HF_TOKEN': { - description: 'HuggingFace token for gated models (leave blank for public models)', - hint: 'hf_xxxxxxxxxxxxxxxx', - default: '', - secret: true, - }, - 'VLLM_GPU_UTIL': { - description: 'GPU memory utilization fraction 0.0–1.0 (leave blank for vLLM default)', - hint: '0.9', - default: '', - secret: false, - }, - 'VLLM_CTX_LEN': { - description: 'Max context length (--max-model-len). Leave blank for model default. Larger = more KV cache VRAM.', - hint: '8192', - default: '', - secret: false, - presets: [ - { label: '8K · 24GB safe', value: '8192' }, - { label: '16K · 24GB balanced', value: '16384' }, - { label: '32K · 24GB / 80GB', value: '32768' }, - { label: '64K · 80GB recommended', value: '65536' }, - { label: '128K · H100 large', value: '131072' } - ] - }, - 'VLLM_ISVC_NAME': { - description: 'InferenceService name — use a unique name (llm2, llm3, ...) to ADD a model instead of replacing', - hint: 'llm', - default: 'llm', - secret: false, - presets: [ - { label: 'llm (1st model, port 30800)', value: 'llm' }, - { label: 'llm2 (2nd model, port 30801)', value: 'llm2' }, - { label: 'llm3 (3rd model, port 30802)', value: 'llm3' }, - { label: 'llm4 (4th model, port 30803)', value: 'llm4' } - ] - }, - 'VLLM_NODEPORT': { - description: 'NodePort for this model\'s OpenAI API (unique per model; allow in the Security Group)', - hint: '30800', - default: '30800', - secret: false, - presets: [ - { label: '30800 (llm)', value: '30800' }, - { label: '30801 (llm2)', value: '30801' }, - { label: '30802 (llm3)', value: '30802' }, - { label: '30803 (llm4)', value: '30803' } - ] - }, - 'GPU_TIMESLICE_REPLICAS': { - description: 'Pods per physical GPU (time-slicing; no VRAM isolation — cap each model with GPU_UTIL)', - hint: '2', - default: '2', - secret: false, - }, - 'GPU_TIMESLICE_NODE': { - description: 'Node name to time-slice (blank = all GPU nodes; set on mixed clusters, e.g. slice only the L40S node)', - hint: 'mc-xxx-g1-1', - default: '', - secret: false, - }, - 'VLLM_TARGET_NODE': { - description: 'Pin this model to a node (blank = scheduler decides; set on mixed GPU types)', - hint: 'mc-xxx-g1-1', - default: '', - secret: false, - }, - 'MIG_PROFILE': { - description: 'MIG profile (A100/H100 only) — each slice is an isolated GPU; no GPU_UTIL needed', - hint: 'all-3g.40gb', - default: 'all-3g.40gb', - secret: false, - presets: [ - { label: '80GB · all-3g.40gb (2 slices x 40GB, ≤32B FP8 each)', value: 'all-3g.40gb' }, - { label: '80GB · all-2g.20gb (3 slices x 20GB, ≤14B FP8 each)', value: 'all-2g.20gb' }, - { label: '80GB · all-1g.10gb (7 slices x 10GB, ≤8B FP8 each)', value: 'all-1g.10gb' }, - { label: 'Disable MIG (restore full GPUs)', value: 'all-disabled' } - ] - }, - // GuideLLM Benchmark - 'GUIDELLM_PROFILE': { - description: 'Benchmark profile (synchronous, constant, sweep, poisson, throughput)', - hint: 'throughput', - default: 'throughput', - secret: false, - }, - 'GUIDELLM_MAX_SECONDS': { - description: 'Maximum duration per target in seconds', - hint: '180', - default: '180', - secret: false, - }, - 'GUIDELLM_RATE': { - description: 'Request rate or sweep count (leave blank to use profile default)', - hint: '', - default: '', - secret: false, - }, - 'GUIDELLM_DATA': { - description: 'Dataset source — HuggingFace dataset ID, or leave blank for synthetic data', - hint: 'HuggingFaceH4/ultrachat_200k', - default: 'HuggingFaceH4/ultrachat_200k', - secret: false, - }, - 'GUIDELLM_DATA_COLUMN_MAPPER': { - description: 'Dataset column mapping in JSON (e.g. {"text_column":"prompt"})', - hint: '{"text_column":"prompt"}', - default: '{"text_column":"prompt"}', - secret: false, - }, - // Ollama — VRAM guide: Q4 ≈ 0.5 GB/B (8B≈5GB · 14B≈9GB · 32B≈20GB · 70B≈43GB) - 'OLLAMA_MODELS': { - description: 'Comma-separated list of Ollama models to pull (one per VM via AssignTask)', - hint: 'llama3.1:8b, qwen2.5:7b, mistral:7b, phi4-mini', - default: 'llama3.1:8b, qwen2.5:7b, mistral:7b, phi4-mini', - secret: false, - refs: [ - { label: 'Ollama Model Library', url: 'https://ollama.com/library' } - ], - presets: [ - // ── 24 GB GPU · Tiny 3-4B (~2GB Q4) — 8 models - { label: '24GB · 3-4B (~2GB Q4)', - value: 'llama3.2:3b, qwen2.5:3b, qwen3:4b, gemma3:4b, phi4-mini, qwen3:1.7b, qwen2.5:1.5b, smollm2:1.7b' }, - // ── 24 GB GPU · General 7-14B (~5-9GB Q4) — 8 models ─ - { label: '24GB · 7-14B (~5-9GB Q4)', - value: 'llama3.1:8b, qwen3:8b, qwen2.5:7b, mistral:7b, gemma3:9b, deepseek-r1:8b, qwen2.5:14b, gemma3:12b' }, - // ── 24 GB GPU · Large 22-32B (~14-20GB Q4) — 8 models - { label: '24GB · 22-32B (~14-20GB Q4)', - value: 'deepseek-r1:32b, qwen2.5:32b, qwen3:32b, qwen3-coder:30b, devstral:24b, codestral:22b, gemma3:27b, qwen2.5-coder:32b' }, - // ── 80 GB GPU (A100 / H100) · 70B+ (~43GB Q4) — 8 models ── - { label: '80GB · 70B+ (~43GB Q4)', - value: 'llama3.3:70b, llama3.1:70b, qwen2.5:72b, qwen3:72b, deepseek-r1:70b, qwen2.5-coder:72b, mixtral:8x7b, command-r-plus' } - ] - }, -}; - -/** - * Script-level quick reference metadata for scripts that don't use parameters. - */ -window.SCRIPT_QUICK_REF = {}; - -/** - * Extract user-input placeholders from command text. - * Matches patterns, excluding shell defaults like ${VAR:-}. - * @param {string} text - Command text to scan - * @returns {Array<{name: string, fullMatch: string, isSecret: boolean, description: string, hint: string}>} - */ -window.extractPlaceholders = function(text) { - if (!text) return []; - const regex = /(?/g; - const placeholders = []; - const seen = new Set(); - let match; - while ((match = regex.exec(text)) !== null) { - const name = match[1]; - if (seen.has(name)) continue; - seen.add(name); - const meta = window.PLACEHOLDER_METADATA[name] || {}; - placeholders.push({ - name: name, - fullMatch: match[0], - isSecret: meta.secret === true, - description: meta.description || '', - hint: meta.hint || '', - default: meta.default || '', - refs: meta.refs || [], - presets: meta.presets || [], - }); - } - return placeholders; -}; - -/** - * Render placeholder input fields for patterns detected in commands. - * - * Consolidated mode (when #cmdParamsPanel exists in the DOM): - * Scans all commands, deduplicates placeholders, and renders them in one shared - * section (#cmdParamsSection / #cmdParamsPanel) placed above the commands area. - * The user fills each parameter once; collectCommands() substitutes the value into - * every command that contains that placeholder. - * - * Inline legacy mode (old popups without #cmdParamsPanel): - * Falls back to per-command input panels appended below each textarea. - */ -window.renderPlaceholderInputs = function() { - const cmdContainer = document.getElementById('cmdContainer'); - if (!cmdContainer) return; - - const paramsSection = document.getElementById('cmdParamsSection'); - const paramsPanel = document.getElementById('cmdParamsPanel'); - - if (paramsPanel) { - // ── Consolidated mode ── - // Save existing values by placeholder name - const savedValues = {}; - paramsPanel.querySelectorAll('.placeholder-input').forEach(input => { - if (input.value && input.dataset.placeholderName) { - savedValues[input.dataset.placeholderName] = input.value; - } - }); - paramsPanel.innerHTML = ''; - // Remove any stale inline panels - cmdContainer.querySelectorAll('.placeholder-panel').forEach(p => p.remove()); - - // Collect unique placeholders across all commands - const phMap = {}; // name → { ph, cmdIndices[] } - cmdContainer.querySelectorAll('[id^="cmdDiv"]').forEach((div, idx) => { - const ta = document.getElementById(`cmd${idx + 1}`); - if (!ta) return; - window.extractPlaceholders(ta.value).forEach(ph => { - if (!phMap[ph.name]) phMap[ph.name] = { ph, cmdIndices: [] }; - phMap[ph.name].cmdIndices.push(idx + 1); - }); - }); - - if (Object.keys(phMap).length === 0) { - if (paramsSection) paramsSection.style.display = 'none'; - return; - } - if (paramsSection) paramsSection.style.display = ''; - - Object.entries(phMap).forEach(([name, { ph, cmdIndices }]) => { - const inputId = `ph_param_${name}`; - const row = document.createElement('div'); - row.style.cssText = 'display:flex; align-items:center; gap:8px; margin-bottom:6px; flex-wrap:wrap;'; - - const label = document.createElement('label'); - label.htmlFor = inputId; - label.style.cssText = 'min-width:130px; font-weight:600; color:#0d6efd; font-size:0.78rem; white-space:nowrap;'; - label.textContent = (ph.isSecret ? '🔒 ' : '📝 ') + name; - - const input = document.createElement('input'); - input.id = inputId; - input.type = ph.isSecret ? 'password' : 'text'; - input.className = 'popup-input placeholder-input'; - input.dataset.placeholderName = name; - input.dataset.fullMatch = ph.fullMatch; - input.dataset.isSecret = String(ph.isSecret); - input.placeholder = ph.hint || `Enter ${name.replace(/_/g, ' ').toLowerCase()}`; - input.style.cssText = 'flex:1; min-width:160px; padding:4px 8px; font-size:0.8rem;'; - // Pre-fill non-secret fields with the hint value as a sensible default; - // secret fields (tokens, keys) are intentionally left blank. - if (savedValues[name]) { - input.value = savedValues[name]; - } else if (window.RUNTIME_PARAM_DEFAULTS[name]) { - input.value = window.RUNTIME_PARAM_DEFAULTS[name]; - } else if (!ph.isSecret && ph.default) { - input.value = ph.default; - } - - row.appendChild(label); - row.appendChild(input); - - if (ph.isSecret) { - const toggleBtn = document.createElement('button'); - toggleBtn.type = 'button'; - toggleBtn.textContent = '👁'; - toggleBtn.title = 'Show / hide value'; - toggleBtn.style.cssText = 'padding:2px 8px; border:1px solid #ccc; border-radius:3px; background:#f8f9fa; cursor:pointer; font-size:0.85rem;'; - toggleBtn.onclick = () => { - input.type = input.type === 'password' ? 'text' : 'password'; - toggleBtn.textContent = input.type === 'password' ? '👁' : '🙈'; - }; - row.appendChild(toggleBtn); - } - - const meta = document.createElement('span'); - meta.style.cssText = 'font-size:0.68rem; color:#888;'; - const descText = ph.description || ''; - const cmdText = `cmd ${cmdIndices.join(', ')}`; - meta.textContent = descText ? `${descText} • ${cmdText}` : cmdText; - row.appendChild(meta); - - // Refs: clickable link badges after description - if (ph.refs && ph.refs.length > 0) { - ph.refs.forEach(ref => { - const a = document.createElement('a'); - a.href = ref.url; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - a.title = ref.label; - a.style.cssText = 'font-size:0.68rem; color:#0d6efd; text-decoration:none; white-space:nowrap; padding:1px 6px; border:1px solid #b3d7ff; border-radius:8px; background:#f0f7ff;'; - a.textContent = `🔗 ${ref.label}`; - a.onmouseover = () => { a.style.background = '#cce5ff'; }; - a.onmouseout = () => { a.style.background = '#f0f7ff'; }; - row.appendChild(a); - }); - } - - paramsPanel.appendChild(row); - - // Presets: grid of chips that click to fill the input - if (ph.presets && ph.presets.length > 0) { - const presetsWrapper = document.createElement('div'); - presetsWrapper.style.cssText = 'margin-bottom:8px; margin-left:138px;'; - - const presetsLabel = document.createElement('span'); - presetsLabel.style.cssText = 'font-size:0.65rem; color:#666; display:block; margin-bottom:3px;'; - presetsLabel.textContent = 'Presets:'; - presetsWrapper.appendChild(presetsLabel); - - const presetsGrid = document.createElement('div'); - presetsGrid.style.cssText = 'display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:4px;'; - - ph.presets.forEach(preset => { - const val = typeof preset === 'string' ? preset : preset.value; - const lbl = typeof preset === 'string' ? null : preset.label; - const chip = document.createElement('button'); - chip.type = 'button'; - chip.title = 'Click to fill'; - chip.style.cssText = 'padding:4px 8px; border:1px solid #b3d7ff; border-radius:6px; background:#e8f3ff; color:#0056b3; cursor:pointer; font-size:0.68rem; text-align:left; display:flex; flex-direction:column; gap:1px; width:100%;'; - chip.onmouseover = () => { chip.style.background = '#cce5ff'; }; - chip.onmouseout = () => { chip.style.background = '#e8f3ff'; }; - if (lbl) { - const ls = document.createElement('span'); - ls.style.cssText = 'font-size:0.62rem; color:#444; font-weight:600;'; - ls.textContent = lbl; - chip.appendChild(ls); - } - const vs = document.createElement('span'); - vs.style.cssText = 'font-family:monospace; font-size:0.68rem; color:#003d80; word-break:break-all; white-space:normal;'; - vs.textContent = val; - chip.appendChild(vs); - chip.onclick = () => { - const inp = document.getElementById(inputId); - if (inp) { inp.value = val; inp.dispatchEvent(new Event('input')); } - }; - presetsGrid.appendChild(chip); - }); - - presetsWrapper.appendChild(presetsGrid); - paramsPanel.appendChild(presetsWrapper); - } - }); - return; - } - - // ── Inline legacy mode (old popups without #cmdParamsPanel) ─── - const savedValues = {}; - cmdContainer.querySelectorAll('.placeholder-panel').forEach(panel => { - panel.querySelectorAll('.placeholder-input').forEach(input => { - if (input.value && input.dataset.cmdIndex && input.dataset.placeholderName) { - const ci = input.dataset.cmdIndex; - if (!savedValues[ci]) savedValues[ci] = {}; - savedValues[ci][input.dataset.placeholderName] = input.value; - } - }); - panel.remove(); - }); - - cmdContainer.querySelectorAll('[id^="cmdDiv"]').forEach((div, idx) => { - const cmdIndex = idx + 1; - const textarea = document.getElementById(`cmd${cmdIndex}`); - if (!textarea) return; - - const placeholders = window.extractPlaceholders(textarea.value); - if (placeholders.length === 0) return; - - const existingValues = savedValues[String(cmdIndex)] || {}; - const panel = document.createElement('div'); - panel.className = 'placeholder-panel'; - panel.style.cssText = 'margin:4px 0 8px 0; padding:8px 12px; background:#f0f7ff; border:1px solid #b3d7ff; border-radius:6px; font-size:0.8rem;'; - - placeholders.forEach(ph => { - const inputId = `ph_cmd${cmdIndex}_${ph.name}`; - const row = document.createElement('div'); - row.style.cssText = 'display:flex; align-items:center; gap:8px; margin-bottom:4px;'; - - const label = document.createElement('label'); - label.htmlFor = inputId; - label.style.cssText = 'min-width:100px; font-weight:600; color:#0d6efd; font-size:0.75rem; white-space:nowrap;'; - label.textContent = (ph.isSecret ? '🔒 ' : '📝 ') + ph.name; - - const input = document.createElement('input'); - input.id = inputId; - input.type = ph.isSecret ? 'password' : 'text'; - input.className = 'popup-input placeholder-input'; - input.dataset.cmdIndex = String(cmdIndex); - input.dataset.placeholderName = ph.name; - input.dataset.fullMatch = ph.fullMatch; - input.dataset.isSecret = String(ph.isSecret); - input.placeholder = ph.hint || `Enter ${ph.name.replace(/_/g, ' ').toLowerCase()}`; - input.style.cssText = 'flex:1; padding:4px 8px; font-size:0.8rem; border:1px solid #b3d7ff; border-radius:4px;'; - if (existingValues[ph.name]) { - input.value = existingValues[ph.name]; - } else if (window.RUNTIME_PARAM_DEFAULTS[ph.name]) { - input.value = window.RUNTIME_PARAM_DEFAULTS[ph.name]; - } else if (!ph.isSecret && ph.default) { - input.value = ph.default; - } - - row.appendChild(label); - row.appendChild(input); - - if (ph.isSecret) { - const toggleBtn = document.createElement('button'); - toggleBtn.type = 'button'; - toggleBtn.textContent = '👁'; - toggleBtn.title = 'Toggle visibility'; - toggleBtn.style.cssText = 'padding:2px 6px; border:1px solid #ccc; border-radius:3px; background:#f8f9fa; cursor:pointer; font-size:0.8rem;'; - toggleBtn.onclick = () => { - input.type = input.type === 'password' ? 'text' : 'password'; - toggleBtn.textContent = input.type === 'password' ? '👁' : '🙈'; - }; - row.appendChild(toggleBtn); - } - - if (ph.description) { - const desc = document.createElement('span'); - desc.style.cssText = 'font-size:0.65rem; color:#888; white-space:nowrap;'; - desc.textContent = ph.description; - row.appendChild(desc); - } - - panel.appendChild(row); - }); - - div.appendChild(panel); - }); -}; - -// ============================================================ -// Common HTML generators and utilities for Commands popups -// ============================================================ - -// Generate Commands section HTML -// ── Shared NodeGroup configuration ── -// Used when adding a NodeGroup to an existing Infra; mirrors the sections of the -// Infra creation flow (identity → labels → spec summary) so both look the same. -window.generateNodeGroupConfigHtml = function (opts = {}) { - const o = Object.assign({ - infraId: '', nodeGroupName: '', specSummaryHtml: '', totalNodes: 0, - }, opts); - const esc = window.escapeHtml || (s => String(s)); - - return ` - - - - - - - -`; -}; - -// Label editor wiring for the NodeGroup config section -window.setupNodeGroupLabelEditor = function (prefillLabels = {}, suggestedKeys = ['role']) { - const rows = document.getElementById('nodegroup-label-rows'); - if (!rows) return; - const esc = window.escapeHtml || (s => String(s)); - const addLabelRow = (k, v) => { - const row = document.createElement('div'); - row.style.cssText = 'display:flex; gap:6px; margin-bottom:4px;'; - row.innerHTML = ` - - `; - row.querySelector('.label-del').onclick = () => row.remove(); - rows.appendChild(row); - }; - const keys = Object.keys(prefillLabels || {}); - if (keys.length === 0) { - suggestedKeys.forEach(k => addLabelRow(k, '')); - } else { - keys.forEach(k => addLabelRow(k, prefillLabels[k])); - } - const addBtn = document.getElementById('add-label-row'); - if (addBtn) addBtn.onclick = () => addLabelRow('', ''); - - const countInput = document.getElementById('node-count'); - if (countInput) { - countInput.addEventListener('input', function () { - const perLocation = parseInt(this.value) || 1; - const locations = (window.nodeGroupRequestFromSpecList || []).length || 1; - const totalEl = document.getElementById('total-nodes'); - if (totalEl) totalEl.textContent = perLocation * locations; - }); - } -}; - -// Validate + read the NodeGroup config section -window.collectNodeGroupConfig = function () { - const nodeGroupName = (document.getElementById('nodegroup-name')?.value || '').trim(); - const ndCount = parseInt(document.getElementById('node-count')?.value) || 1; - if (!nodeGroupName) return { error: 'Please enter a NodeGroup name' }; - if (ndCount < 1 || ndCount > 10) return { error: 'Node count must be between 1 and 10' }; - - const labels = {}; - document.querySelectorAll('#nodegroup-label-rows > div').forEach(row => { - const k = (row.querySelector('.label-key')?.value || '').trim(); - const v = (row.querySelector('.label-val')?.value || '').trim(); - if (k) labels[k] = v; - }); - return { nodeGroupName, ndCount, labels }; -}; - -// ── Shared command composer ─ -// Single source for the "Predefined Scripts / Parameters / Commands" UI used by -// the Application Deployment popup, the post-deployment command dialog, and the -// NodeGroup add dialog. The markup is the one from Application Deployment, so -// that popup renders identically after the extraction. -// -// Wiring is unchanged: setupCommandsPopup() (didOpen) + collectCommands() / -// collectPhases() (preConfirm) work on the ids emitted here. -window.generateCommandComposerHtml = function (opts = {}) { - const o = Object.assign({ - commands: ['', '', ''], // initial command values - showScripts: true, // 📜 Predefined Scripts (with category tabs) - showParams: true, // 📋 Parameters (consolidated placeholder panel) - showCommands: true, // ⌨️ Commands - showPhases: false, // ordered post-deployment phases (postCommands[]) - showLabelSelector: false, // target filter by labels - labelSelectorOptional: true, - includeDeployOptions: true, // show the 'platform' script category - }, opts); - - const defaultCat = window._currentScriptCategory || 'llm-ollama'; - const cat = (window.predefinedScriptCategories || {})[defaultCat] || - (window.predefinedScriptCategories || {})['llm-ollama'] || { description: '', scripts: [] }; - - let html = ''; - - if (o.showScripts) { - html += ` - - -`; - } - - if (o.showParams) { - html += ` - - -`; - } - - if (o.showCommands) { - let cmdRows = ''; - for (let i = 1; i <= 3; i++) { - cmdRows += ` -
    - -
    `; - } - html += ` - - -`; - } - - if (o.showPhases) { - html += ` - -`; - } - - if (o.showLabelSelector) { - html += window.generateLabelSelectorHtml(o.labelSelectorOptional, true); - } - - return html; -}; - -// ── Post-deployment phase editor ─ -// Optional multi-phase bootstrap: each phase runs in order against its own -// target (all nodes / nodeGroup / labelSelector). Server contract: postCommands[]. -window.generatePhaseEditorHtml = function () { - return ` - -
    - -
    `; -}; - -window.togglePhaseEditor = function (on) { - const wrap = document.getElementById('phaseEditorWrap'); - const cmds = document.getElementById('cmdContainer'); - if (wrap) wrap.style.display = on ? '' : 'none'; - if (cmds) cmds.style.display = on ? 'none' : ''; - if (on && document.querySelectorAll('#phaseList .phase-block').length === 0) addPhaseBlock(); -}; - -window.addPhaseBlock = function () { - const list = document.getElementById('phaseList'); - if (!list) return; - const idx = list.querySelectorAll('.phase-block').length + 1; - const block = document.createElement('div'); - block.className = 'phase-block'; - block.style.cssText = 'border:1px solid #d0e3ff;border-radius:6px;padding:8px;margin-bottom:8px;background:#fff;'; - block.innerHTML = ` -
    - Phase ${idx} - - - - -
    - `; - block.querySelector('.phase-target-type').onchange = function () { - const val = block.querySelector('.phase-target-val'); - val.disabled = !this.value; - if (!this.value) val.value = ''; - }; - block.querySelector('.phase-del').onclick = function () { - block.remove(); - document.querySelectorAll('#phaseList .phase-block').forEach((b, i) => { - b.querySelector('.phase-title').textContent = 'Phase ' + (i + 1); - }); - }; - list.appendChild(block); -}; - -// Returns postCommands[] when the phase editor is active, else null -window.collectPhases = function () { - const toggle = document.getElementById('usePhasesToggle'); - if (!toggle || !toggle.checked) return null; - const phases = []; - document.querySelectorAll('#phaseList .phase-block').forEach(block => { - const commands = block.querySelector('.phase-cmds').value - .split('\n').map(c => c.trim()).filter(c => c.length > 0); - if (commands.length === 0) return; - const phase = { command: commands }; - const type = block.querySelector('.phase-target-type').value; - const val = block.querySelector('.phase-target-val').value.trim(); - if (type && val) phase[type] = val; - if (block.querySelector('.phase-continue').checked) phase.continueOnError = true; - phases.push(phase); - }); - return phases.length > 0 ? phases : null; -}; - -window.generateCommandsHtml = function (defaultCommands = ['', '', '']) { - let html = ` -

    [Commands]

    -
    `; - - for (let i = 0; i < 3; i++) { - const value = defaultCommands[i] || ''; - html += ` -
    - Command ${i + 1}: - -
    `; - } - - html += ` - -
    `; - - return html; -}; - -// Generate Predefined Scripts section HTML -// Predefined scripts organized by category -window.predefinedScriptCategories = { - 'llm-ollama': { - label: '🤖 LLM (Ollama)', - description: 'Ollama-based LLM service deployment', - scripts: [ - { value: 'Nvidia', label: '1. Install GPU Driver (NVIDIA/AMD auto-detect)', step: 1 }, - { value: 'RebootVM', label: '2. Reboot Node', step: 2 }, - { value: 'Nvidia-Status', label: '3. Check GPU Driver (NVIDIA/AMD)', step: 3 }, - { value: 'Ollama', label: '4. Install Ollama', step: 4 }, - { value: 'OllamaPull', label: '5. Pull LLM Model', step: 5 }, - { value: 'Netdata', label: '6. Install Monitoring', step: 6, optional: true }, - { value: 'OpenWebUI', label: '7. Install Open WebUI', step: 7 } - ] - }, - 'llm-vllm': { - label: '🤖 LLM (vLLM)', - description: 'vLLM-based high-performance LLM service', - scripts: [ - { value: 'Nvidia', label: '1. Install GPU Driver (NVIDIA/AMD auto-detect)', step: 1, targetLabel: 'accelerator=gpu' }, - { value: 'RebootVM', label: '2. Reboot Node', step: 2, targetLabel: 'accelerator=gpu' }, - { value: 'Nvidia-Status', label: '3. Check GPU Driver (NVIDIA/AMD)', step: 3, targetLabel: 'accelerator=gpu' }, - { value: 'vLLM', label: '4. Install vLLM', step: 4, targetLabel: 'accelerator=gpu' }, - { value: 'vLLMServe', label: '5. Serve LLM Model', step: 5, targetLabel: 'accelerator=gpu' }, - { value: 'Netdata', label: '6. Install Monitoring', step: 6, optional: true }, - { value: 'OpenWebUI-vLLM', label: '7. Install Open WebUI (vLLM)', step: 7 }, - { value: 'TelemetrySensor', label: '8. Setup GPU Telemetry Sensor', step: 8, experimental: true, targetLabel: 'accelerator=gpu' }, - { value: 'TelemetryMonitor', label: '9. Setup Monitoring Server', step: 9, experimental: true, targetLabel: 'role=observability' }, - { value: 'TelemetryExport', label: '10. Export Metrics to CSV', step: 10, experimental: true, targetLabel: 'role=observability' }, - { value: 'HermesAgent', label: 'Deploy Hermes Agent', targetLabel: 'accelerator=gpu' } - ] - }, - 'llm-benchmark': { - label: '📊 LLM Benchmark', - description: 'LLM benchmark environment (vLLM + GuideLLM + Monitoring)', - scripts: [ - { value: 'Nvidia', label: '1. Install GPU Driver (NVIDIA/AMD auto-detect)', step: 1, targetLabel: 'accelerator=gpu' }, - { value: 'RebootVM', label: '2. Reboot Node', step: 2, targetLabel: 'accelerator=gpu' }, - { value: 'Nvidia-Status', label: '3. Check GPU Driver (NVIDIA/AMD)', step: 3, targetLabel: 'accelerator=gpu' }, - { value: 'BenchmarkTarget', label: '4. Setup Benchmark Target (vLLM+Model+Telemetry)', step: 4, targetLabel: 'accelerator=gpu' }, - { value: 'BenchmarkManager', label: '5. Setup Benchmark Manager (Monitoring+Tools)', step: 5, targetLabel: 'role=benchmark', targetLabels: ['role=benchmark', 'role=observability'] }, - { value: 'vLLMServe', label: '6. Serve LLM Model (change model on GPU nodes)', step: 6, targetLabel: 'accelerator=gpu' }, - { value: 'RunBenchmark', label: '7. Run Benchmark', step: 7, targetLabel: 'role=benchmark', targetLabels: ['role=benchmark', 'role=observability'] }, - { value: 'BenchmarkTelemetryExport', label: '8. Export Metrics to CSV', step: 8, optional: true, targetLabel: 'role=benchmark', targetLabels: ['role=benchmark', 'role=observability'] } - ] - }, - 'k8s': { - label: '☸️ Kubernetes', - description: 'Kubernetes cluster deployment — Standard, GPU, or llm-d (distributed LLM inference). Steps 4-6 are for GPU workers; steps 9-13 are for llm-d only. Steps 14-17 are demo apps and visualization tools.', - scripts: [ - { value: 'Setup-WireGuard', label: '0. Setup WireGuard VPN (optional)', step: 0, optional: true }, - { value: 'K8sControlPlane-Deploy', label: '1. Deploy Control Plane (Standard; opt CNI=cilium)', step: 1, targetLabel: 'role=control' }, - { value: 'K8sLlmdControlPlane', label: '1-alt. Deploy Control Plane (llm-d)', step: 1, targetLabel: 'role=control', optional: true }, - { value: 'K8sGetJoinCommand', label: '2. Get Join Command', step: 2, targetLabel: 'role=control', syncMode: true }, - { value: 'K8sGetKubeconfig', label: '3. Get Kubeconfig (Base64)', step: 3, targetLabel: 'role=control', syncMode: true }, - { value: 'K8sGetKubeconfigExternal', label: '3-alt. Get Kubeconfig for a designated IP (nested/NAT — re-issues cert SAN)', step: 3, targetLabel: 'role=control', optional: true, syncMode: true }, - { value: 'Nvidia', label: '4. Install GPU Driver — NVIDIA/AMD auto-detect (GPU worker only)', step: 4, targetLabel: 'accelerator=gpu', optional: true }, - { value: 'RebootVM', label: '5. Reboot Node (GPU worker only)', step: 5, targetLabel: 'role=node', optional: true }, - { value: 'Nvidia-Status', label: '6. Check GPU Driver — NVIDIA/AMD (GPU worker only)', step: 6, targetLabel: 'accelerator=gpu', optional: true, syncMode: true }, - { value: 'K8sWorker-Deploy', label: '7. Deploy Worker & Join Cluster', step: 7, targetLabel: 'role=node' }, - { value: 'K8sClusterStatus', label: '8. Check Cluster Status', step: 8, targetLabel: 'role=control', syncMode: true }, - { value: 'K8sGpuStatus', label: '9. Check GPU Operator Status (GPU/llm-d)', step: 9, targetLabel: 'role=control', optional: true, syncMode: true }, - { value: 'LlmdCheck', label: '10. Check llm-d Prerequisites (llm-d only)', step: 10, targetLabel: 'role=control', optional: true, syncMode: true }, - { value: 'LlmdDeployWithModel', label: '11. Deploy llm-d with Model (llm-d only)', step: 11, targetLabel: 'role=control', optional: true }, - { value: 'LlmdStatus', label: '12. Check llm-d Status (llm-d only)', step: 12, targetLabel: 'role=control', optional: true, syncMode: true }, - { value: 'LlmdUninstall', label: '13. Uninstall llm-d (llm-d only)', step: 13, targetLabel: 'role=control', optional: true }, - { value: 'K8sDemoApp', label: '14. Deploy Demo Web App (NodePort)', step: 14, targetLabel: 'role=control', syncMode: true }, - { value: 'K8sScaleApp', label: '15. Scale Demo App (set replica count)', step: 15, targetLabel: 'role=control', syncMode: true }, - { value: 'K8sLoadTest', label: '16. Run Load Test (Batch Job → demo app)', step: 16, targetLabel: 'role=control', syncMode: true }, - { value: 'K8sDashboard', label: '17. Install K8s Dashboard (Visualization)', step: 17, targetLabel: 'role=control', syncMode: true }, - { value: 'K8sPortainer', label: '18. Install Portainer CE (Visual Cluster Monitor)', step: 18, targetLabel: 'role=control', syncMode: true }, - { value: 'WeaveScopeK8s', label: '19. Install Weave Scope (Live Topology Map — NodePort 30040)', step: 19, targetLabel: 'role=control', optional: true }, - { value: 'K8sHubbleUI', label: '20. Enable Hubble UI (Cilium service map — NodePort 30012)', step: 20, targetLabel: 'role=control', optional: true } - ] - }, - 'kserve': { - label: '🚀 KServe (LLM Serving)', - description: 'KServe(RawDeployment) + vLLM + Open WebUI on K8s — build the cluster with steps 1-4 (GPU driver steps only if not pre-installed on the image), then deploy serving with steps 5-8. Multiple LLMs: repeat step 6 with unique name/port per model (one GPU each; 5-opt shares a GPU across models), then re-run step 8 to connect all of them to the WebUI', - scripts: [ - { value: 'K8sControlPlane-Deploy', label: '1. Deploy Control Plane (opt CNI=cilium)', step: 1, targetLabel: 'role=control' }, - { value: 'K8sGetJoinCommand', label: '2. Get Join Command', step: 2, targetLabel: 'role=control', syncMode: true }, - { value: 'Nvidia', label: '2-opt. Install GPU Driver (skip on GPU-ready images)', step: 2, targetLabel: 'accelerator=gpu', optional: true }, - { value: 'RebootVM', label: '2-opt. Reboot GPU Node (after driver install)', step: 2, targetLabel: 'accelerator=gpu', optional: true }, - { value: 'K8sWorker-Deploy', label: '3. Deploy Worker & Join Cluster', step: 3, targetLabel: 'role=node' }, - { value: 'K8sClusterStatus', label: '4. Check Cluster Status', step: 4, targetLabel: 'role=control', syncMode: true }, - { value: 'KServeDeploy', label: '5. Deploy KServe Stack (GPU Operator + cert-manager + KServe)', step: 5, targetLabel: 'role=control' }, - { value: 'KServeGpuTimeslice', label: '5-opt. Enable GPU Time-Slicing (share one GPU across LLMs)', step: 5, targetLabel: 'role=control', optional: true }, - { value: 'KServeGpuMig', label: '5-opt. Enable MIG Partitioning (A100/H100 only, isolated slices)', step: 5, targetLabel: 'role=control', optional: true }, - { value: 'KServeVllmServe', label: '6. Serve LLM Model (repeat per model: unique name/port)', step: 6, targetLabel: 'role=control' }, - { value: 'KServeStatus', label: '7. Check Serving Status', step: 7, targetLabel: 'role=control', syncMode: true }, - { value: 'KServeOpenWebUI', label: '8. Install Open WebUI (KServe)', step: 8, targetLabel: 'role=control' }, - { value: 'HermesAgent-KServe', label: '9. Deploy Hermes Agent (uses KServe endpoint)', step: 9, targetLabel: 'role=control', optional: true, experimental: true }, - { value: 'KServeExampleA', label: '10. Example: serve sklearn model (no image build)', step: 10, targetLabel: 'role=control', optional: true }, - { value: 'KServeExampleC', label: '11. Example: plain Deployment serving (no KServe)', step: 11, targetLabel: 'role=control', optional: true }, - { value: 'KServeRegistryDeploy', label: '12. Deploy Private Registry (in-cluster)', step: 12, targetLabel: 'role=control', optional: true }, - { value: 'KServeRegistryAccess', label: '13. Enable Registry Access (run on ALL nodes)', step: 13, optional: true }, - { value: 'KServeExampleB', label: '14. Example: build & serve custom model (registry)', step: 14, targetLabel: 'role=control', optional: true }, - { value: 'KServeMonitoring', label: '15. Deploy Monitoring (Prometheus + Grafana, GPU/LLM dashboards)', step: 15, targetLabel: 'role=control', optional: true } - ] - }, - 'mcp': { - label: '🔌 MCP (agentgateway)', - description: 'AI-operated model registry demo — a Hugging Face-style model catalog (web + REST + PostgreSQL) exposed as MCP adapters, federated by agentgateway into one external endpoint (NodePort 30900). Build the cluster with steps 1-4 (CPU nodes are enough), then deploy the registry + MCP stack with steps 5-11', - scripts: [ - { value: 'K8sControlPlane-Deploy', label: '1. Deploy Control Plane (opt CNI=cilium)', step: 1, targetLabel: 'role=control' }, - { value: 'K8sGetJoinCommand', label: '2. Get Join Command', step: 2, targetLabel: 'role=control', syncMode: true }, - { value: 'K8sWorker-Deploy', label: '3. Deploy Worker & Join Cluster', step: 3, targetLabel: 'role=node' }, - { value: 'K8sClusterStatus', label: '4. Check Cluster Status', step: 4, targetLabel: 'role=control', syncMode: true }, - { value: 'McpRegistryDb', label: '5. Deploy Model Registry DB (+ agri/livestock sample catalog)', step: 5, targetLabel: 'role=control' }, - { value: 'McpRegistryBackend', label: '6. Deploy Model Registry Backend (REST API)', step: 6, targetLabel: 'role=control' }, - { value: 'McpRegistryWeb', label: '7. Deploy Model Registry Web (NodePort 30902)', step: 7, targetLabel: 'role=control' }, - { value: 'McpRegistryWebScaleOut', label: '7-opt. Scale OUT Registry Web (traffic-burst demo — watch in Headlamp)', step: 7, targetLabel: 'role=control', optional: true, syncMode: true }, - { value: 'McpRegistryWebScaleIn', label: '7-opt. Scale IN Registry Web (back to 1 replica)', step: 7, targetLabel: 'role=control', optional: true, syncMode: true }, - { value: 'McpServers', label: '8. Deploy MCP Adapters (catalog tools + read-only SQL)', step: 8, targetLabel: 'role=control' }, - { value: 'McpServingAdapter', label: '8-opt. Deploy KServe Serving Adapter (same-cluster KServe)', step: 8, targetLabel: 'role=control', optional: true }, - { value: 'McpAgentgateway', label: '9. Deploy agentgateway (federated MCP endpoint)', step: 9, targetLabel: 'role=control' }, - { value: 'McpE2eTest', label: '10. Run E2E Demo (tools via gateway)', step: 10, targetLabel: 'role=control', syncMode: true }, - { value: 'McpStatus', label: '11. Check MCP Stack Status', step: 11, targetLabel: 'role=control', syncMode: true }, - { value: 'WeaveScopeK8s', label: '11-opt. Install Weave Scope (Live Topology Map — NodePort 30040)', step: 11, targetLabel: 'role=control', optional: true }, - { value: 'K8sHubbleUI', label: '11-opt. Enable Hubble UI (Cilium service map — NodePort 30012)', step: 11, targetLabel: 'role=control', optional: true } - ] - }, - 'ml-ray': { - label: '🔬 ML (Ray)', - description: 'Ray distributed computing cluster', - scripts: [ - { value: 'RayHead-Deploy', label: '1. Deploy Ray Head', step: 1, targetLabel: 'role=head' }, - { value: 'RayWorker-Deploy', label: '2. Deploy Ray Worker', step: 2, targetLabel: 'role=worker' } - ] - }, - 'game': { - label: '🎮 Game', - description: 'Game server deployment', - scripts: [ - { value: 'Xonotic', label: 'Xonotic (FPS Game)', step: 1 }, - { value: 'Westward', label: 'Westward (Strategy)', step: 2 } - ] - }, - 'platform': { - label: '🏗️ Platform', - description: 'Cloud-Barista platform deployment', - scripts: [ - { value: 'CB-TB-Deploy', label: 'Deploy CB-Tumblebug', step: 1 }, - { value: 'M-CMP-Install', label: '1. M-CMP: Install (Stage 1)', step: 2 }, - { value: 'M-CMP-Pull', label: '2. M-CMP: Pull Images', step: 3 }, - { value: 'M-CMP-Run', label: '3. M-CMP: Run (Stage 2)', step: 4 }, - { value: 'M-CMP-Info', label: '4. M-CMP: Check Status', step: 5 }, - { value: 'M-CMP-Stop', label: '5. M-CMP: Stop', step: 6 } - ] - }, - 'openstack-devstack': { - label: '☁️ OpenStack (DevStack)', - description: 'Deploy OpenStack via DevStack — lightweight, dev/test only, does NOT survive reboot', - scripts: [ - { value: 'DevStack-Install', label: '1. Install DevStack', step: 1, experimental: true }, - { value: 'DevStack-Info', label: '2. Get Registration Info', step: 2 }, - { value: 'DevStack-UpdateEndpoints', label: '3. Update Endpoints (IP changed)', step: 3 }, - { value: 'DevStack-Clean', label: '4. Clean / Rollback', step: 4 } - ] - }, - 'openstack-kolla': { - label: '☁️ OpenStack (Kolla-Ansible)', - description: 'Deploy OpenStack via Kolla-Ansible — Docker-based, production-grade, survives reboot', - scripts: [ - { value: 'Kolla-Install', label: '1. Install Kolla-Ansible', step: 1, experimental: true }, - { value: 'Kolla-Info', label: '2. Get Registration Info', step: 2 }, - { value: 'Kolla-UpdateEndpoints', label: '3. Update Endpoints (IP changed)', step: 3 }, - { value: 'Kolla-Clean', label: '4. Clean / Rollback', step: 4 } - ] - }, - 'monitoring': { - label: '📊 Monitoring', - description: 'Monitoring and observability tools', - scripts: [ - { value: 'Netdata', label: 'Install Netdata', step: 1 }, - { value: 'Netdata-Status', label: 'Check Netdata Status', step: 2 }, - { value: 'WeaveScope', label: 'Install WeaveScope', step: 3 } - ] - }, - 'network': { - label: '🌐 Network', - description: 'Network configuration tools', - scripts: [ - { value: 'Setup-CrossNAT', label: 'Setup Cross-Cloud NAT', step: 1 }, - { value: 'Setup-WireGuard', label: 'Setup WireGuard Mesh VPN', step: 2 }, - { value: 'PortForward-Add', label: '🔀 Port Forwarding: Add rule', step: 3 }, - { value: 'PortForward-List', label: '🔀 Port Forwarding: List rules', step: 4 }, - { value: 'PortForward-Del', label: '🔀 Port Forwarding: Delete rule', step: 5 }, - { value: 'PortForward-Save', label: '🔀 Port Forwarding: Save (persist on reboot)', step: 6, optional: true } - ] - }, - 'utility': { - label: '🔧 Utility', - description: 'Utility scripts and tools', - scripts: [ - { value: 'RebootVM', label: 'Reboot Node', step: 1 }, - { value: 'Nginx', label: 'Install Web Server', step: 2 }, - { value: 'MvToWebRoot', label: 'Move files to web root (/var/www/html/)', step: 3 }, - { value: 'ExtractToWebRoot', label: 'Extract archive to web root (auto-detect format)', step: 4 }, - { value: 'Jitsi', label: 'Install Jitsi (Video Conf)', step: 5 }, - { value: 'Stress', label: 'CPU Stress Test', step: 6 } - ] - }, - 'all': { - label: '📋 All Scripts', - description: 'View all available scripts', - scripts: [] // Will be populated dynamically - } -}; - -// Build the "All Scripts" category from all other categories -// Using plain object instead of Map for better compatibility -(function() { - const allScriptsArray = []; - const categories = window.predefinedScriptCategories; - const catKeys = Object.keys(categories); - const seenScripts = {}; - - // Iterate through categories in order to group by category - for (let i = 0; i < catKeys.length; i++) { - const catKey = catKeys[i]; - if (catKey === 'all') continue; - - const cat = categories[catKey]; - const scripts = cat.scripts || []; - - for (let j = 0; j < scripts.length; j++) { - const script = scripts[j]; - // Only add if not already seen (avoid duplicates) - if (!seenScripts[script.value]) { - seenScripts[script.value] = true; - var entry = { - value: script.value, - label: script.label, - step: script.step, - optional: script.optional, - experimental: script.experimental, - category: cat.label - }; - if (script.targetLabel) entry.targetLabel = script.targetLabel; - if (script.syncMode) entry.syncMode = script.syncMode; - allScriptsArray.push(entry); - } - } - } - - // Sort by category first, then by label within category - allScriptsArray.sort(function(a, b) { - // First sort by category - const catCompare = a.category.localeCompare(b.category); - if (catCompare !== 0) return catCompare; - // Then sort by label within same category - return a.label.localeCompare(b.label); - }); - - window.predefinedScriptCategories.all.scripts = allScriptsArray; -})(); - -window.generatePredefinedScriptsHtml = function (includeDeployOptions = false) { - const categories = window.predefinedScriptCategories; - - // Generate category tabs (remember last selected category) - let categoryTabs = ''; - const defaultCategory = window._currentScriptCategory || 'llm-ollama'; - - Object.entries(categories).forEach(([key, cat]) => { - // Skip platform category if not includeDeployOptions - if (key === 'platform' && !includeDeployOptions) return; - - const isActive = key === defaultCategory ? 'active' : ''; - const bgColor = key === defaultCategory ? '#007bff' : '#e9ecef'; - const textColor = key === defaultCategory ? 'white' : '#495057'; - - categoryTabs += ``; - }); - - // Generate initial script list for default category - const defaultCat = categories[defaultCategory]; - let scriptOptions = window.generateScriptOptionsHtml(defaultCat.scripts); - - return ` -

    [Predefined Scripts]

    -
    -
    - ${categoryTabs} -
    -
    - 📝 ${defaultCat.description} -
    -
    - - -
    -
    `; -}; - -// Generate script category tabs HTML only (for inline use) -window.generateScriptCategoryTabsHtml = function(includeDeployOptions = false) { - const categories = window.predefinedScriptCategories; - const defaultCategory = window._currentScriptCategory || 'llm-ollama'; - let html = ''; - - Object.entries(categories).forEach(([key, cat]) => { - if (key === 'platform' && !includeDeployOptions) return; - - const isActive = key === defaultCategory; - const bgColor = isActive ? '#007bff' : '#e9ecef'; - const textColor = isActive ? 'white' : '#495057'; - - html += ``; - }); - - return html; -}; - -// Generate script options HTML for a category -window.generateScriptOptionsHtml = function(scripts) { - let options = ``; - scripts.forEach(script => { - const optionalTag = script.optional ? ' [Optional]' : ''; - const experimentalTag = script.experimental ? ' [Experimental]' : ''; - const categoryTag = script.category ? ` [${script.category}]` : ''; - options += ``; - }); - return options; -}; - -// Switch script category -window.switchScriptCategory = function(categoryKey) { - const categories = window.predefinedScriptCategories; - const category = categories[categoryKey]; - if (!category) return; - - // Update active tab styling - document.querySelectorAll('.script-category-tab').forEach(tab => { - if (tab.dataset.category === categoryKey) { - tab.classList.add('active'); - tab.style.background = '#007bff'; - tab.style.color = 'white'; - } else { - tab.classList.remove('active'); - tab.style.background = '#e9ecef'; - tab.style.color = '#495057'; - } - }); - - // Update description - const descDiv = document.getElementById('categoryDescription'); - if (descDiv) { - descDiv.innerHTML = `📝 ${category.description}`; - } - - // Update script dropdown - const scriptSelect = document.getElementById('predefinedScripts'); - if (scriptSelect) { - scriptSelect.innerHTML = window.generateScriptOptionsHtml(category.scripts); - } - - // Store current category - window._currentScriptCategory = categoryKey; -}; - -// Generate Label Selector section HTML with clickable label chips -// usePopupStyle: true for new POPUP_STYLES, false for legacy style -window.generateLabelSelectorHtml = function (isOptional = false, usePopupStyle = false) { - if (usePopupStyle) { - const hintText = isOptional ? '(Optional - filter Nodes by labels)' : ''; - return ` - `; - } - - // Legacy style (for backward compatibility) - const optionalText = isOptional ? ' (optional)' : ''; - return ` -

    [Label Selector]${optionalText}

    -
    -
    - Click labels below to select... -
    - - -

    Available Labels (click to add/remove)

    -
    - Select an Infra to see available labels... -
    - -
    `; -}; - -// Clear label selector input -window.clearLabelSelector = function() { - const labelInput = document.getElementById('labelSelector'); - if (labelInput) { - labelInput.value = ''; - updateSelectedLabelsDisplay(); - updateLabelMatchPreview(); - updateAvailableLabelChipStyles(); - } -}; - -// Setup Clear All button listener (avoiding inline onclick for XSS safety) -window.setupClearLabelButtonListener = function() { - const clearBtn = document.getElementById('clearLabelSelector'); - if (clearBtn && !clearBtn._listenerAttached) { - clearBtn.addEventListener('click', window.clearLabelSelector); - clearBtn._listenerAttached = true; - } -}; - -// Extract unique labels from Infra VMs -window.extractLabelsFromInfra = function(infraId) { - const infraData = window.cloudBaristaCentralData?.infraData || []; - const infra = infraData.find(m => m.id === infraId || m.name === infraId); - - if (!infra || !infra.node || infra.node.length === 0) { - return { labels: {}, ndCount: 0, nodes: [] }; - } - - const labelMap = {}; // key -> Set of values - const nodes = []; - - infra.node.forEach(nd => { - nodes.push({ - id: nd.id, - name: nd.name || nd.id, - label: nd.label || {} - }); - - if (nd.label && typeof nd.label === 'object') { - Object.entries(nd.label).forEach(([key, value]) => { - if (!labelMap[key]) { - labelMap[key] = new Set(); - } - labelMap[key].add(value); - }); - } - }); - - // Convert Sets to arrays for easier handling - const labels = {}; - Object.entries(labelMap).forEach(([key, valueSet]) => { - labels[key] = Array.from(valueSet); - }); - - return { labels, ndCount: infra.node.length, nodes }; -}; - -// Update available labels display when Infra is selected -window.updateAvailableLabels = function(infraId) { - const container = document.getElementById('availableLabelsContainer'); - if (!container) return; - - const { labels, ndCount, nodes } = extractLabelsFromInfra(infraId); - - // Store nodes data for preview - window._currentInfraNodes = nodes; - window._currentInfraLabels = labels; - - if (Object.keys(labels).length === 0) { - container.innerHTML = 'No labels found in this Infra\'s Nodes'; - return; - } - - const labelEntries = Object.entries(labels); - const maxVisibleKeys = 2; - const hasMore = labelEntries.length > maxVisibleKeys; - - let html = '
    '; - - // Group by label key - labelEntries.forEach(([key, values], index) => { - const isHidden = index >= maxVisibleKeys; - html += `
    - - ${window.escapeHtml(key)} - -
    `; - - values.forEach(value => { - const labelPair = `${key}=${value}`; - html += ``; - }); - - html += '
    '; - }); - - html += '
    '; - - // Add "Show more" / "Show less" toggle if needed - if (hasMore) { - const hiddenCount = labelEntries.length - maxVisibleKeys; - html += `
    - -
    `; - } - - html += `

    Total: ${Object.keys(labels).length} label keys, ${ndCount} Nodes

    `; - - container.innerHTML = html; - - // Setup delegated event listeners for label chips (XSS-safe) - setupLabelChipEventListeners(container); - - // Update chip styles based on current selection - updateAvailableLabelChipStyles(); - updateLabelMatchPreview(); -}; - -// Setup delegated event listeners for label value chips (avoids XSS from inline onclick) -window.setupLabelChipEventListeners = function(container) { - if (!container) return; - - // Delegated click handler for label-value-chip buttons - container.addEventListener('click', function(event) { - const chip = event.target.closest('.label-value-chip'); - if (chip && chip.dataset.label) { - window.addLabelToSelector(chip.dataset.label); - } - }); - - // Hover effects for chips - container.addEventListener('mouseover', function(event) { - const chip = event.target.closest('.label-value-chip'); - if (chip && !chip.classList.contains('selected')) { - chip.style.background = '#0056b3'; - } - }); - - container.addEventListener('mouseout', function(event) { - const chip = event.target.closest('.label-value-chip'); - if (chip && !chip.classList.contains('selected')) { - chip.style.background = '#007bff'; - } - }); -}; - -// Setup delegated event listener for selected labels display (remove buttons) -window.setupSelectedLabelsEventListeners = function() { - const displayDiv = document.getElementById('selectedLabelsDisplay'); - if (!displayDiv || displayDiv._labelListenerAttached) return; - - displayDiv.addEventListener('click', function(event) { - const removeBtn = event.target.closest('.remove-label-btn'); - if (removeBtn && removeBtn.dataset.label) { - window.removeLabelFromSelector(removeBtn.dataset.label); - } - }); - - displayDiv._labelListenerAttached = true; -}; - -// Toggle visibility of additional label groups -window.toggleLabelGroups = function() { - const groups = document.querySelectorAll('.label-group[data-label-group]'); - const toggleBtn = document.getElementById('toggleLabelsBtn'); - if (!toggleBtn) return; - - const isExpanded = toggleBtn.dataset.expanded === 'true'; - - groups.forEach((group, index) => { - if (index >= 2) { - group.style.display = isExpanded ? 'none' : 'flex'; - } - }); - - if (isExpanded) { - const hiddenCount = groups.length - 2; - toggleBtn.innerHTML = `Show ${hiddenCount} more label${hiddenCount > 1 ? 's' : ''} ▼`; - toggleBtn.dataset.expanded = 'false'; - } else { - toggleBtn.innerHTML = 'Show less ▲'; - toggleBtn.dataset.expanded = 'true'; - } -}; - -// Update the selected labels display (chip-style in input area) -window.updateSelectedLabelsDisplay = function() { - const displayDiv = document.getElementById('selectedLabelsDisplay'); - const placeholder = document.getElementById('labelPlaceholder'); - const labelInput = document.getElementById('labelSelector'); - - if (!displayDiv || !labelInput) return; - - const labelValue = labelInput.value.trim(); - const labels = labelValue.split(',').map(l => l.trim()).filter(l => l && l.includes('=')); - - if (labels.length === 0) { - displayDiv.innerHTML = 'Click labels below to select...'; - return; - } - - let html = ''; - labels.forEach(label => { - const [key, value] = label.split('='); - html += ` - ${window.escapeHtml(key)} - = - ${window.escapeHtml(value)} - - `; - }); - - displayDiv.innerHTML = html; - - // Setup delegated event listeners for remove buttons (XSS-safe) - window.setupSelectedLabelsEventListeners(); -}; - -// Remove a specific label from selector -window.removeLabelFromSelector = function(labelPair) { - const labelInput = document.getElementById('labelSelector'); - if (!labelInput) return; - - const currentLabels = labelInput.value.split(',').map(l => l.trim()).filter(l => l); - const newLabels = currentLabels.filter(l => l !== labelPair); - labelInput.value = newLabels.join(','); - - updateSelectedLabelsDisplay(); - updateLabelMatchPreview(); - updateAvailableLabelChipStyles(); -}; - -// Update available label chip styles based on selection -window.updateAvailableLabelChipStyles = function() { - const labelInput = document.getElementById('labelSelector'); - if (!labelInput) return; - - const selectedLabels = labelInput.value.split(',').map(l => l.trim()).filter(l => l); - const chips = document.querySelectorAll('.label-value-chip'); - - chips.forEach(chip => { - const labelPair = chip.dataset.label; - if (selectedLabels.includes(labelPair)) { - chip.classList.add('selected'); - chip.style.background = '#28a745'; - chip.style.boxShadow = '0 0 0 2px #28a74566'; - } else { - chip.classList.remove('selected'); - chip.style.background = '#007bff'; - chip.style.boxShadow = 'none'; - } - }); -}; - -// Add label to selector input -window.addLabelToSelector = function(labelPair) { - const labelInput = document.getElementById('labelSelector'); - if (!labelInput) return; - - const currentValue = labelInput.value.trim(); - - // Check if label already exists - const existingLabels = currentValue.split(',').map(l => l.trim()).filter(l => l); - if (existingLabels.includes(labelPair)) { - // Remove if already exists (toggle behavior) - const newLabels = existingLabels.filter(l => l !== labelPair); - labelInput.value = newLabels.join(','); - } else { - // Add new label - if (currentValue) { - labelInput.value = currentValue + ',' + labelPair; - } else { - labelInput.value = labelPair; - } - } - - updateSelectedLabelsDisplay(); - updateLabelMatchPreview(); - updateAvailableLabelChipStyles(); -}; - -// Update preview of matching VMs -window.updateLabelMatchPreview = function() { - const previewDiv = document.getElementById('labelMatchPreview'); - const matchingCountSpan = document.getElementById('matchingNodeCount'); - const totalCountSpan = document.getElementById('totalNodeCount'); - const matchingListDiv = document.getElementById('matchingVmList'); - const labelInput = document.getElementById('labelSelector'); - - if (!previewDiv || !labelInput || !window._currentInfraNodes) return; - - const nodes = window._currentInfraNodes; - const labelSelector = labelInput.value.trim(); - - totalCountSpan.textContent = nodes.length; - - if (!labelSelector) { - previewDiv.style.display = 'none'; - return; - } - - // Parse label selector into array of {key, value} pairs - // Each pair must be satisfied (AND condition) - const requiredLabelPairs = []; - labelSelector.split(',').forEach(pair => { - const [key, value] = pair.split('=').map(s => s.trim()); - if (key && value) { - requiredLabelPairs.push({ key, value }); - } - }); - - // Find matching VMs - ALL label pairs must match (AND condition) - const matchingNodes = nodes.filter(nd => { - if (!nd.label || requiredLabelPairs.length === 0) return false; - - // Every required label pair must exist in Node's labels - return requiredLabelPairs.every(({ key, value }) => { - return nd.label[key] === value; - }); - }); - - matchingCountSpan.textContent = matchingNodes.length; - - if (matchingNodes.length > 0) { - matchingListDiv.innerHTML = matchingNodes.map(nd => - `${escapeHtml(nd.name)}` - ).join(''); - previewDiv.style.background = '#d4edda'; - } else { - matchingListDiv.innerHTML = 'No Nodes match the current selector'; - previewDiv.style.background = '#f8d7da'; - } - - previewDiv.style.display = 'block'; -}; - -// Helper function to escape HTML -window.escapeHtml = function(text) { - const div = document.createElement('div'); - div.textContent = text; - // textContent->innerHTML escapes & < > but NOT quotes; escape them too so - // the result is safe inside double- or single-quoted HTML attributes - // (e.g., data-nodeid="${escapeHtml(id)}") as well as in text nodes. - return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); -}; - -// Setup Infra selector change handler for label updates -window.setupInfraSelectorForLabels = function() { - const infraSelector = document.getElementById('infraSelector'); - if (!infraSelector) return; - - // Update labels when Infra selection changes - infraSelector.addEventListener('change', function() { - // Clear previous selection when changing Infra - const labelInput = document.getElementById('labelSelector'); - if (labelInput) { - labelInput.value = ''; - updateSelectedLabelsDisplay(); - } - updateAvailableLabels(this.value); - }); - - // Note: Label input is now hidden, so no need for direct input listener - // Interaction happens through chip clicks which call addLabelToSelector() - - // Initialize with current selection - if (infraSelector.value) { - updateAvailableLabels(infraSelector.value); - } -}; - -// Setup Commands popup (call in didOpen) -window.setupCommandsPopup = function (maxCommands = 10) { - // Define addCmd function - window.addCmd = function () { - const cmdContainer = document.getElementById('cmdContainer'); - if (!cmdContainer) return; - - const cmdCount = cmdContainer.querySelectorAll('[id^="cmdDiv"]').length + 1; - - if (maxCommands > 0 && cmdCount > maxCommands) { - Swal.showValidationMessage(`Maximum ${maxCommands} commands allowed`); - return; - } - - const newCmdDiv = document.createElement('div'); - newCmdDiv.id = `cmdDiv${cmdCount}`; - newCmdDiv.className = 'cmdRow'; - newCmdDiv.style.marginBottom = '6px'; - newCmdDiv.innerHTML = ` - - `; - - // Insert before the buttons row (find by #addCmd button's parent) - const addCmdBtn = cmdContainer.querySelector('#addCmd'); - const buttonsRow = addCmdBtn ? addCmdBtn.parentElement : null; - if (buttonsRow && buttonsRow.parentElement === cmdContainer) { - cmdContainer.insertBefore(newCmdDiv, buttonsRow); - } else { - // Fallback: append before the last child if it's the buttons row - const lastChild = cmdContainer.lastElementChild; - if (lastChild && lastChild.querySelector('#addCmd')) { - cmdContainer.insertBefore(newCmdDiv, lastChild); - } else { - cmdContainer.appendChild(newCmdDiv); - } - } - }; - - // Setup predefined script dropdown listener - const scriptSelect = document.getElementById('predefinedScripts'); - if (scriptSelect) { - scriptSelect.removeEventListener('change', window.loadPredefinedScript); - scriptSelect.addEventListener('change', window.loadPredefinedScript); - } - - // Setup placeholder auto-detection: re-render placeholder inputs when textarea content changes - const phCmdContainer = document.getElementById('cmdContainer'); - if (phCmdContainer && !phCmdContainer.dataset.phDelegationAttached) { - phCmdContainer.dataset.phDelegationAttached = 'true'; - let phDebounce; - phCmdContainer.addEventListener('input', (e) => { - if (e.target.tagName === 'TEXTAREA') { - clearTimeout(phDebounce); - phDebounce = setTimeout(() => window.renderPlaceholderInputs(), 500); - } - }); - } - - // Initial render of placeholder inputs for pre-filled commands - window.renderPlaceholderInputs(); -}; - -// Collect commands from popup (call in preConfirm). -// Substitutes tokens with values before returning. -// - Consolidated mode (#cmdParamsPanel present): reads from shared params panel; -// each unique param is entered once and substituted into all commands. -// - Legacy inline mode: reads per-command .placeholder-input panels. -window.collectCommands = function () { - const commands = []; - const cmdContainer = document.getElementById('cmdContainer'); - if (!cmdContainer) return commands; - - // Build consolidated substitution map from #cmdParamsPanel (if present) - const consolidatedMap = {}; // fullMatch → value - const paramsPanel = document.getElementById('cmdParamsPanel'); - if (paramsPanel) { - paramsPanel.querySelectorAll('.placeholder-input').forEach(input => { - const fullMatch = input.dataset.fullMatch; - // Always substitute: empty value → empty string (prevents literal reaching remote) - if (fullMatch) consolidatedMap[fullMatch] = input.value; - }); - } - const useConsolidated = paramsPanel !== null; - - cmdContainer.querySelectorAll('[id^="cmdDiv"]').forEach((div, index) => { - const cmdInput = document.getElementById(`cmd${index + 1}`); - if (cmdInput && cmdInput.value && cmdInput.value.trim()) { - let cmdText = cmdInput.value.trim(); - - if (useConsolidated) { - Object.entries(consolidatedMap).forEach(([fullMatch, value]) => { - // Use a function replacement so '$' in the value is preserved literally - // (a string replacement would interpret $$, $&, $`, $' specials). - cmdText = cmdText.replaceAll(fullMatch, () => value); - }); - } else { - div.querySelectorAll('.placeholder-input').forEach(phInput => { - const fullMatch = phInput.dataset.fullMatch; - if (fullMatch) cmdText = cmdText.replaceAll(fullMatch, () => phInput.value); - }); - } - - commands.push(cmdText); - } - }); - - return commands; -}; - -// ============================================================ - -/** - * Update the script-level quick reference panel (#scriptQuickRef). - * Used for scripts that have no parameters but still need refs/presets. - */ -window.updateScriptQuickRef = function(scriptValue) { - const panel = document.getElementById('scriptQuickRef'); - if (!panel) return; - const meta = window.SCRIPT_QUICK_REF && window.SCRIPT_QUICK_REF[scriptValue]; - const refsDiv = document.getElementById('scriptQuickRefRefs'); - const presetsDiv = document.getElementById('scriptQuickRefPresets'); - if (!meta || (!meta.refs?.length && !meta.presets?.length)) { - panel.style.display = 'none'; - return; - } - if (refsDiv) refsDiv.innerHTML = ''; - if (presetsDiv) presetsDiv.innerHTML = ''; - if (meta.refs && meta.refs.length > 0 && refsDiv) { - const h = document.createElement('span'); - h.style.cssText = 'font-size:0.72rem; color:#555; font-weight:600; margin-right:6px;'; - h.textContent = '🔗 References:'; - refsDiv.appendChild(h); - meta.refs.forEach(ref => { - const a = document.createElement('a'); - a.href = ref.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; - a.textContent = ref.label; - a.style.cssText = 'font-size:0.72rem; color:#0d6efd; text-decoration:none; padding:1px 8px; border:1px solid #b3d7ff; border-radius:8px; background:#f0f7ff; margin-right:6px;'; - a.onmouseover = () => { a.style.background = '#cce5ff'; }; - a.onmouseout = () => { a.style.background = '#f0f7ff'; }; - refsDiv.appendChild(a); - }); - } - if (meta.presets && meta.presets.length > 0 && presetsDiv) { - presetsDiv.style.cssText = 'display:flex; flex-wrap:wrap; align-items:center; gap:4px; margin-top:6px;'; - const h = document.createElement('span'); - h.style.cssText = 'font-size:0.72rem; color:#555; font-weight:600; white-space:nowrap;'; - h.textContent = '📋 Quick Copy:'; - presetsDiv.appendChild(h); - meta.presets.forEach(preset => { - const val = preset.value || preset; - const lbl = preset.label || val; - const chip = document.createElement('button'); - chip.type = 'button'; chip.textContent = lbl; chip.title = val; - chip.style.cssText = 'padding:3px 10px; border:1px solid #ffc107; border-radius:10px; background:#fff8e1; color:#664d03; cursor:pointer; font-size:0.7rem; white-space:nowrap;'; - chip.onmouseover = () => { chip.style.background = '#ffe69c'; }; - chip.onmouseout = () => { chip.style.background = '#fff8e1'; }; - chip.onclick = () => { - const orig = chip.textContent; - const done = () => { chip.textContent = '✅ Copied!'; setTimeout(() => { chip.textContent = orig; }, 1500); }; - if (navigator.clipboard) { navigator.clipboard.writeText(val).then(done).catch(() => { document.execCommand('copy'); done(); }); } - else { const el = document.createElement('textarea'); el.value = val; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); done(); } - }; - presetsDiv.appendChild(chip); - }); - } - panel.style.display = ''; -}; - -// loadPredefinedScript function for loading predefined script -// Supports two modes: Replace (default) and Append -window.loadPredefinedScript = function () { - const scriptTypeSelect = document.getElementById("predefinedScripts"); - if (!scriptTypeSelect) return; - - const scriptType = scriptTypeSelect.value; - if (!scriptType) return; - - // Check if append mode is enabled - const appendModeCheckbox = document.getElementById("scriptAppendMode"); - const isAppendMode = appendModeCheckbox && appendModeCheckbox.checked; - - console.log("Loading predefined script:", scriptType, "| Mode:", isAppendMode ? "Append" : "Replace"); - - // Get the new commands from predefined script - setDefaultRemoteCommandsByApp(scriptType); - // Filter out empty strings to avoid appending blank commands - const newCommands = [...defaultRemoteCommand].filter(cmd => cmd && cmd.trim()); - console.log("New commands from script:", newCommands); - - // Raise the timeout for long-running usecases. Only ever raises it, so a value the - // user typed themselves is never cut down. - const timeoutField = document.getElementById("timeoutMinutes"); - if (timeoutField && defaultRemoteCommandTimeout > 0) { - const current = parseInt(timeoutField.value, 10) || 0; - if (defaultRemoteCommandTimeout > current) { - timeoutField.value = defaultRemoteCommandTimeout; - console.log("Raised command timeout to", defaultRemoteCommandTimeout, "min for", scriptType); - } - } - - if (isAppendMode) { - // Append mode: compact existing commands (remove empty gaps), then append new commands - // Step 1: Collect all existing non-empty commands - const existingCommands = []; - let maxCmdIndex = 0; - - for (let i = 1; i <= 20; i++) { - const cmdField = document.getElementById(`cmd${i}`); - if (!cmdField) break; - maxCmdIndex = i; - if (cmdField.value && cmdField.value.trim()) { - existingCommands.push(cmdField.value.trim()); - } - } - console.log("Existing commands:", existingCommands.length, "New commands:", newCommands.length); - - // Step 2: Combine existing + new commands - const allCommands = [...existingCommands, ...newCommands]; - - // Step 3: Fill cmd fields from cmd1 (compact) - for (let i = 0; i < allCommands.length; i++) { - const targetIndex = i + 1; - let cmdField = document.getElementById(`cmd${targetIndex}`); - - // If field doesn't exist, create it - if (!cmdField && window.addCmd) { - window.addCmd(); - cmdField = document.getElementById(`cmd${targetIndex}`); - } - - if (cmdField) { - cmdField.value = allCommands[i]; - autoResizeTextarea(cmdField); - console.log(`Set cmd${targetIndex}:`, cmdField.value); - } - } - - // Step 4: Remove extra empty cmd fields (keep minimum 3) - const minCmdCount = 3; - const targetCmdCount = Math.max(allCommands.length, minCmdCount); - - for (let i = maxCmdIndex; i > targetCmdCount; i--) { - const cmdDiv = document.getElementById(`cmdDiv${i}`); - if (cmdDiv) { - cmdDiv.remove(); - console.log(`Removed cmdDiv${i}`); - } - } - - // Clear any remaining fields beyond allCommands.length but within targetCmdCount - for (let i = allCommands.length + 1; i <= targetCmdCount; i++) { - const cmdField = document.getElementById(`cmd${i}`); - if (cmdField) { - cmdField.value = ""; - autoResizeTextarea(cmdField); - } - } - } else { - // Replace mode: clear and set new commands - for (let i = 0; i < newCommands.length; i++) { - const cmdField = document.getElementById(`cmd${i + 1}`); - if (cmdField) { - cmdField.value = newCommands[i] || ""; - autoResizeTextarea(cmdField); - console.log(`Set cmd${i + 1} to:`, cmdField.value); - } - } - // Clear remaining fields - for (let i = newCommands.length + 1; i <= 10; i++) { - const cmdField = document.getElementById(`cmd${i}`); - if (cmdField) { - cmdField.value = ""; - autoResizeTextarea(cmdField); - } - } - } - - // Reset the select to allow selecting the same script again - scriptTypeSelect.selectedIndex = 0; - - // Auto-set label selector based on script's targetLabel (if available) - window.applyScriptTargetLabel(scriptType); - - // Auto-toggle sync mode based on script's syncMode property - window.applyScriptSyncMode(scriptType); - - // Update script-level quick reference panel (refs + copy presets) - window.updateScriptQuickRef(scriptType); - - // Render placeholder input fields for any detected placeholders in commands - window.renderPlaceholderInputs(); -}; - -// Apply targetLabel from predefined script to Label Selector -// Only sets the label if: -// 1. The script has a targetLabel defined -// 2. The target label is available in the current Infra's VMs -window.applyScriptTargetLabel = function(scriptValue) { - if (!scriptValue) return; - - // Find the script definition with targetLabel / targetLabels - const currentCategory = window._currentScriptCategory; - let targetLabel = null; - let targetLabels = null; - - // Search in current category first, then all categories - const categoriesToSearch = currentCategory - ? [currentCategory, ...Object.keys(window.predefinedScriptCategories).filter(k => k !== currentCategory)] - : Object.keys(window.predefinedScriptCategories); - - for (const catKey of categoriesToSearch) { - const cat = window.predefinedScriptCategories[catKey]; - if (!cat || !cat.scripts) continue; - const script = cat.scripts.find(s => s.value === scriptValue); - if (script && (script.targetLabel || script.targetLabels)) { - targetLabel = script.targetLabel; - targetLabels = script.targetLabels || null; - break; - } - } - - // Build ordered list of labels to try (targetLabels array first, then single targetLabel as fallback) - const labelsToTry = targetLabels ? [...targetLabels] : (targetLabel ? [targetLabel] : []); - if (labelsToTry.length === 0) return; // No targetLabel for this script - - // Check if any target label is available in current Infra's VMs - const availableLabels = window._currentInfraLabels; - if (!availableLabels || Object.keys(availableLabels).length === 0) return; - - let matchedLabel = null; - for (const candidate of labelsToTry) { - const [candKey, candValue] = candidate.split('='); - if (!candKey || !candValue) continue; - const availableValues = availableLabels[candKey]; - if (availableValues && availableValues.includes(candValue)) { - matchedLabel = candidate; - break; - } - } - - if (!matchedLabel) return; // None of the target labels are available in the Infra - - // Set the label in the selector - const labelInput = document.getElementById('labelSelector'); - if (!labelInput) return; - - // Replace current label (don't append - the script target is specific) - labelInput.value = matchedLabel; - - // Update UI - if (window.updateSelectedLabelsDisplay) window.updateSelectedLabelsDisplay(); - if (window.updateLabelMatchPreview) window.updateLabelMatchPreview(); - if (window.updateAvailableLabelChipStyles) window.updateAvailableLabelChipStyles(); - - console.log(`Auto-set label selector: ${matchedLabel} (from script: ${scriptValue})`); -}; - -// Auto-toggle sync mode checkbox based on script's syncMode property -window.applyScriptSyncMode = function(scriptValue) { - const syncToggle = document.getElementById('syncModeToggle'); - if (!syncToggle) return; - - if (!scriptValue) { - syncToggle.checked = false; - return; - } - - // Find the script definition with syncMode - const currentCategory = window._currentScriptCategory; - let hasSyncMode = false; - - const categoriesToSearch = currentCategory - ? [currentCategory, ...Object.keys(window.predefinedScriptCategories).filter(k => k !== currentCategory)] - : Object.keys(window.predefinedScriptCategories); - - for (const catKey of categoriesToSearch) { - const cat = window.predefinedScriptCategories[catKey]; - if (!cat || !cat.scripts) continue; - const script = cat.scripts.find(s => s.value === scriptValue); - if (script) { - hasSyncMode = !!script.syncMode; - break; - } - } - - syncToggle.checked = hasSyncMode; - if (hasSyncMode) { - console.log(`Auto-enabled sync mode for script: ${scriptValue}`); - } -}; - -// ============================================================ -// Remote Command Result Viewer -// ============================================================ -// Shows a formatted, human-readable view of remote command execution results. -// Groups output by Node and command index for easy readability. -// Provides a "View Raw JSON" button to see the original JSON output. - -/** - * Truncates text to last N lines and returns { truncated, visible, fullText, totalLines } - */ -function _tailLines(text, n) { - if (!text || !text.trim()) return null; - const lines = text.split('\n'); - // Remove trailing empty line (common from shell output) - while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop(); - if (lines.length === 0) return null; - const totalLines = lines.length; - const visible = lines.slice(-n); - return { truncated: totalLines > n, visible, fullText: lines.join('\n'), totalLines }; -} - -/** - * Escapes HTML then converts URLs and bare IP:port patterns into clickable links. - * Handles: http(s)://..., and standalone IP:port like 52.14.140.219:8081 - * - * Strategy: Extract $$MARKER patterns FIRST and stash their chip HTML in a side table, - * leaving opaque placeholder tokens (\u0001RC_CHIP_\u0002) in the text. Then escape - * the text, linkify URLs/IPs (which can safely process the now-clean text), and finally - * substitute the chip HTML back in. This prevents the linkifier from mangling chip - * attributes like data-copy="http://...", which used to break the buttons. - */ -function _escAndLinkify(text, ctx = {}) { - // Inject chip CSS once on first call - if (!document.getElementById('rc-chip-styles')) { - const s = document.createElement('style'); - s.id = 'rc-chip-styles'; - s.textContent = '.rc-result-item{display:inline-flex;align-items:center;gap:2px;vertical-align:middle}.rc-btn{display:inline-flex;align-items:center;border:none;background:none;cursor:pointer;padding:0 2px;font-size:12px;line-height:1;opacity:0.6;transition:opacity .12s,transform .12s;text-decoration:none;vertical-align:middle}.rc-btn:hover{opacity:1;transform:scale(1.2)}.rc-btn:active{transform:scale(0.9)}@keyframes rc-flash{0%,100%{opacity:1}50%{opacity:0.25}}.rc-flash{animation:rc-flash .4s ease}'; - document.head.appendChild(s); - } - if (!text) return ''; - const linkStyle = 'color:#64b5f6; text-decoration:underline;'; - - // Attribute-safe escape (for use inside HTML attribute values that we emit directly) - const attrEsc = (s) => String(s) - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(//g, '>'); - // Text-safe escape (for label rendering) - const textEsc = (s) => window.escapeHtml(String(s)); - // ENDPOINT: replace 0.0.0.0/localhost bind address with the actual node public IP - const resolveUrl = (url) => - ctx.nodeIp ? url.replace(/\/\/(0\.0\.0\.0|localhost)(?=[:\/]|$)/, `//${ctx.nodeIp}`) : url; - - // Phase 1: Extract $$MARKER chips into a side table, leaving placeholders behind. - const chips = []; - const PLACEHOLDER_RE = /\u0001RC_CHIP_(\d+)\u0002/g; - const RESULT_MARKER = /\$\$([A-Z]+)\[([^\]]{0,80})\]\(([^)]{0,400})\)/g; - const withPlaceholders = String(text).replace(RESULT_MARKER, (_, type, label, value) => { - let chipHtml; - switch (type) { - case 'ENDPOINT': { - const resolved = resolveUrl(value); - if (!/^https?:\/\//i.test(resolved)) return label; // plain label, will be escaped later - chipHtml = `${textEsc(label)}` - + ` 🔗` - + ` ` - + ``; - break; - } - case 'FILEPATH': { - const di = attrEsc(ctx.infraId || ''); - const dn = attrEsc(ctx.nodeId || ''); - chipHtml = `${textEsc(label)}` - + ` ` - + ` ` - + ``; - break; - } - case 'CREDENTIAL': - chipHtml = `${textEsc(label)}: ••••` - + ` ` - + ` ` - + ``; - break; - case 'CMD': - chipHtml = `${textEsc(label)}` - + ` ` - + ``; - break; - default: - return ''; // suppress unknown marker tokens - } - const idx = chips.length; - chips.push(chipHtml); - return `\u0001RC_CHIP_${idx}\u0002`; - }); - - // Phase 2: HTML-escape entire text (placeholder bytes are not HTML-special, they survive). - let safe = window.escapeHtml(withPlaceholders); - - // Phase 3: linkify http(s) URLs (chips are absent from `safe` at this point). - safe = safe.replace(/(https?:\/\/[^\s<&'")\]]+)/g, (url) => - `${url}` - ); - - // Phase 4: linkify bare IP(:port)(/path) outside existing tags. - const parts = safe.split(/(]*>.*?<\/a>)/g); - for (let i = 0; i < parts.length; i++) { - if (i % 2 === 0) { - parts[i] = parts[i].replace( - /\b((?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?::\d{1,5})?(?:\/[^\s<&'"]*)?)\b/g, - (m) => `${m}` - ); - } - } - safe = parts.join(''); - - // Phase 5: substitute chip HTML back in. This must run AFTER linkification so the - // chip HTML (with attributes like data-copy="http://...") is never re-processed. - safe = safe.replace(PLACEHOLDER_RE, (_, idx) => chips[Number(idx)] || ''); - - return safe; -} - -/** - * Shared click handler for $$RESULT chip buttons (.rc-btn). Returns an async listener - * suitable for a document-level capturing 'click' handler. Used by both the - * non-streaming result viewer and the live streaming session modal so the chip - * buttons behave identically in both views. - * - * Behavior: - * - data-path → open File Transfer modal preset to Download mode, with the - * Infra / Node / Source Path on Node fields pre-filled. - * - data-cred → toggle credential visibility (eye icon). - * - data-copy → write value to clipboard with a brief visual ack. - * - * @param {HTMLElement} popup The SweetAlert popup element to scope clicks to. - * @returns {(e: Event) => void} - */ -function _createRcBtnClickHandler(popup) { - return async (e) => { - const btn = e.target.closest('.rc-btn'); - if (!btn || !popup.contains(btn)) return; - // chips already navigate via href; nothing else to do here. - if (btn.tagName === 'A') return; - e.stopPropagation(); - - if (btn.dataset.path !== undefined) { - // FILEPATH: open File Transfer modal in Download mode, preset target & source path. - const infra = btn.dataset.infra || ''; - const node = btn.dataset.node || ''; - const path = btn.dataset.path || ''; - if (!path) { - if (typeof errorAlert === 'function') errorAlert('Cannot download: source path is missing.'); - return; - } - const orig = btn.textContent; - btn.textContent = '📂'; - try { - if (typeof window.transferFileToInfra === 'function') { - await window.transferFileToInfra({ - mode: 'download', - infraId: infra, - nodeId: node, - sourcePath: path, - }); - } else { - if (typeof errorAlert === 'function') errorAlert('File Transfer is unavailable.'); - } - } finally { - btn.textContent = orig; - } - return; - } - - if (btn.dataset.cred !== undefined) { - const shown = btn.dataset.shown === '1'; - btn.dataset.shown = shown ? '0' : '1'; - const item = btn.closest('.rc-result-item'); - const mask = item ? item.querySelector('.rc-mask') : null; - if (mask) mask.textContent = shown ? '••••' : btn.dataset.cred; - return; - } - - if (btn.dataset.copy !== undefined) { - const text = btn.dataset.copy; - try { - if (navigator.clipboard && window.isSecureContext !== false) { - await navigator.clipboard.writeText(text); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - } - } catch (_) { /* swallow — visual ack covers UX */ } - const orig = btn.textContent; - btn.textContent = '✓'; - btn.classList.add('rc-flash'); - setTimeout(() => { btn.textContent = orig; btn.classList.remove('rc-flash'); }, 1000); - } - }; -} - -/** - * Shows formatted remote command execution results in a SweetAlert window. - * Groups output by Node → Command for readability. - * @param {Object} data - The API response with data.results[] - */ -function showRemoteCmdResult(data, appliedDnsUrl, infraId = '') { - if (!data || !Array.isArray(data.results) || data.results.length === 0) { - displayJsonData(data, typeInfo); - return; - } - - const results = data.results; - const ndCount = results.length; - const TAIL_LINES = 10; - const MAX_CMD_DISPLAY = 120; // Max chars for command preview in header - - // --- Build per-Node tab content --- - const ndTabs = results.map((nd, ndIdx) => { - const ndLabel = nd.nodeId || `node-${ndIdx}`; - const ndIp = nd.nodeIp || ''; - const ndCtx = { infraId, nodeId: nd.nodeId || '', nodeIp: ndIp }; - const hasError = nd.error && nd.error.trim(); - const cmdKeys = Object.keys(nd.command || {}).sort((a, b) => Number(a) - Number(b)); - - // Build command groups - const cmdGroupsHtml = cmdKeys.map((key) => { - const cmdText = (nd.command[key] || '').trim(); - const stdoutInfo = _tailLines(nd.stdout?.[key] || '', TAIL_LINES); - const stderrInfo = _tailLines(nd.stderr?.[key] || '', TAIL_LINES); - const cmdIdx = Number(key) + 1; - - // Truncated command display (long curl commands, etc.) - const cmdShort = cmdText.length > MAX_CMD_DISPLAY ? cmdText.substring(0, MAX_CMD_DISPLAY - 3) + '...' : cmdText; - - let html = ` -
    -
    - CMD ${cmdIdx} - ${window.escapeHtml(cmdShort)} -
    `; - - // stdout block - if (stdoutInfo) { - const blockId = `stdout-${ndIdx}-${key}`; - html += ` -
    -
    - stdout - ${stdoutInfo.totalLines} line${stdoutInfo.totalLines > 1 ? 's' : ''} -
    -
    - ${stdoutInfo.truncated ? ` - -
    - -
    ${_escAndLinkify(stdoutInfo.visible.join('\n'), ndCtx)}
    -
    - ` : ` -
    ${_escAndLinkify(stdoutInfo.fullText, ndCtx)}
    - `} -
    -
    `; - } - - // stderr block (only if non-empty) - if (stderrInfo) { - const blockId = `stderr-${ndIdx}-${key}`; - html += ` -
    -
    - stderr - ${stderrInfo.totalLines} line${stderrInfo.totalLines > 1 ? 's' : ''} -
    -
    - ${stderrInfo.truncated ? ` - -
    - -
    ${_escAndLinkify(stderrInfo.visible.join('\n'), ndCtx)}
    -
    - ` : ` -
    ${_escAndLinkify(stderrInfo.fullText, ndCtx)}
    - `} -
    -
    `; - } - - html += `
    `; // end cmd group border - return html; - }).join(''); - - // Node-level error - const ndErrorHtml = hasError ? ` -
    - Error: ${window.escapeHtml(nd.error)} -
    ` : ''; - - return { ndLabel, ndIp, ndIdx, cmdGroupsHtml, ndErrorHtml, cmdCount: cmdKeys.length }; - }); - - // --- Determine if we need Node tabs or single Node view --- - const buildNodeContent = (nd) => ` -
    - ${window.escapeHtml(nd.ndLabel)} - ${nd.ndIp ? `(${window.escapeHtml(nd.ndIp)})` : ''} - ${nd.cmdCount} command${nd.cmdCount > 1 ? 's' : ''} -
    - ${nd.ndErrorHtml} - ${nd.cmdGroupsHtml}`; - - let bodyHtml; - if (ndCount === 1) { - bodyHtml = buildNodeContent(ndTabs[0]); - } else { - // Node tab buttons - const tabBtns = ndTabs.map((nd, i) => ` - `).join(''); - - const tabPanels = ndTabs.map((nd, i) => ` -
    - ${buildNodeContent(nd)} -
    `).join(''); - - bodyHtml = ` -
    - ${tabBtns} -
    - ${tabPanels}`; - } - - // --- Summary bar --- - const totalCmds = results.reduce((s, nd) => s + Object.keys(nd.command || {}).length, 0); - const hasAnyError = results.some(nd => (nd.error && nd.error.trim())); - const hasAnyStderr = results.some(nd => { - const keys = Object.keys(nd.stderr || {}); - return keys.some(k => nd.stderr[k] && nd.stderr[k].trim()); - }); - const statusIcon = hasAnyError ? '⚠️' : (hasAnyStderr ? '⚡' : '✅'); - const statusColor = hasAnyError ? '#d32f2f' : (hasAnyStderr ? '#e65100' : '#2e7d32'); - const statusText = hasAnyError ? 'Error' : (hasAnyStderr ? 'Completed (with stderr)' : 'Success'); - - const dnsLinkHtml = appliedDnsUrl ? ` -
    ` : ''; - - const summaryHtml = ` - ${dnsLinkHtml} -
    -
    - ${statusIcon} - ${statusText} - ${ndCount} Node${ndCount > 1 ? 's' : ''} · ${totalCmds} command${totalCmds > 1 ? 's' : ''} -
    - -
    `; - - // --- Show SweetAlert --- - let rcBtnClick; // shared between didOpen and willClose for cleanup - Swal.fire({ - title: '🖥️ Remote Command Result', - width: 750, - html: ` -
    - ${summaryHtml} - ${bodyHtml} -
    `, - showConfirmButton: true, - confirmButtonText: 'Close', - didOpen: () => { - // Tab switching logic - const popup = Swal.getPopup(); - popup.querySelectorAll('.rcr-tab-btn').forEach(btn => { - btn.addEventListener('click', () => { - const idx = btn.dataset.idx; - popup.querySelectorAll('.rcr-tab-btn').forEach(b => { - b.classList.remove('rcr-tab-active'); - b.style.background = '#f1f3f5'; - b.style.color = '#888'; - b.style.fontWeight = '400'; - }); - btn.classList.add('rcr-tab-active'); - btn.style.background = '#fff'; - btn.style.color = '#333'; - btn.style.fontWeight = '600'; - popup.querySelectorAll('.rcr-tab-panel').forEach(p => { - p.style.display = p.dataset.idx === idx ? 'block' : 'none'; - }); - }); - }); - - // "Raw JSON" button → show original JSON viewer - const rawBtn = popup.querySelector('#rcr-raw-json-btn'); - if (rawBtn) { - rawBtn.addEventListener('click', () => { - displayJsonData(data, typeInfo); - }); - } - - // Scroll all stdout/stderr tail blocks to bottom - popup.querySelectorAll('pre').forEach(pre => { - pre.scrollTop = pre.scrollHeight; - }); - - // $$RESULT action buttons: document-level capture fires at the very top of the - // event chain — before SweetAlert2's popup handler or any other listener. - rcBtnClick = _createRcBtnClickHandler(popup); - document.addEventListener('click', rcBtnClick, true); - }, - willClose: () => { - if (rcBtnClick) document.removeEventListener('click', rcBtnClick, true); - }, - }); -} -window.showRemoteCmdResult = showRemoteCmdResult; - -// ============================================================ -// Real-time Streaming Command Sessions (SSE-based) -// ============================================================ -// Architecture: SSE streams run in background, independent of any modal. -// The Swal modal is just a "view" into a session. Closing it does NOT -// kill the stream. Users can re-open any session from the floating badge. -// ============================================================ - -/** - * Global registry of active command streaming sessions. - * Key: xRequestId, Value: session object - */ -window._cmdStreamSessions = {}; - -/** - * Create a new streaming session, start SSE consumption in background, - * and open the streaming modal. - */ -// Toggle blur/reveal for the command banner in a streaming modal. -// Called via data-xreqid attribute so xRequestId never needs to be embedded in JS inside an HTML attribute. -window._cmdRevealToggle = function (xReqId, revealed) { - const s = window._cmdStreamSessions && window._cmdStreamSessions[xReqId]; - if (!s) return; - s.commandRevealed = !!revealed; - if (s.rebuildCallback) s.rebuildCallback(); -}; - -function startStreamingSession(streamUrl, username, password, xRequestId, infraId, spinnerId, appliedDnsUrl, templateCommands) { - const session = { - xRequestId, - infraId, - spinnerId, - streamUrl, - appliedDnsUrl: appliedDnsUrl || null, - templateCommands: templateCommands || [], // pre-substitution command text (no real secrets) - commandRevealed: false, // blur/reveal toggle state — survives rebuildModal() calls - startTime: Date.now(), - nodeState: {}, // { nodeId: { status, stdoutLines: [], stderrLines: [], statusInfo: null } } - nodeIpMap: {}, // { nodeId: publicIP } populated asynchronously from Tumblebug infra API - doneSummary: null, - abortController: new AbortController(), - rebuildCallback: null, // set when modal is open, null when closed - cleanupTimer: null, - error: null, // SSE transport/connection error - commandError: null, // Server-side command execution error (from CommandDone summary) - cancelArmed: {}, // { nodeId: expiryTs } — two-click confirm state for per-Node cancel - cancelPending: {}, // { nodeId: true } — cancel API request in flight - }; - - window._cmdStreamSessions[xRequestId] = session; - updateStreamingBadge(); - - // Best-effort lookup of each Node's public IP so $$ENDPOINT[…](http://0.0.0.0:…) - // can be rewritten to the actual reachable URL in the live streaming view. - // Failures are non-fatal: the URL simply stays at its original 0.0.0.0/localhost. - (async () => { - try { - const cfg = (typeof getConfig === 'function') ? getConfig() : {}; - const ns = cfg.namespace || window.configNamespace; - if (!cfg.hostname || !cfg.port || !ns || !infraId) return; - const res = await axios.get( - `${tbApiBase()}/ns/${ns}/infra/${infraId}`, - { auth: { username: cfg.username, password: cfg.password } } - ); - const nodes = (res.data && res.data.node) || []; - nodes.forEach((nd) => { - if (nd && nd.id) session.nodeIpMap[nd.id] = nd.publicIP || ''; - }); - if (session.rebuildCallback) session.rebuildCallback(); - } catch (err) { - console.warn('[Streaming] Failed to fetch Node IPs for endpoint rewrite:', err && err.message); - } - })(); - - const getOrCreateNode = (nodeId) => { - if (!session.nodeState[nodeId]) { - session.nodeState[nodeId] = { status: 'Queued', stdoutLines: [], stderrLines: [], statusInfo: null }; - } - return session.nodeState[nodeId]; - }; - - // Start background SSE consumption - consumeSSEStream(streamUrl, username, password, session.abortController, (event) => { - // console.log('[SSE] Event received:', event.type, event.nodeId || '', event); - if (event.type === 'CommandStatus' && event.nodeId) { - const nd = getOrCreateNode(event.nodeId); - nd.status = event.status?.status || nd.status; - nd.statusInfo = event.status || nd.statusInfo; - // commandIndex is required to build the per-Node taskId ({xRequestId}:{nodeId}:{index}) - // for the task cancel API - if (typeof event.commandIndex === 'number' && event.commandIndex > 0) nd.commandIndex = event.commandIndex; - } else if (event.type === 'CommandLog' && event.nodeId && event.log) { - const nd = getOrCreateNode(event.nodeId); - if (typeof event.commandIndex === 'number' && event.commandIndex > 0) nd.commandIndex = event.commandIndex; - const line = event.log.line || ''; - if (event.log.stream === 'stdout') { - nd.stdoutLines.push(line); - } else if (event.log.stream === 'stderr') { - nd.stderrLines.push(line); - } - } else if (event.type === 'CommandDone') { - const totalNodes = Object.keys(session.nodeState).length; - session.doneSummary = event.summary || { totalNodes, completedNodes: 0, failedNodes: 0, elapsedSeconds: 0 }; - // Propagate server-side error from CommandDone summary - if (event.summary && event.summary.error) { - session.commandError = event.summary.error; - } - removeSpinnerTask(session.spinnerId); - // Auto-cleanup session after 5 minutes - session.cleanupTimer = setTimeout(() => { - delete window._cmdStreamSessions[xRequestId]; - updateStreamingBadge(); - }, 5 * 60 * 1000); - } - updateStreamingBadge(); - // Notify modal view if open - if (session.rebuildCallback) session.rebuildCallback(); - }).catch(err => { - if (err.name !== 'AbortError') { - console.error('SSE stream error:', err); - session.error = err.message || 'Connection failed'; - removeSpinnerTask(session.spinnerId); - updateStreamingBadge(); - if (session.rebuildCallback) session.rebuildCallback(); - } - }); - - // Periodic timer to update "Waiting..." elapsed counter and detect timeout - session._waitingTimer = setInterval(() => { - if (session.doneSummary || session.error || session.commandError) { - clearInterval(session._waitingTimer); - return; - } - const elapsed = (Date.now() - session.startTime) / 1000; - // No events yet: the run may already have finished before we subscribed - // (short bootstrap commands complete in seconds). Fall back to the stored - // result instead of leaving the user with a scary timeout message. - if (elapsed > 8 && Object.keys(session.nodeState).length === 0 && !session._fallbackTried) { - session._fallbackTried = true; - tryPostCommandResultFallback(session); - } - if (elapsed > 60 && Object.keys(session.nodeState).length === 0) { - session.error = 'No events received within 60 seconds. The command may have failed silently.'; - removeSpinnerTask(session.spinnerId); - clearInterval(session._waitingTimer); - } - updateStreamingBadge(); - if (session.rebuildCallback) session.rebuildCallback(); - }, 2000); - - // Open modal immediately - openStreamingSessionModal(xRequestId); -} - -/** - * Late-subscriber fallback: when a stream produces no events, the run has usually - * already finished. Read the Infra and render its stored post-deployment result so - * the user sees the outcome instead of an empty stream. - */ -function tryPostCommandResultFallback(session) { - if (!session || !session.infraId) return; - const cfg = getConfig(); - axios({ method: 'get', url: `${tbApiBase()}/ns/${configNamespace}/infra/${session.infraId}`, - auth: { username: cfg.username, password: cfg.password } }) - .then((res) => { - const data = res.data || {}; - const status = data.postCommandStatus; - if (!status || status === 'Running' || status === 'None') return; // still running: keep waiting - Swal.close(); - Swal.fire({ - title: `Post-deployment result: ${data.id || session.infraId}`, - width: 800, - html: `${POPUP_STYLES} - `, - showConfirmButton: false, showCancelButton: true, cancelButtonText: '✕ Close', - }); - removeSpinnerTask(session.spinnerId); - if (session._waitingTimer) clearInterval(session._waitingTimer); - }) - .catch(() => { /* keep the stream waiting; the 60s message still applies */ }); -} - -/** - * Cancel a single Node's command execution via the Tumblebug task cancel API. - * Uses a two-click confirm (arm then confirm) so no extra dialog is needed on - * top of the streaming modal. taskId format: {xRequestId}:{nodeId}:{commandIndex} - */ -function _cmdCancelNodeTask(session, nodeId) { - const nd = session.nodeState[nodeId]; - if (!nd || !(nd.commandIndex > 0) || session.cancelPending[nodeId]) return; - - // Repaint via the session's live callback: it is nulled when the modal - // closes, so async repaints (arm expiry, API response) can never paint - // this session's state into another session's modal. - const repaint = () => { if (session.rebuildCallback) session.rebuildCallback(); }; - - const now = Date.now(); - if (!session.cancelArmed[nodeId] || session.cancelArmed[nodeId] <= now) { - // First click: arm the button for 8 seconds - session.cancelArmed[nodeId] = now + 8000; - repaint(); - setTimeout(repaint, 8200); // repaint back to normal state after arm expires - return; - } - - // Second click within the arm window: fire the cancel API - delete session.cancelArmed[nodeId]; - session.cancelPending[nodeId] = true; - nd.cancelError = null; - repaint(); - - const cfg = (typeof getConfig === 'function') ? getConfig() : {}; - const ns = cfg.namespace || window.configNamespace; - if (!cfg.hostname || !cfg.port || !ns || !session.infraId) { - delete session.cancelPending[nodeId]; - nd.cancelError = 'Missing server configuration for cancel request'; - repaint(); - return; - } - // taskId components (xRequestId, nodeId) are validated lowercase [a-z0-9-] - // strings and ':' is a legal path character, so no URL encoding — this also - // keeps compatibility with Tumblebug versions that don't unescape the param. - const taskId = `${session.xRequestId}:${nodeId}:${nd.commandIndex}`; - const url = `${tbApiBase()}/ns/${ns}/cmd/infra/${session.infraId}/task/${taskId}/cancel`; - axios.post(url, { reason: 'Cancelled from streaming view' }, { - auth: { username: cfg.username, password: cfg.password }, - timeout: 15000, - }).then(() => { - // The status flips to Cancelled via the SSE CommandStatus event published - // by the server — no local state change needed beyond clearing the flag. - delete session.cancelPending[nodeId]; - repaint(); - }).catch((err) => { - delete session.cancelPending[nodeId]; - nd.cancelError = (err.response && err.response.data && err.response.data.message) || err.message || 'Cancel request failed'; - repaint(); - }); -} - -/** - * Open (or re-open) the streaming modal for a given session. - */ -function openStreamingSessionModal(xRequestId) { - const session = window._cmdStreamSessions[xRequestId]; - if (!session) { - Swal.fire({ icon: 'info', title: 'Session Expired', text: 'This streaming session is no longer available.' }); - return; - } - - const statusBadge = (status) => { - const colors = { - 'Queued': '#6c757d', 'Handling': '#0d6efd', 'Completed': '#198754', - 'Failed': '#dc3545', 'CompletedWithError': '#fd7e14', 'Timeout': '#fd7e14', - 'Cancelled': '#6c757d', 'Interrupted': '#ffc107' - }; - const bg = colors[status] || '#6c757d'; - return `${window.escapeHtml(status)}`; - }; - - const renderNodePanel = (nodeId) => { - const nd = session.nodeState[nodeId]; - if (!nd) return ''; - const stdoutText = nd.stdoutLines.join('\n'); - const stderrText = nd.stderrLines.join('\n'); - // ctx is consumed by _escAndLinkify: nodeIp rewrites 0.0.0.0/localhost URLs, - // infraId+nodeId carry context for $$FILEPATH download chips. - const ndCtx = { - infraId: session.infraId, - nodeId, - nodeIp: (session.nodeIpMap && session.nodeIpMap[nodeId]) || '', - }; - - // Per-Node cancel button: only for still-active executions with a known - // commandIndex (required to build the taskId for the cancel API). - let cancelBtnHtml = ''; - if (['Queued', 'Handling'].includes(nd.status) && nd.commandIndex > 0) { - const safeId = window.escapeHtml(nodeId); - if (session.cancelPending[nodeId]) { - cancelBtnHtml = `⏳ Cancelling...`; - } else if (session.cancelArmed[nodeId] && session.cancelArmed[nodeId] > Date.now()) { - cancelBtnHtml = ``; - } else { - cancelBtnHtml = ``; - } - } - - let html = ` -
    - ${window.escapeHtml(nodeId)} - ${statusBadge(nd.status)} - ${nd.statusInfo && nd.statusInfo.elapsedTime ? `${nd.statusInfo.elapsedTime}s` : ''} - ${cancelBtnHtml} -
    `; - - if (nd.cancelError) { - html += ` -
    - Cancel failed: ${window.escapeHtml(nd.cancelError)} -
    `; - } - - // Show error details for terminal error states (SSH connection failure, - // command timeout, non-zero exit, user cancellation) - if (['Failed', 'Timeout', 'CompletedWithError', 'Cancelled'].includes(nd.status) && nd.statusInfo) { - const errMsg = nd.statusInfo.errorMessage; - const summary = nd.statusInfo.resultSummary; - if (errMsg || summary) { - html += ` -
    `; - if (summary) { - html += `
    ${window.escapeHtml(summary)}
    `; - } - if (errMsg) { - html += `
    ${window.escapeHtml(errMsg)}
    `; - } - html += `
    `; - } - } - - if (stdoutText) { - html += ` -
    -
    - stdout (${nd.stdoutLines.length} lines) - -
    -
    ${_escAndLinkify(stdoutText, ndCtx)}
    -
    `; - } - - if (stderrText) { - html += ` -
    -
    - stderr (${nd.stderrLines.length} lines) - -
    -
    ${_escAndLinkify(stderrText, ndCtx)}
    -
    `; - } - - return html; - }; - - // Auto-scroll toggle state (default: on) - let autoScroll = true; - - // Document-level capture listener for $$RESULT chip buttons (copy / open / download). - // Lives across rebuildModal() innerHTML swaps because it's bound at document scope. - let streamRcBtnClick = null; - - // Throttled rebuild - let rebuildTimer = null; - const scheduleRebuild = () => { - if (!rebuildTimer) { - rebuildTimer = setTimeout(() => { - rebuildTimer = null; - rebuildModal(); - }, 100); - } - }; - - const rebuildModal = () => { - const popup = Swal.getPopup(); - if (!popup) return; - const container = popup.querySelector('#stream-body'); - if (!container) return; - - // Save scroll positions before innerHTML replacement (used when autoScroll is OFF) - const savedScrollPositions = {}; - container.querySelectorAll('pre[data-scrollkey]').forEach(pre => { - savedScrollPositions[pre.dataset.scrollkey] = pre.scrollTop; - }); - - const nodeIds = Object.keys(session.nodeState).sort(); - const totalNodes = nodeIds.length; - const completedCount = nodeIds.filter(id => ['Completed','CompletedWithError','Failed','Timeout','Cancelled','Interrupted'].includes(session.nodeState[id].status)).length; - const handlingCount = nodeIds.filter(id => session.nodeState[id].status === 'Handling').length; - const isFinished = session.doneSummary !== null; - - const streamDnsLinkHtml = session.appliedDnsUrl ? ` -
    - 🌐 - ${window.escapeHtml(session.appliedDnsUrl)} - (open in new tab) -
    ` : ''; - - // Command banner: show pre-substitution template so tokens are visible, - // not actual secret values. Blurred by default; reveal toggle persists across rebuilds - // via session.commandRevealed (survives the 100 ms rebuildModal() cycle). - let cmdBannerHtml = ''; - if (session.templateCommands && session.templateCommands.length > 0) { - const cmdLines = session.templateCommands.map(c => window.escapeHtml(c)); - const blurStyle = session.commandRevealed ? '' : 'filter:blur(5px);user-select:none;'; - const safeXReqId = window.escapeHtml(xRequestId); - const toggleEmoji = session.commandRevealed ? '🙈' : '👁️'; - const toggleRevealed = session.commandRevealed ? 'false' : 'true'; - cmdBannerHtml = ` -
    - -
    ${cmdLines.join('\n&&\n')}
    -
    `; - } - - let summaryHtml = ` - ${streamDnsLinkHtml} - ${cmdBannerHtml} -
    -
    - ${isFinished ? (session.commandError || session.doneSummary.failedNodes > 0 ? '⚠️' : '✅') : '⏳'} - ${isFinished ? (session.commandError ? 'Failed' : 'Completed') : 'Streaming...'} - ${completedCount}/${totalNodes} Nodes done${handlingCount > 0 ? ` · ${handlingCount} running` : ''} - ${session.doneSummary ? `· ${session.doneSummary.elapsedSeconds}s total` : ''} -
    - Infra: ${window.escapeHtml(session.infraId)} | x-request-id: ${window.escapeHtml(xRequestId)} -
    `; - - if (session.error) { - summaryHtml += `
    - Stream Error: ${window.escapeHtml(session.error)} -
    `; - } - - // Waiting state - if (nodeIds.length === 0 && !isFinished) { - if (session.commandError) { - // Error before any VMs started (e.g., preprocessing failure) - container.innerHTML = summaryHtml + `
    -
    - Command Failed: ${window.escapeHtml(session.commandError)} -
    -
    The command failed before reaching any Nodes.
    -
    `; - } else if (session.error) { - // SSE transport error while waiting - container.innerHTML = summaryHtml + `
    -
    No Node execution results available.
    -
    `; - } else { - // Still waiting for events - const waitingSec = Math.floor((Date.now() - session.startTime) / 1000); - container.innerHTML = summaryHtml + `
    -
    Waiting for first event... (${waitingSec}s) -
    `; - } - return; - } - - // Finished with error but no VMs processed - if (nodeIds.length === 0 && isFinished) { - let errorHtml = ''; - if (session.commandError) { - errorHtml = `
    - Command Failed: ${window.escapeHtml(session.commandError)} -
    `; - } - container.innerHTML = summaryHtml + `
    - ${errorHtml} -
    No Node execution results available.
    -
    `; - return; - } - - // Node panels with tabs - let bodyHtml; - if (nodeIds.length <= 1) { - bodyHtml = nodeIds.map(id => renderNodePanel(id)).join(''); - } else { - const activeTab = popup.querySelector('.stream-tab-btn.stream-tab-active')?.dataset?.nodeid || nodeIds[0]; - const tabBtns = nodeIds.map(id => { - const isActive = id === activeTab; - const nd = session.nodeState[id]; - const statusDot = { - 'Handling': '🔵', 'Completed': '🟢', 'Failed': '🔴', - 'CompletedWithError': '🟠', 'Timeout': '⏰', 'Cancelled': '🚫', 'Interrupted': '🟡' - }[nd.status] || '⚪'; - return ``; - }).join(''); - - const tabPanels = nodeIds.map(id => { - const isActive = id === activeTab; - return `
    - ${renderNodePanel(id)} -
    `; - }).join(''); - - bodyHtml = ` -
    - ${tabBtns} -
    - ${tabPanels}`; - } - - container.innerHTML = summaryHtml + bodyHtml; - - // Tab click listeners - container.querySelectorAll('.stream-tab-btn').forEach(btn => { - btn.addEventListener('click', () => { - const nodeid = btn.dataset.nodeid; - container.querySelectorAll('.stream-tab-btn').forEach(b => { - b.classList.remove('stream-tab-active'); - b.style.background = '#f1f3f5'; b.style.color = '#888'; b.style.fontWeight = '400'; - }); - btn.classList.add('stream-tab-active'); - btn.style.background = '#fff'; btn.style.color = '#333'; btn.style.fontWeight = '600'; - container.querySelectorAll('.stream-tab-panel').forEach(p => { - p.style.display = p.dataset.nodeid === nodeid ? 'block' : 'none'; - }); - }); - }); - - // Per-Node cancel buttons (two-click confirm; see _cmdCancelNodeTask) - container.querySelectorAll('.stream-cancel-btn').forEach(btn => { - btn.addEventListener('click', () => { - _cmdCancelNodeTask(session, btn.dataset.nodeid); - }); - }); - - // Copy-to-clipboard buttons - container.querySelectorAll('.stream-copy-btn').forEach(btn => { - btn.addEventListener('click', () => { - const nd = session.nodeState[btn.dataset.nodeid]; - if (!nd) return; - const text = btn.dataset.type === 'stdout' ? nd.stdoutLines.join('\n') : nd.stderrLines.join('\n'); - const markCopied = () => { - btn.textContent = '✅ Copied!'; - setTimeout(() => { btn.textContent = '📋 Copy'; }, 1500); - }; - if (navigator.clipboard) { - navigator.clipboard.writeText(text).then(markCopied).catch(() => { - const ta = document.createElement('textarea'); - ta.value = text; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - markCopied(); - }); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - markCopied(); - } - }); - }); - - // Auto-scroll: scroll to bottom when ON, restore previous position when OFF - if (autoScroll) { - container.querySelectorAll('pre').forEach(pre => { pre.scrollTop = pre.scrollHeight; }); - } else { - container.querySelectorAll('pre[data-scrollkey]').forEach(pre => { - const saved = savedScrollPositions[pre.dataset.scrollkey]; - if (saved !== undefined) pre.scrollTop = saved; - }); - } - }; - - // Register rebuild callback on the session - session.rebuildCallback = scheduleRebuild; - - Swal.fire({ - title: '🖥️ Remote Command (Streaming)', - width: 750, - html: ` -
    - -
    -
    -
    -
    Connecting to stream... -
    -
    `, - showConfirmButton: true, - confirmButtonText: 'Close', - showCancelButton: !session.doneSummary, - cancelButtonText: '⏹️ Stop streaming', - cancelButtonColor: '#dc3545', - allowOutsideClick: true, - didOpen: () => { - // Render current state immediately (for re-open case) - rebuildModal(); - // Wire up auto-scroll toggle button - const toggleBtn = Swal.getPopup().querySelector('#auto-scroll-toggle'); - if (toggleBtn) { - toggleBtn.addEventListener('click', () => { - autoScroll = !autoScroll; - toggleBtn.textContent = autoScroll ? '⬇️ Auto-scroll: ON' : '⏸️ Auto-scroll: OFF'; - toggleBtn.style.background = autoScroll ? '#198754' : '#6c757d'; - if (autoScroll) { - // Immediately scroll to bottom when re-enabling - const container = Swal.getPopup().querySelector('#stream-body'); - if (container) container.querySelectorAll('pre').forEach(pre => { pre.scrollTop = pre.scrollHeight; }); - } - }); - } - // Shared $$RESULT chip handler (copy / open / download) — same behavior as - // the non-streaming result viewer. Uses document-level capture so the - // listener works across rebuildModal() innerHTML replacements without - // needing per-button re-binding. - streamRcBtnClick = _createRcBtnClickHandler(Swal.getPopup()); - document.addEventListener('click', streamRcBtnClick, true); - }, - willClose: () => { - // Detach view callback — stream continues in background - session.rebuildCallback = null; - if (rebuildTimer) clearTimeout(rebuildTimer); - if (streamRcBtnClick) document.removeEventListener('click', streamRcBtnClick, true); - }, - preDeny: () => false, - }).then(result => { - if (result.dismiss === Swal.DismissReason.cancel) { - // User chose "Stop streaming" — abort the SSE stream - session.abortController.abort(); - removeSpinnerTask(session.spinnerId); - session.error = 'Stopped by user'; - updateStreamingBadge(); - } - }); -} -window.openStreamingSessionModal = openStreamingSessionModal; - -/** - * Show a list of all active/recent streaming sessions so user can re-open any. - */ -function showStreamingSessionList() { - const sessions = window._cmdStreamSessions; - const keys = Object.keys(sessions); - - if (keys.length === 0) { - Swal.fire({ icon: 'info', title: 'No Active Sessions', text: 'There are no active or recent command streaming sessions.' }); - return; - } - - const rows = keys.map(reqId => { - const s = sessions[reqId]; - const nodeIds = Object.keys(s.nodeState); - const totalNodes = nodeIds.length; - const completedNodes = nodeIds.filter(id => ['Completed','CompletedWithError','Failed','Timeout','Cancelled','Interrupted'].includes(s.nodeState[id].status)).length; - const isFinished = s.doneSummary !== null; - const elapsed = ((Date.now() - s.startTime) / 1000).toFixed(0); - const statusIcon = isFinished ? (s.commandError || s.doneSummary.failedNodes > 0 ? '⚠️' : '✅') : (s.error ? '❌' : '⏳'); - const statusText = isFinished ? (s.commandError ? 'Failed' : 'Done') : (s.error ? 'Error' : 'Running'); - - return ` - ${statusIcon} ${statusText} - ${window.escapeHtml(s.infraId)} - ${completedNodes}/${totalNodes} Nodes - ${elapsed}s ago - ${window.escapeHtml(reqId)} - - ${!isFinished && !s.error ? `` : - ``} - - `; - }).join(''); - - Swal.fire({ - title: '📡 Streaming Command Sessions', - width: 700, - html: `
    - - - - - - - - - - - - - ${rows} - -
    StatusInfraProgressElapsedRequest IDAction
    -
    Click a row to open the streaming view. Sessions auto-dismiss 5 minutes after completion.
    -
    `, - showConfirmButton: true, - confirmButtonText: 'Close', - }); -} -window.showStreamingSessionList = showStreamingSessionList; - -/** - * Safely clean up all resources for a streaming session (timers, abort controller, spinner). - */ -function cleanupStreamingSession(session) { - if (!session) return; - if (session._waitingTimer) clearInterval(session._waitingTimer); - if (session.cleanupTimer) clearTimeout(session.cleanupTimer); - session.abortController.abort(); - removeSpinnerTask(session.spinnerId); -} - -function abortStreamingSession(xRequestId) { - const session = window._cmdStreamSessions[xRequestId]; - if (session) { - cleanupStreamingSession(session); - delete window._cmdStreamSessions[xRequestId]; - updateStreamingBadge(); - } - // Refresh the session list if it's open - const popup = Swal.getPopup(); - if (popup && popup.querySelector('table')) { - showStreamingSessionList(); - } -} -window.abortStreamingSession = abortStreamingSession; - -function dismissStreamingSession(xRequestId) { - const session = window._cmdStreamSessions[xRequestId]; - if (session) { - cleanupStreamingSession(session); - delete window._cmdStreamSessions[xRequestId]; - updateStreamingBadge(); - } - const popup = Swal.getPopup(); - if (popup && popup.querySelector('table')) { - showStreamingSessionList(); - } -} -window.dismissStreamingSession = dismissStreamingSession; - -/** - * Update the floating badge that shows the count of active streaming sessions. - * Creates the badge element if it doesn't exist yet. - */ -function updateStreamingBadge() { - const sessions = window._cmdStreamSessions; - const total = Object.keys(sessions).length; - const running = Object.values(sessions).filter(s => !s.doneSummary && !s.error && !s.commandError).length; - - let badge = document.getElementById('streaming-sessions-badge'); - if (!badge) { - badge = document.createElement('div'); - badge.id = 'streaming-sessions-badge'; - badge.style.cssText = 'position:fixed;bottom:15px;right:20px;z-index:10000;cursor:pointer;display:none;' + - 'background:linear-gradient(135deg,#0d6efd,#6610f2);color:#fff;border-radius:24px;padding:8px 16px;' + - 'box-shadow:0 4px 16px rgba(13,110,253,0.4);font-size:12px;font-weight:600;' + - 'transition:all 0.3s ease;user-select:none;'; - badge.addEventListener('click', () => showStreamingSessionList()); - badge.addEventListener('mouseenter', () => { badge.style.transform = 'scale(1.05)'; badge.style.boxShadow = '0 6px 20px rgba(13,110,253,0.5)'; }); - badge.addEventListener('mouseleave', () => { badge.style.transform = 'scale(1)'; badge.style.boxShadow = '0 4px 16px rgba(13,110,253,0.4)'; }); - document.body.appendChild(badge); - } - - if (total === 0) { - badge.style.display = 'none'; - } else { - badge.style.display = 'flex'; - badge.style.alignItems = 'center'; - badge.style.gap = '6px'; - const pulseHtml = running > 0 ? '' : ''; - badge.innerHTML = `${pulseHtml} 📡 ${running > 0 ? running + ' streaming' : ''} ${total - running > 0 ? (running > 0 ? '· ' : '') + (total - running) + ' done' : ''}`; - - // Inject pulse animation if not already present - if (!document.getElementById('stream-pulse-style')) { - const style = document.createElement('style'); - style.id = 'stream-pulse-style'; - style.textContent = '@keyframes stream-pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }'; - document.head.appendChild(style); - } - } -} - -/** - * Consumes an SSE stream using fetch() + ReadableStream. - * This approach (vs EventSource) supports custom Authorization headers for BasicAuth. - * - * @param {string} url - SSE endpoint URL - * @param {string} username - BasicAuth username - * @param {string} password - BasicAuth password - * @param {AbortController} abortController - Controller to abort the stream - * @param {Function} onEvent - Callback invoked with parsed JSON event objects - * @returns {Promise} - */ -async function consumeSSEStream(url, username, password, abortController, onEvent) { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'text/event-stream', - 'Authorization': 'Basic ' + btoa(username + ':' + password), - }, - signal: abortController.signal, - }); - - if (!response.ok) { - throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // Process complete SSE messages (delimited by double newline) - let boundary; - while ((boundary = buffer.indexOf('\n\n')) !== -1) { - const message = buffer.substring(0, boundary); - buffer = buffer.substring(boundary + 2); - - // Parse SSE lines - for (const line of message.split('\n')) { - if (line.startsWith('data: ')) { - const jsonStr = line.substring(6).trim(); - if (jsonStr) { - try { - const event = JSON.parse(jsonStr); - onEvent(event); - } catch (e) { - console.warn('Failed to parse SSE event:', jsonStr, e); - } - } - } - } - } - } -} - - -// === Recent Remote Commands (localStorage) === -const RECENT_CMDS_KEY = 'recentRemoteCmds'; -const RECENT_CMDS_MAX = 30; - -function loadRecentCmds() { - try { - const raw = localStorage.getItem(RECENT_CMDS_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { return []; } -} - -function saveRecentCmd(commands, options = {}) { - const filtered = commands.filter(c => c.trim()); - if (filtered.length === 0) return; - const history = loadRecentCmds(); - // Deduplicate: remove existing entry with same commands - const key = JSON.stringify(filtered); - const deduped = history.filter(h => JSON.stringify(h.commands) !== key); - const entry = { timestamp: new Date().toISOString(), commands: filtered }; - if (options.labelSelector) entry.labelSelector = options.labelSelector; - if (options.syncMode) entry.syncMode = true; - deduped.unshift(entry); - if (deduped.length > RECENT_CMDS_MAX) deduped.length = RECENT_CMDS_MAX; - try { - localStorage.setItem(RECENT_CMDS_KEY, JSON.stringify(deduped)); - } catch (e) { - // Ignore storage errors (quota exceeded, disabled, Safari private mode) - } -} - -function buildRecentCmdsSectionHtml() { - const history = loadRecentCmds(); - if (history.length === 0) return ''; - const options = history.map((h, i) => { - const preview = h.commands.join(' && ').substring(0, 70) + (h.commands.join(' && ').length > 70 ? '...' : ''); - const ago = getTimeAgo(h.timestamp); - const tags = [ago]; - if (h.labelSelector) tags.push('🏷️'); - if (h.syncMode) tags.push('⏱️'); - return ``; - }).join(''); - return ` - `; -} - -function getTimeAgo(isoStr) { - const diff = Date.now() - new Date(isoStr).getTime(); - if (isNaN(diff) || diff < 0) return 'unknown'; - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; -} - -window.applyRecentCmd = function(idx) { - if (idx === '') return; - const history = loadRecentCmds(); - const entry = history[parseInt(idx)]; - if (!entry) return; - const appendMode = document.getElementById('recentAppendMode')?.checked || false; - - const cmdContainer = document.getElementById('cmdContainer'); - if (!cmdContainer) return; - - const existingRows = cmdContainer.querySelectorAll('.cmdRow'); - let startIdx = 0; - - if (appendMode) { - // Append after the last non-empty command (skip trailing empty rows) - let lastNonEmpty = -1; - existingRows.forEach((row, i) => { - const ta = document.getElementById(`cmd${i + 1}`); - if (ta && ta.value.trim() !== '') lastNonEmpty = i; - }); - startIdx = lastNonEmpty + 1; - } else { - // Clear all existing textareas (don't remove DOM — just blank them) - existingRows.forEach((row, i) => { - const ta = document.getElementById(`cmd${i + 1}`); - if (ta) { ta.value = ''; autoResizeTextarea(ta); } - }); - } - - entry.commands.forEach((cmd, i) => { - const n = startIdx + i + 1; - const el = document.getElementById(`cmd${n}`); - if (el) { - el.value = cmd; - autoResizeTextarea(el); - } else if (typeof window.addCmd === 'function') { - window.addCmd(); - const newEl = document.getElementById(`cmd${n}`); - if (newEl) { newEl.value = cmd; autoResizeTextarea(newEl); } - } - }); - - // Restore label selector if saved (non-append mode only) - if (!appendMode && entry.labelSelector) { - const labelInput = document.getElementById('labelSelector'); - if (labelInput) { - labelInput.value = entry.labelSelector; - if (window.updateSelectedLabelsDisplay) window.updateSelectedLabelsDisplay(); - if (window.updateLabelMatchPreview) window.updateLabelMatchPreview(); - if (window.updateAvailableLabelChipStyles) window.updateAvailableLabelChipStyles(); - } - } - - // Restore sync mode toggle if saved - if (!appendMode) { - const syncToggle = document.getElementById('syncModeToggle'); - if (syncToggle) syncToggle.checked = !!entry.syncMode; - } - - // Re-render placeholder inputs if available - if (typeof window.renderPlaceholderInputs === 'function') { - window.renderPlaceholderInputs(); - } - - // Reset selector - const selector = document.getElementById('recentCmdSelector'); - if (selector) selector.value = ''; -}; - -window.clearRecentCmds = function() { - localStorage.removeItem(RECENT_CMDS_KEY); - const section = document.getElementById('recentCmdsSection'); - if (section) section.style.display = 'none'; -}; - -// Set (register) a bastion for a SUBNET. Bastions are stored per subnet in -// CB-Tumblebug and serve every node in that subnet, so this dialog is framed -// around "which node is the bastion for this subnet" rather than a per-target -// assignment. `preset` (from the Net-graph right-click menu) scopes the dialog -// to a specific subnet and pre-selects the clicked node as the bastion: -// { targetInfraId, subnetMemberNodeId, subnetId, defaultBastionNodeId } -async function setBastionNode(preset) { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = (preset && preset.targetInfraId) || getSelectedInfraId(); - const hasPreset = !!(preset && preset.subnetMemberNodeId); - const presetSubnetMemberNodeId = (preset && preset.subnetMemberNodeId) || ''; - const presetDefaultBastionNodeId = (preset && preset.defaultBastionNodeId) || ''; - - if (!namespace || !infraid || infraid === 'all') { - errorAlert("Please select a namespace and a specific Infra first"); - return; - } - - // Load the nodes of the (target) Infra so we can group them by subnet. - let nodes = []; - try { - const infraRes = await axios.get( - `${tbApiBase()}/ns/${namespace}/infra/${infraid}`, - { auth: { username, password } } - ); - nodes = infraRes.data.node || []; - } catch (err) { - console.error("Failed to fetch target Infra Nodes:", err); - } - - const esc = (v) => window.escapeHtml(String(v == null ? '' : v)); - - // Resolve which subnet this dialog targets. - let presetSubnetId = (preset && preset.subnetId) || ''; - if (hasPreset && !presetSubnetId) { - const m = nodes.find(n => String(n.id) === String(presetSubnetMemberNodeId)); - presetSubnetId = m ? (m.subnetId || '') : ''; - } - - // Options for the "pick any node to identify the subnet" selector (no-preset). - const subnetPickerOptions = [''] - .concat(nodes.map(n => { - const ip = n.publicIP ? ` (${n.publicIP})` : ''; - return ``; - })).join(''); - - // Read-only "serves" block when the subnet is fixed by the preset; otherwise a picker. - const servesHtml = hasPreset - ? ` - ` - : ``; - - Swal.fire({ - title: "🛡️ Set Bastion for Subnet", - width: 680, - html: ` - ${POPUP_STYLES} - `, - showCancelButton: true, - confirmButtonText: "Set Bastion", - cancelButtonText: "Cancel", - didOpen: () => { - const subnetSel = document.getElementById('subnetMemberSel'); // null when preset-fixed - const subnetNodeSel = document.getElementById('bastionSubnetNodeSel'); - const subnetBlock = document.getElementById('bastionSubnetBlock'); - const externalBlock = document.getElementById('bastionExternalBlock'); - - const currentSubnetId = () => { - if (hasPreset) return presetSubnetId; - const opt = subnetSel && subnetSel.selectedOptions[0]; - return opt ? (opt.getAttribute('data-subnet') || '') : ''; - }; - const repopulateSubnetBastions = (preselectId) => { - const sid = currentSubnetId(); - const inSubnet = nodes.filter(n => (n.subnetId || '') === sid); - if (!sid) { - subnetNodeSel.innerHTML = ''; - return; - } - if (!inSubnet.length) { - subnetNodeSel.innerHTML = ''; - return; - } - subnetNodeSel.innerHTML = inSubnet.map(n => { - const has = !!n.publicIP; - const label = has ? `${n.id} ✅ ${n.publicIP}` : `${n.id} (no public IP)`; - const sel = preselectId && String(n.id) === String(preselectId) ? ' selected' : ''; - const style = has ? '' : ' style="color:#aaa"'; - return ``; - }).join(''); - }; - repopulateSubnetBastions(presetDefaultBastionNodeId); - if (subnetSel) subnetSel.addEventListener('change', () => repopulateSubnetBastions('')); - - // Mode toggle: show the matching block only. - document.querySelectorAll('input[name="bastionMode"]').forEach(r => { - r.addEventListener('change', () => { - const mode = document.querySelector('input[name="bastionMode"]:checked').value; - subnetBlock.style.display = mode === 'subnet' ? '' : 'none'; - externalBlock.style.display = mode === 'external' ? '' : 'none'; - }); - }); - - // External bastion loaders (advanced). - document.getElementById('loadBastionInfraBtn').addEventListener('click', async () => { - const bastionNs = document.getElementById('bastionNsId').value.trim(); - if (!bastionNs) { return; } - const infraSel = document.getElementById('bastionInfraId'); - infraSel.innerHTML = ''; - document.getElementById('bastionNodeId').innerHTML = ''; - try { - const res = await axios.get( - `${tbApiBase()}/ns/${bastionNs}/infra?option=id`, - { auth: { username, password } } - ); - const infras = Array.isArray(res.data.output) ? res.data.output : []; - infraSel.innerHTML = ''; - infras.forEach(m => { - const opt = document.createElement('option'); - opt.value = String(m); - opt.textContent = String(m); - infraSel.appendChild(opt); - }); - } catch (err) { - infraSel.innerHTML = ''; - console.error("Failed to load bastion Infras:", err); - } - }); - - document.getElementById('bastionInfraId').addEventListener('change', async () => { - const bastionNs = document.getElementById('bastionNsId').value.trim(); - const bastionInfra = document.getElementById('bastionInfraId').value; - const nodeSel = document.getElementById('bastionNodeId'); - if (!bastionInfra) { - nodeSel.innerHTML = ''; - return; - } - nodeSel.innerHTML = ''; - try { - const res = await axios.get( - `${tbApiBase()}/ns/${bastionNs}/infra/${bastionInfra}`, - { auth: { username, password } } - ); - const extNodes = res.data.node || []; - nodeSel.innerHTML = ''; - extNodes.forEach(nd => { - const hasPublic = !!nd.publicIP; - const opt = document.createElement('option'); - opt.value = String(nd.id); - opt.textContent = hasPublic ? `${nd.id} ✅ ${nd.publicIP}` : `${nd.id} (no public IP)`; - if (!hasPublic) opt.style.color = '#aaa'; - nodeSel.appendChild(opt); - }); - } catch (err) { - nodeSel.innerHTML = ''; - console.error("Failed to load bastion Nodes:", err); - } - }); - }, - preConfirm: () => { - // Any node in the target subnet identifies the subnet for the API. - const targetNodeId = hasPreset - ? presetSubnetMemberNodeId - : (document.getElementById('subnetMemberSel') || {}).value; - if (!targetNodeId) { - Swal.showValidationMessage("Please pick the target subnet (select a node in it)"); - return false; - } - const mode = (document.querySelector('input[name="bastionMode"]:checked') || {}).value; - if (mode === 'external') { - const bastionNsId = document.getElementById('bastionNsId').value.trim(); - const bastionInfraId = document.getElementById('bastionInfraId').value; - const bastionNodeId = document.getElementById('bastionNodeId').value; - if (!bastionInfraId || !bastionNodeId) { - Swal.showValidationMessage("Please load Infras and select an external bastion Node"); - return false; - } - return { targetNodeId, bastionNsId, bastionInfraId, bastionNodeId }; - } - // Same-subnet node acts as the bastion. - const bastionNodeId = document.getElementById('bastionSubnetNodeSel').value; - if (!bastionNodeId) { - Swal.showValidationMessage("Please select a node in this subnet to act as the bastion"); - return false; - } - return { targetNodeId, bastionNsId: namespace, bastionInfraId: infraid, bastionNodeId }; - }, - }).then(async (result) => { - if (!result.isConfirmed || !result.value) return; - - const { targetNodeId, bastionNsId, bastionInfraId, bastionNodeId } = result.value; - - // Build URL based on how many components differ - let url; - const sameBastionNs = bastionNsId === namespace; - const sameBastionInfra = bastionInfraId === infraid; - - if (sameBastionNs && sameBastionInfra && bastionNodeId) { - // Same NS, same Infra - url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/node/${targetNodeId}/bastion/${bastionNodeId}`; - } else if (sameBastionNs && bastionNodeId) { - // Same NS, different Infra - url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/node/${targetNodeId}/bastion/${bastionInfraId}/${bastionNodeId}`; - } else if (sameBastionNs && !bastionNodeId) { - // Same NS, different Infra, auto-select Node - url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/node/${targetNodeId}/bastion/${bastionInfraId}/`; - } else if (bastionNodeId) { - // Cross-NS with explicit Node - url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/node/${targetNodeId}/bastion/${bastionNsId}/${bastionInfraId}/${bastionNodeId}`; - } else { - // Cross-NS, auto-select Node — not supported via URL params; fall back to explicit auto-select call - url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/node/${targetNodeId}/bastion/${bastionNsId}/${bastionInfraId}/`; - } - - // Remove trailing slash if bastionNodeId is empty (auto-select not available via 3-segment route) - // For auto-select, use the existing same-Infra route which auto-picks a public-IP Node in bastionInfraId - if (!bastionNodeId) { - // Use bastionInfraId route without Node ID — not directly supported; show guidance - Swal.fire({ - icon: 'info', - title: 'Auto-select requires a Node ID', - text: `Please select a specific bastion Node. Auto-selection across Infra/namespaces is not supported via the UI — select a Node with a ✅ public IP from the list.`, - confirmButtonText: 'OK' - }); - return; - } - - const spinnerId = addSpinnerTask("Setting bastion node..."); - try { - const res = await axios({ - method: 'put', - url: url, - auth: { username, password }, - timeout: 30000, - }); - removeSpinnerTask(spinnerId); - Swal.fire({ - icon: 'success', - title: '✅ Bastion Set', - text: JSON.stringify(res.data, null, 2), - width: 600, - }); - } catch (err) { - removeSpinnerTask(spinnerId); - const msg = err.response?.data?.message || err.message; - Swal.fire({ icon: 'error', title: 'Failed to set bastion', text: msg }); - } - }); -} - -async function executeRemoteCmd() { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = getSelectedInfraId(); - var nodegroupid = getNodeGroupIdFromNodeSelection(); - var nodeid = document.getElementById("nodeid").value; - let _appliedDnsUrl = null; // set when Apply DNS succeeds - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - // Fetch Infra list for selector - let infraListOptions = []; - try { - const infraListUrl = `${tbApiBase()}/ns/${namespace}/infra?option=id`; - const infraRes = await axios.get(infraListUrl, { - auth: { username: username, password: password } - }); - if (infraRes.data.output && infraRes.data.output.length > 0) { - infraListOptions = infraRes.data.output; - } - } catch (err) { - console.error("Failed to fetch Infra list:", err); - } - - if (infraListOptions.length === 0) { - errorAlert("No Infra available. Please create an Infra first."); - return; - } - - // Build Infra selector options HTML - const infraOptionsHtml = infraListOptions.map(m => - `` - ).join(''); - - var spinnerId = ""; - - console.log("Opening remote command dialog (context Infra: " + infraid + ")"); - - var cmd = []; - - // Generate target selection HTML - const targetSelectionHtml = ` -

    [Select target]

    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    `; - - Swal.fire({ - title: "🖥️ Application Deployment", - width: 850, - html: ` - ${POPUP_STYLES} - `, - showCancelButton: true, - confirmButtonText: "Execute", - didOpen: () => { - setupCommandsPopup(0); // 0 means no limit - setupInfraSelectorForLabels(); // Setup Infra selector for label updates - setupClearLabelButtonListener(); // Setup Clear All button listener - // Auto-resize textareas on open - document.querySelectorAll('#cmdContainer textarea').forEach(ta => autoResizeTextarea(ta)); - - // Upload Files handler (parallel, 5 at a time) - document.getElementById('rcUploadBtn').addEventListener('click', async () => { - const fileInput = document.getElementById('rcUploadFileInput'); - const statusEl = document.getElementById('rcUploadStatus'); - const files = Array.from(fileInput.files); - if (files.length === 0) { - statusEl.innerHTML = '⚠️ No files selected.'; - return; - } - const oversized = files.find(f => f.size > 50 * 1024 * 1024); - if (oversized) { - statusEl.innerHTML = `⚠️ "${window.escapeHtml(oversized.name)}" exceeds 50MB limit.`; - return; - } - const infraId = document.getElementById('infraSelector').value; - if (!infraId) { - statusEl.innerHTML = '⚠️ Select an Infra first.'; - return; - } - - const radioEl = Swal.getPopup().querySelector('input[name="selectOption"]:checked'); - const radioValue = radioEl ? radioEl.value : 'Infra'; - let uploadUrl = `${tbApiBase()}/ns/${namespace}/transferFile/infra/${infraId}`; - if (radioValue === 'NodeGroup') uploadUrl += `?nodeGroupId=${encodeURIComponent(nodegroupid)}`; - else if (radioValue === 'Node') uploadUrl += `?nodeId=${encodeURIComponent(nodeid)}`; - - const btn = document.getElementById('rcUploadBtn'); - btn.disabled = true; - statusEl.innerHTML = `⏳ Uploading 0/${files.length}...`; - - const CONCURRENCY = 3; - let completed = 0; - let succeeded = 0; - let failed = 0; - const failedNames = new Set(); - - // Upload in batches of CONCURRENCY - const nodeFailDetails = []; // { fileName, nodeId, error } - for (let i = 0; i < files.length; i += CONCURRENCY) { - const batch = files.slice(i, i + CONCURRENCY); - await Promise.all(batch.map(async (file) => { - const fd = new FormData(); - fd.append('file', file); - fd.append('path', '/home/cb-user'); - try { - const res = await axios.post(uploadUrl, fd, { - headers: { - 'Authorization': `Basic ${btoa(`${username}:${password}`)}`, - 'Content-Type': 'multipart/form-data', - }, - }); - // Check per-Node results inside the response body - const results = res.data?.results || []; - const nodeFails = results.filter(r => r.error && r.error.trim()); - if (nodeFails.length > 0) { - nodeFails.forEach(r => nodeFailDetails.push({ fileName: file.name, nodeId: r.nodeId || '?', error: r.error })); - failed++; - failedNames.add(file.name); - console.warn(`RC upload Node-level failure for ${file.name}:`, nodeFails); - } else { - succeeded++; - } - } catch (e) { - failed++; - failedNames.add(file.name); - console.error(`RC upload error for ${file.name}:`, e); - } - completed++; - statusEl.innerHTML = `⏳ Uploading ${completed}/${files.length}... ✅${succeeded} ❌${failed}`; - })); - } - - btn.disabled = false; - const color = failed === 0 ? '#198754' : '#fd7e14'; - let statusMsg = `✅ ${succeeded} uploaded${failed > 0 ? `, ❌ ${failed} failed` : ''} (${files.length} total)`; - if (nodeFailDetails.length > 0) { - const detailLines = nodeFailDetails.map(d => - `
    • ${window.escapeHtml(d.fileName)} → Node ${window.escapeHtml(d.nodeId)}: ${window.escapeHtml(d.error)}
    ` - ).join(''); - statusMsg += `
    ${detailLines}
    `; - } - - // Auto-insert run commands for successfully uploaded shell scripts - const appendRunCommand = (text) => { - const cmdContainer = document.getElementById('cmdContainer'); - if (!cmdContainer) return; - let target = Array.from(cmdContainer.querySelectorAll('textarea[id^="cmd"]')) - .find(t => !t.value.trim()); - if (!target) { - if (typeof window.addCmd === 'function') window.addCmd(); - const all = cmdContainer.querySelectorAll('textarea[id^="cmd"]'); - target = all[all.length - 1]; - } - if (target && !target.value.trim()) { - target.value = text; - if (typeof autoResizeTextarea === 'function') autoResizeTextarea(target); - } - }; - const uploadedScripts = files.filter(f => /\.(sh|bash)$/i.test(f.name) && !failedNames.has(f.name)); - uploadedScripts.forEach(f => appendRunCommand(`bash /home/cb-user/${f.name}`)); - if (uploadedScripts.length > 0) { - statusMsg += `
    ▶ Run command${uploadedScripts.length > 1 ? 's' : ''} added to Commands below
    `; - } - statusEl.innerHTML = statusMsg; - }); - - // Load Route53 hosted zones; show DNS section only if available - (async () => { - try { - const res = await axios.get( - `${tbApiBase()}/resources/globalDns/hostedZone`, - { headers: { 'Authorization': `Basic ${btoa(`${username}:${password}`)}` } } - ); - const zones = res.data?.hostedZones || []; - if (zones.length === 0) return; // No Route53 access — keep section hidden - - const zoneSelect = document.getElementById('dnsHostedZone'); - zoneSelect.innerHTML = zones.map(z => - `` - ).join(''); - // Pre-fill record name with the currently selected Infra ID - const currentInfraId = document.getElementById('infraSelector')?.value || ''; - if (currentInfraId) { - document.getElementById('dnsRecordName').value = currentInfraId; - } - document.getElementById('dnsUpdateSection').style.display = ''; - } catch (_) { - // Route53 not available or credentials missing — silently hide - } - })(); - - // Apply DNS button handler - document.getElementById('applyDnsBtn').addEventListener('click', async () => { - const domainName = document.getElementById('dnsHostedZone').value; - const recordName = document.getElementById('dnsRecordName').value.trim(); - const recordType = document.getElementById('dnsRecordType').value; - const ttl = parseInt(document.getElementById('dnsTtl').value) || 300; - const infraId = document.getElementById('infraSelector').value; - const resultEl = document.getElementById('dnsApplyResult'); - - if (!infraId) { - resultEl.innerHTML = '⚠️ Select an Infra first.'; - return; - } - - const btn = document.getElementById('applyDnsBtn'); - btn.disabled = true; - btn.textContent = '⏳ Applying...'; - resultEl.innerHTML = ''; - - try { - const body = { - domainName, - recordName, - recordType, - ttl, - routingPolicy: 'simple', - setBy: { infra: { nsId: namespace, infraId } }, - }; - await axios.put( - `${tbApiBase()}/resources/globalDns/record`, - body, - { headers: { 'Authorization': `Basic ${btoa(`${username}:${password}`)}` } } - ); - const fullRecord = (recordName ? `${recordName}.${domainName}` : domainName).replace(/\.$/, ''); - _appliedDnsUrl = `http://${fullRecord}`; - resultEl.innerHTML = `✅ DNS record updated: ${window.escapeHtml(fullRecord)} → Infra public IPs (TTL ${ttl}s)`; - } catch (err) { - const msg = err.response?.data?.message || err.message || 'Request failed'; - resultEl.innerHTML = `❌ ${window.escapeHtml(msg)}`; - } finally { - btn.disabled = false; - btn.textContent = '🌐 Apply DNS'; - } - }); - }, - preConfirm: () => { - // Capture raw (pre-substitution) command text for safe display in the streaming modal. - // collectCommands() replaces tokens with real values (which may include - // secrets like API keys); we keep the template so the modal can show intent without - // exposing the actual secret values. - const cmdContainer = document.getElementById('cmdContainer'); - const templateCommands = cmdContainer - ? Array.from(cmdContainer.querySelectorAll('[id^="cmdDiv"]')) - .map((_, i) => document.getElementById(`cmd${i + 1}`)?.value?.trim() || '') - .filter(Boolean) - : []; - - const commands = collectCommands(); - const selectedInfra = document.getElementById("infraSelector").value; - const timeout = parseInt(document.getElementById("timeoutMinutes").value) || 30; - const syncMode = document.getElementById("syncModeToggle")?.checked || false; - const labelSelector = document.getElementById('labelSelector')?.value || ''; - const sshUserName = document.getElementById('sshUserName')?.value?.trim() || ''; - return { commands, templateCommands, selectedInfra, timeout, syncMode, labelSelector, sshUserName, appliedDnsUrl: _appliedDnsUrl }; - }, - }).then((result) => { - // result.value is false if result.isDenied or another key such as result.isDismissed - if (result.value && result.value.commands && result.value.commands.length > 0) { - // Save to recent commands history (with label & sync context) - saveRecentCmd(result.value.commands, { labelSelector: result.value.labelSelector, syncMode: result.value.syncMode }); - const selectedInfraId = result.value.selectedInfra; - // Validate timeout is within allowed range (1-120 minutes) - const timeoutMinutes = Math.max(1, Math.min(120, parseInt(result.value.timeout, 10) || 30)); - const useSyncMode = result.value.syncMode; - const appliedDnsUrl = result.value.appliedDnsUrl || null; - const templateCommands = result.value.templateCommands || []; - - // Handle radio button value - const radioValue = Swal.getPopup().querySelector( - 'input[name="selectOption"]:checked' - ).value; - if (radioValue === "Infra") { - var url = `${tbApiBase()}/ns/${namespace}/cmd/infra/${selectedInfraId}`; - console.log("Performing remote command for Infra:", selectedInfraId); - } else if (radioValue === "NodeGroup") { - var url = `${tbApiBase()}/ns/${namespace}/cmd/infra/${selectedInfraId}?nodeGroupId=${encodeURIComponent(nodegroupid)}`; - console.log("Performing remote command for NodeGroup:", nodegroupid, "in Infra:", selectedInfraId); - } else if (radioValue === "Node") { - var url = `${tbApiBase()}/ns/${namespace}/cmd/infra/${selectedInfraId}?nodeId=${encodeURIComponent(nodeid)}`; - console.log("Performing remote command for Node:", nodeid, "in Infra:", selectedInfraId); - } - - // Get label selector value and add to URL if provided - const labelSelector = Swal.getPopup().querySelector('#labelSelector').value; - if (labelSelector && labelSelector.trim() !== '') { - url += (url.includes('?') ? '&' : '?') + `labelSelector=${encodeURIComponent(labelSelector)}`; - console.log("Added labelSelector:", labelSelector); - } - - cmd = result.value.commands; - console.log(cmd.join(", ")); - - const sshUserName = (result.value.sshUserName || '').trim(); - var commandReqTmp = { - command: cmd, - timeoutMinutes: timeoutMinutes, - }; - if (sshUserName !== '') { - commandReqTmp.userName = sshUserName; - } - - var jsonBody = JSON.stringify(commandReqTmp, undefined, 4); - - spinnerId = addSpinnerTask("Remote command to " + selectedInfraId); - - var requestId = generateRandomRequestId("cmd-" + selectedInfraId + "-", 10); - addRequestIdToSelect(requestId); - - if (useSyncMode) { - // Sync mode: wait for full response and show formatted result - console.log('[RemoteCmd] Using sync mode'); - axios({ - method: "post", - url: url, - headers: { "Content-Type": "application/json", "x-request-id": requestId }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - console.log('[RemoteCmd] Sync response:', 'status=' + res.status, res); - showRemoteCmdResult(res.data, appliedDnsUrl, selectedInfraId); - removeSpinnerTask(spinnerId); - }).catch(function (error) { - if (error.response) { - console.log(error.response.data); - console.log(error.response.status); - console.log(error.response.headers); - } else { - console.log("Error", error.message); - } - console.log(error.config); - var errMsg = error.response && error.response.data - ? JSON.stringify(error.response.data, null, 2).replace(/['",]+/g, "") - : error.message || "Unknown error"; - errorAlert(errMsg); - removeSpinnerTask(spinnerId); - }); - } else { - // Async mode: use SSE streaming for real-time log output - var asyncUrl = url + (url.includes('?') ? '&' : '?') + 'async=true'; - - axios({ - method: "post", - url: asyncUrl, - headers: { "Content-Type": "application/json", "x-request-id": requestId }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - console.log('[RemoteCmd] POST response:', 'status=' + res.status, 'hasXRequestId=' + !!(res.data && res.data.xRequestId), 'url=' + asyncUrl, res); - - if (res.status === 202 && res.data && res.data.xRequestId) { - // Async mode accepted - start background SSE session and open streaming modal - var xReqId = res.data.xRequestId; - var streamUrl = `${tbApiBase()}/ns/${namespace}/stream/cmd/infra/${selectedInfraId}?xRequestId=${encodeURIComponent(xReqId)}`; - console.log('[RemoteCmd] Starting streaming session:', xReqId); - startStreamingSession(streamUrl, username, password, xReqId, selectedInfraId, spinnerId, appliedDnsUrl, templateCommands); - } else { - // Fallback: sync response (async=true not in URL or server returned non-202) - console.warn('[RemoteCmd] Sync fallback - status:', res.status, 'data:', res.data); - showRemoteCmdResult(res.data, appliedDnsUrl, selectedInfraId); - removeSpinnerTask(spinnerId); - } - }) - .catch(function (error) { - if (error.response) { - // status code is not 2xx - console.log(error.response.data); - console.log(error.response.status); - console.log(error.response.headers); - } else { - console.log("Error", error.message); - } - console.log(error.config); - - var errMsg = error.response && error.response.data - ? JSON.stringify(error.response.data, null, 2).replace(/['",]+/g, "") - : error.message || "Unknown error"; - errorAlert(errMsg); - removeSpinnerTask(spinnerId); - }); - } - - } else { - console.log("Cannot set command"); - removeSpinnerTask(spinnerId); - } - }); -} -window.setBastionNode = setBastionNode; -window.executeRemoteCmd = executeRemoteCmd; - -// Function for transferFileToInfra by remoteCmd button item. -// -// Optional `opts` lets callers preset the dialog state (used by the $$FILEPATH -// chip in the Remote Command result viewer to launch the dialog with the right -// download target already filled in): -// - mode: 'upload' | 'download' (default 'upload') -// - infraId: initially selected Infra (defaults to current selection) -// - nodeId: initially selected Node (defaults to current selection) -// - sourcePath: pre-filled "Source Path on Node" when mode === 'download' -async function transferFileToInfra(opts = {}) { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = opts.infraId || (infraidElement ? infraidElement.value : ''); - var nodegroupid = getNodeGroupIdFromNodeSelection(); - var nodeid = opts.nodeId || document.getElementById("nodeid").value; - var presetMode = opts.mode === 'download' ? 'download' : 'upload'; - var presetSourcePath = opts.sourcePath || ''; - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - // Fetch Infra list for selector - let infraListOptions = []; - try { - const infraListUrl = `${tbApiBase()}/ns/${namespace}/infra?option=id`; - const infraRes = await axios.get(infraListUrl, { - auth: { username: username, password: password } - }); - if (infraRes.data.output && infraRes.data.output.length > 0) { - infraListOptions = infraRes.data.output; - } - } catch (err) { - console.error("Failed to fetch Infra list:", err); - } - - if (infraListOptions.length === 0) { - errorAlert("No Infra available. Please create an Infra first."); - return; - } - - // Build Infra selector options HTML - const infraOptionsHtml = infraListOptions.map(m => - `` - ).join(''); - - // Fetch Node list for the selected Infra (for download target) - let vmListOptions = []; - const fetchVmList = async (targetInfraId) => { - try { - const vmListUrl = `${tbApiBase()}/ns/${namespace}/infra/${targetInfraId}`; - const vmRes = await axios.get(vmListUrl, { - auth: { username: username, password: password } - }); - if (vmRes.data && vmRes.data.node) { - return vmRes.data.node.map(nd => ({ - id: nd.id, - nodeGroupId: nd.nodeGroupId || 'default', - publicIP: nd.publicIP || '' - })); - } - } catch (err) { - console.error("Failed to fetch Node list:", err); - } - return []; - }; - - vmListOptions = await fetchVmList(infraid || infraListOptions[0]); - - const buildNodeOptionsHtml = (nodes) => { - if (nodes.length === 0) return ''; - return nodes.map(nd => - `` - ).join(''); - }; - - console.log("Opening file transfer dialog (context Infra: " + infraid + ")"); - - Swal.fire({ - title: "📁 File Transfer", - width: 800, - html: ` - ${POPUP_STYLES} - `, - showCancelButton: true, - confirmButtonText: "⬆️ Upload", - cancelButtonText: "Close", - didOpen: () => { - // Make mode switch function available - window.switchFileTransferMode = (mode) => { - const uploadBtn = document.getElementById('uploadModeBtn'); - const downloadBtn = document.getElementById('downloadModeBtn'); - const uploadSection = document.getElementById('uploadSection'); - const downloadSection = document.getElementById('downloadSection'); - const uploadTargetScope = document.getElementById('uploadTargetScope'); - const downloadTargetVm = document.getElementById('downloadTargetVm'); - const confirmBtn = Swal.getConfirmButton(); - - if (mode === 'upload') { - uploadBtn.style.background = '#28a745'; - uploadBtn.style.color = 'white'; - uploadBtn.style.borderColor = '#28a745'; - downloadBtn.style.background = '#f8f9fa'; - downloadBtn.style.color = '#333'; - downloadBtn.style.borderColor = '#0d6efd'; - uploadSection.style.display = ''; - downloadSection.style.display = 'none'; - uploadTargetScope.style.display = ''; - downloadTargetVm.style.display = 'none'; - confirmBtn.textContent = '⬆️ Upload'; - confirmBtn.style.background = '#28a745'; - confirmBtn.classList.remove('swal2-styled-download'); - document.getElementById('fileTransferMode').value = 'upload'; - } else { - downloadBtn.style.background = '#0d6efd'; - downloadBtn.style.color = 'white'; - downloadBtn.style.borderColor = '#0d6efd'; - uploadBtn.style.background = '#f8f9fa'; - uploadBtn.style.color = '#333'; - uploadBtn.style.borderColor = '#28a745'; - uploadSection.style.display = 'none'; - downloadSection.style.display = ''; - uploadTargetScope.style.display = 'none'; - downloadTargetVm.style.display = ''; - confirmBtn.textContent = '⬇️ Download'; - confirmBtn.style.background = '#0d6efd'; - document.getElementById('fileTransferMode').value = 'download'; - } - }; - - // Add hidden input to track mode - const hiddenInput = document.createElement('input'); - hiddenInput.type = 'hidden'; - hiddenInput.id = 'fileTransferMode'; - hiddenInput.value = 'upload'; - Swal.getPopup().appendChild(hiddenInput); - - // Update Node list when Infra selector changes - document.getElementById('infraSelector').addEventListener('change', async (e) => { - const selectedInfra = e.target.value; - const newNodeList = await fetchVmList(selectedInfra); - const nodeSelector = document.getElementById('downloadNodeSelector'); - nodeSelector.innerHTML = buildNodeOptionsHtml(newNodeList); - }); - - // Favorites for post-transfer command (persisted via localStorage) - const FAV_KEY = 'postTransferCmdFavorites'; - const getFavs = () => JSON.parse(localStorage.getItem(FAV_KEY) || '[]'); - const setFavs = (f) => localStorage.setItem(FAV_KEY, JSON.stringify(f)); - - // Seed default favorites on first use - if (!localStorage.getItem(FAV_KEY)) { - setFavs([ - // Web server deployment - 'sudo mv /home/cb-user/* /var/www/html/', - 'sudo cp /home/cb-user/nginx.conf /etc/nginx/sites-available/default && sudo systemctl restart nginx', - 'sudo cp /home/cb-user/nginx.conf /etc/nginx/ && sudo nginx -t && sudo systemctl reload nginx', - // Directory creation - 'sudo mkdir -p /var/www/html && sudo mv /home/cb-user/* /var/www/html/', - 'mkdir -p /home/cb-user/app && mv /home/cb-user/* /home/cb-user/app/', - // Permissions & script execution - 'chmod +x /home/cb-user/*.sh && bash /home/cb-user/*.sh', - 'sudo chmod +x /home/cb-user/*.sh && sudo bash /home/cb-user/*.sh', - // Archive extraction to web root (auto-detect format, overwrite-safe) - 'which unzip || sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y unzip; f=/home/cb-user/{filename}; case "$f" in *.zip) sudo unzip -o "$f" -d /var/www/html/ ;; *.tar.gz|*.tgz) sudo tar -xzf "$f" -C /var/www/html/ ;; *.tar.bz2) sudo tar -xjf "$f" -C /var/www/html/ ;; esac', - // Archive extraction to home dir - 'tar -xzf /home/cb-user/*.tar.gz -C /home/cb-user/', - 'unzip /home/cb-user/*.zip -d /home/cb-user/', - // Docker - 'docker load -i /home/cb-user/*.tar', - 'cd /home/cb-user && docker compose up -d', - // Package install - 'sudo dpkg -i /home/cb-user/*.deb', - // Systemd service - 'sudo cp /home/cb-user/*.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now $(basename /home/cb-user/*.service)', - // Disk & system usage - 'df -h', - 'du -sh /home/cb-user/*', - 'free -h && df -h', - ]); - } - - const refreshFavBadge = () => { - const f = getFavs(); - const badge = document.getElementById('favoritesCountBadge'); - badge.style.display = f.length > 0 ? '' : 'none'; - badge.textContent = `${f.length} saved`; - }; - - const renderFavList = () => { - const f = getFavs(); - const dropdown = document.getElementById('favoritesDropdown'); - if (f.length === 0) { - dropdown.innerHTML = '
    No favorites saved yet.
    '; - return; - } - dropdown.innerHTML = f.map((cmd, i) => - `
    - ${cmd} - -
    ` - ).join(''); - }; - - refreshFavBadge(); - - document.getElementById('saveFavBtn').addEventListener('click', () => { - const cmd = document.getElementById('postTransferCmd').value.trim(); - if (!cmd) return; - const f = getFavs(); - if (f.includes(cmd)) { - const btn = document.getElementById('saveFavBtn'); - btn.style.borderColor = '#aaa'; - setTimeout(() => { btn.style.borderColor = '#ffc107'; }, 1500); - return; - } - f.push(cmd); - setFavs(f); - refreshFavBadge(); - const btn = document.getElementById('saveFavBtn'); - btn.textContent = '✅'; - setTimeout(() => { btn.textContent = '⭐'; }, 1200); - }); - - document.getElementById('showFavBtn').addEventListener('click', () => { - const dropdown = document.getElementById('favoritesDropdown'); - if (dropdown.style.display === 'none') { - renderFavList(); - dropdown.style.display = ''; - } else { - dropdown.style.display = 'none'; - } - }); - - document.getElementById('favoritesDropdown').addEventListener('click', (e) => { - const item = e.target.closest('.fav-cmd-item'); - const delBtn = e.target.closest('.fav-del-btn'); - if (item) { - const f = getFavs(); - document.getElementById('postTransferCmd').value = f[+item.dataset.idx] || ''; - document.getElementById('favoritesDropdown').style.display = 'none'; - } else if (delBtn) { - const f = getFavs(); - f.splice(+delBtn.dataset.idx, 1); - setFavs(f); - refreshFavBadge(); - renderFavList(); - } - }); - - // Apply preset options (e.g., when invoked from a $$FILEPATH chip in the - // Remote Command result viewer). Switch to download mode and pre-fill the - // Source Path on Node so the user only has to confirm. - if (presetMode === 'download') { - try { window.switchFileTransferMode('download'); } catch (_) { /* noop */ } - if (presetSourcePath) { - const sp = document.getElementById('sourcePathInput'); - if (sp) sp.value = presetSourcePath; - } - // Ensure the requested Node is selected in the download Node selector. - if (nodeid) { - const sel = document.getElementById('downloadNodeSelector'); - if (sel) { - const hasOpt = Array.from(sel.options).some(o => o.value === nodeid); - if (hasOpt) sel.value = nodeid; - } - } - } - }, - preConfirm: () => { - const mode = document.getElementById('fileTransferMode').value; - const selectedInfra = document.getElementById('infraSelector').value; - - if (mode === 'upload') { - const fileInput = document.getElementById('fileInput'); - const targetPath = document.getElementById('targetPathInput').value; - const files = Array.from(fileInput.files); - if (files.length === 0) { - Swal.showValidationMessage('Please select file(s) to upload.'); - return false; - } - const fileSizeLimit = 50 * 1024 * 1024; // 50MB - const oversized = files.find(f => f.size > fileSizeLimit); - if (oversized) { - Swal.showValidationMessage(`File "${oversized.name}" is too large. Maximum upload size is 50MB per file.`); - return false; - } - if (!targetPath) { - Swal.showValidationMessage('Please specify the target path.'); - return false; - } - const postTransferCmd = document.getElementById('postTransferCmd').value.trim(); - return { mode, selectedInfra, files, targetPath, postTransferCmd }; - } else { - const selectedVm = document.getElementById('downloadNodeSelector').value; - const sourcePath = document.getElementById('sourcePathInput').value; - if (!selectedVm) { - Swal.showValidationMessage('Please select a Node.'); - return false; - } - if (!sourcePath) { - Swal.showValidationMessage('Please specify the source file path on the Node.'); - return false; - } - return { mode, selectedInfra, selectedVm, sourcePath }; - } - }, - }).then((result) => { - if (result.value) { - const { mode, selectedInfra } = result.value; - - if (mode === 'upload') { - // === UPLOAD (supports multiple files) === - const { files, targetPath, postTransferCmd } = result.value; - const radioValue = Swal.getPopup().querySelector('input[name="selectOption"]:checked').value; - const endpoint = postTransferCmd ? 'transferFileAndCmd' : 'transferFile'; - let url = `${tbApiBase()}/ns/${namespace}/${endpoint}/infra/${selectedInfra}`; - if (radioValue === 'NodeGroup') { - url += `?nodeGroupId=${encodeURIComponent(nodegroupid)}`; - } else if (radioValue === 'Node') { - url += `?nodeId=${encodeURIComponent(nodeid)}`; - } - - const totalFiles = files.length; - const scopeLabel = radioValue === 'NodeGroup' ? nodegroupid : radioValue === 'Node' ? nodeid : selectedInfra; - - Swal.fire({ - title: `⬆️ Uploading (0/${totalFiles})...`, - html: `
    -

    Files: ${totalFiles} file(s) selected

    -

    Target: ${window.escapeHtml(targetPath)}

    -

    Scope: ${window.escapeHtml(radioValue)} ${window.escapeHtml(scopeLabel)}

    - ${postTransferCmd ? `

    Post-cmd: ${window.escapeHtml(postTransferCmd)}

    ` : ''} -
    Preparing...
    -
    `, - allowOutsideClick: false, - didOpen: () => { Swal.showLoading(); }, - }); - - // Upload files in parallel batches of 5 - (async () => { - const allResults = []; - const fileErrors = []; - let lastResData = null; - const allResData = []; - let completed = 0; - const CONCURRENCY = 3; - - const uploadFile = async (file) => { - const formData = new FormData(); - formData.append('file', file); - formData.append('path', targetPath.replace(/\/+$/, '')); - if (postTransferCmd) { - formData.append('command', postTransferCmd.replaceAll('{filename}', file.name)); - } - try { - const res = await axios({ - method: 'post', - url: url, - headers: { - 'Authorization': `Basic ${btoa(`${username}:${password}`)}`, - 'Content-Type': 'multipart/form-data', - }, - data: formData, - }); - lastResData = res.data; - allResData.push({ fileName: file.name, ...res.data }); - const results = res.data.results || []; - results.forEach(r => allResults.push({ ...r, _fileName: file.name })); - } catch (error) { - console.error(`Upload error for ${file.name}:`, error); - const errMsg = error.response?.data?.message || error.message || 'Request failed'; - fileErrors.push({ name: file.name, error: errMsg }); - } - completed++; - const progressEl = document.getElementById('uploadProgressDetail'); - if (progressEl) progressEl.textContent = `${completed}/${totalFiles} done...`; - Swal.update({ title: `⬆️ Uploading (${completed}/${totalFiles})...` }); - }; - - for (let i = 0; i < totalFiles; i += CONCURRENCY) { - await Promise.all(files.slice(i, i + CONCURRENCY).map(uploadFile)); - } - - // Show accumulated results - const successCount = allResults.filter(r => !r.error).length; - const failCount = allResults.filter(r => r.error).length + fileErrors.length; - - let resultHtml = `
    `; - resultHtml += `

    📁 ${totalFiles} file(s) — ✅ ${successCount} succeeded, ❌ ${failCount} failed

    `; - - // Per-file request errors - fileErrors.forEach(fe => { - resultHtml += `
    - ❌ ${window.escapeHtml(fe.name)} — ${window.escapeHtml(fe.error)} -
    `; - }); - - // Per-Node results - allResults.forEach(r => { - const isSuccess = !r.error; - const safeNodeId = window.escapeHtml(r.nodeId || ''); - const safeNodeIp = window.escapeHtml(r.nodeIp || 'N/A'); - const safeFileName = window.escapeHtml(r._fileName || ''); - const safeDetail = isSuccess - ? '✅ ' + window.escapeHtml(r.stdout && r.stdout['0'] || 'OK') - : '❌ ' + window.escapeHtml(r.error || r.stderr && r.stderr['0'] || 'Failed'); - resultHtml += `
    - ${safeNodeId} (${safeNodeIp}) — ${safeFileName} — ${safeDetail} -
    `; - }); - resultHtml += `
    `; - - Swal.fire({ - icon: failCount === 0 ? 'success' : 'warning', - title: `Upload ${failCount === 0 ? 'Complete' : 'Partial'}`, - html: resultHtml, - width: 700, - }); - if (lastResData) { - // Show combined results for all files in JSON panel - const combinedData = totalFiles > 1 - ? { totalFiles, results: allResData } - : lastResData; - displayJsonData(combinedData, typeInfo); - } - })(); - - } else { - // === DOWNLOAD === - const { selectedVm, sourcePath } = result.value; - const url = `${tbApiBase()}/ns/${namespace}/downloadFile/infra/${selectedInfra}/node/${selectedVm}`; - - Swal.fire({ - title: '⬇️ Downloading...', - html: `
    -

    File: ${window.escapeHtml(sourcePath)}

    -

    From: ${window.escapeHtml(selectedVm)} in ${window.escapeHtml(selectedInfra)}

    -
    `, - allowOutsideClick: false, - didOpen: () => { Swal.showLoading(); }, - }); - - axios({ - method: 'post', - url: url, - headers: { - 'Authorization': `Basic ${btoa(`${username}:${password}`)}`, - 'Content-Type': 'application/json', - }, - data: JSON.stringify({ sourcePath: sourcePath }), - responseType: 'blob', - }) - .then((res) => { - // Extract filename from Content-Disposition header or use source path - let downloadFileName = sourcePath.split('/').pop() || 'downloaded_file'; - const contentDisposition = res.headers['content-disposition']; - if (contentDisposition) { - const match = contentDisposition.match(/filename="?([^";\n]+)"?/); - if (match) downloadFileName = match[1]; - } - - // Create download link - const blob = new Blob([res.data]); - const downloadUrl = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = downloadFileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(downloadUrl); - - Swal.fire({ - icon: 'success', - title: 'Download Complete', - html: `
    -

    ✅ File ${window.escapeHtml(downloadFileName)} (${(blob.size / 1024).toFixed(1)} KB) downloaded successfully.

    -

    Source: ${window.escapeHtml(selectedVm)} in ${window.escapeHtml(selectedInfra)}

    -
    `, - }); - }) - .catch(async (error) => { - console.error('Download error:', error); - // For blob responseType, error response data needs special handling - let errMsg = error.message || 'Unknown error'; - if (error.response?.data instanceof Blob) { - try { - const text = await error.response.data.text(); - const parsed = JSON.parse(text); - errMsg = parsed.message || JSON.stringify(parsed, null, 2); - } catch (e) { - errMsg = 'Download failed. Check if the file path is correct and the Node is running.'; - } - } else if (error.response?.data) { - errMsg = JSON.stringify(error.response.data, null, 2).replace(/['",]+/g, ''); - } - errorAlert(errMsg); - }); - } - } else { - console.log('File transfer was canceled.'); - } - }); -} -window.transferFileToInfra = transferFileToInfra; - -// function for getAccessInfo of Infra -function getAccessInfo() { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = getSelectedInfraId(); - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - if (!infraid) { - errorAlert("Please select an Infra first"); - return; - } - - console.log( - "Retrieve access information for Infra:" + infraid - ); - - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}?option=accessinfo`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - console.log(res); // for debug - displayAccessInfoGui(res.data, infraid); - }); -} -window.getAccessInfo = getAccessInfo; - - -// SSH Key save function (single Node) -const saveBtn = document.querySelector(".save-file"); -saveBtn.addEventListener("click", function () { - console.log(" [Retrieve Infra Access Information ...]\n"); - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = infraidElement.value; - var groupid = getNodeGroupIdFromNodeSelection(); - var nodeid = document.getElementById("nodeid").value; - - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}?option=accessinfo&accessInfoOption=showSshKey`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - console.log(res); // for debug - displayJsonData(res.data, typeInfo); - var privateKey = ""; - - for (let nodeGroupAccessInfo of res.data.InfraNodeGroupAccessInfo) { - if (nodeGroupAccessInfo.NodeGroupId == groupid) { - for (let nodeAccessInfo of nodeGroupAccessInfo.NodeAccessInfo) { - if (nodeAccessInfo.nodeId == nodeid) { - privateKey = nodeAccessInfo.privateKey.replace(/['",]+/g, ""); - break; - } - } - } - } - - var tempLink = document.createElement("a"); - var taBlob = new Blob([privateKey], { type: "text/plain" }); - - tempLink.setAttribute("href", URL.createObjectURL(taBlob)); - tempLink.setAttribute("download", `${namespace}-${infraid}-${nodeid}.pem`); - tempLink.click(); - - URL.revokeObjectURL(tempLink.href); - }); -}); - -// Download ALL Node SSH keys in an Infra as a single zip file -// infraIdOverride: optional Infra id (e.g., passed from the provisioning result -// popup). When omitted, falls back to the currently selected #infraid input. -function downloadAllSshKeys(infraIdOverride) { - console.log(" [Download All SSH Keys as ZIP ...]\n"); - - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = infraIdOverride || (infraidElement ? infraidElement.value : ""); - - if (!namespace || !infraid) { - Swal.fire("Error", "Please select a namespace and Infra first.", "warning"); - return; - } - - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}?option=accessinfo&accessInfoOption=showSshKey`; - - Swal.fire({ - title: "Downloading SSH Keys...", - html: "Retrieving access information for all Nodes.", - allowOutsideClick: false, - didOpen: () => { Swal.showLoading(); } - }); - - // Fetch both Infra info and access info in parallel - var infraInfoUrl = `${tbApiBase()}/ns/${namespace}/infra/${infraid}`; - var authConfig = { username: username, password: password }; - - Promise.all([ - axios({ method: "get", url: url, auth: authConfig }), - axios({ method: "get", url: infraInfoUrl, auth: authConfig }) - ]).then(([accessRes, infoRes]) => { - const zip = new JSZip(); - let keyCount = 0; - - // Sanitize path component to prevent zip-slip (strip path separators and traversal segments) - const safeName = (name) => String(name).replace(/[\\/]/g, "_").replace(/\.\./g, "_").replace(/[^a-zA-Z0-9._-]/g, "_") || "unknown"; - - for (let nodeGroupAccessInfo of (accessRes.data.InfraNodeGroupAccessInfo || [])) { - const nodeGroupId = safeName(nodeGroupAccessInfo.NodeGroupId || "unknown"); - const nodeGroupVmSummaries = []; - for (let nodeAccessInfo of (nodeGroupAccessInfo.NodeAccessInfo || [])) { - const nodeId = safeName(nodeAccessInfo.nodeId || "unknown"); - const privateKey = (nodeAccessInfo.privateKey || "").replace(/['",]+/g, ""); - if (privateKey) { - zip.file(`${nodeGroupId}/${safeName(namespace)}-${safeName(infraid)}-${nodeId}.pem`, privateKey); - keyCount++; - } - // Collect per-Node summary for nodegroup JSON - nodeGroupVmSummaries.push({ - nodeId: nodeId, - publicIP: nodeAccessInfo.publicIP || "", - privateIP: nodeAccessInfo.privateIP || "", - sshPort: nodeAccessInfo.sshPort || 22, - nodeUserName: nodeAccessInfo.nodeUserName || "", - keyFile: `${safeName(namespace)}-${safeName(infraid)}-${nodeId}.pem` - }); - } - // Add per-nodegroup access info JSON - if (nodeGroupVmSummaries.length > 0) { - zip.file(`${nodeGroupId}/access-info.json`, JSON.stringify({ - nodeGroupId: nodeGroupId, - ndCount: nodeGroupVmSummaries.length, - nodes: nodeGroupVmSummaries - }, null, 2)); - } - } - - if (keyCount === 0) { - Swal.fire("No Keys Found", "No SSH private keys were found for this Infra.", "info"); - return; - } - - // Add Infra info JSON - zip.file(`${safeName(infraid)}-info.json`, JSON.stringify(infoRes.data, null, 2)); - - // Add access info JSON (redact privateKey to avoid duplication with .pem files) - const redactedAccessInfoJson = JSON.stringify( - accessRes.data, - (key, value) => (key === "privateKey" ? undefined : value), - 2 - ); - zip.file(`${safeName(infraid)}-access-info.json`, redactedAccessInfoJson); - - return zip.generateAsync({ type: "blob" }).then((content) => { - var tempLink = document.createElement("a"); - tempLink.setAttribute("href", URL.createObjectURL(content)); - tempLink.setAttribute("download", `${namespace}-${infraid}-ssh-keys.zip`); - tempLink.click(); - URL.revokeObjectURL(tempLink.href); - - Swal.fire({ - icon: "success", - title: "Download Complete", - html: `Downloaded ${keyCount} SSH key(s) as a ZIP file.
    ${window.escapeHtml(namespace)}-${window.escapeHtml(infraid)}-ssh-keys.zip`, - timer: 3000, - showConfirmButton: false - }); - }); - }).catch((err) => { - console.error(err); - Swal.fire("Error", "Failed to retrieve SSH keys: " + (err.message || err), "error"); - }); -} -window.downloadAllSshKeys = downloadAllSshKeys; -window.reviewRetryFailedNodes = reviewRetryFailedNodes; -window.executeRetryFailedNodes = executeRetryFailedNodes; -window.applySiblingZones = applySiblingZones; -window.fixAndReplaceNodeGroup = fixAndReplaceNodeGroup; - -// Global array to store X-Request-Ids -let xRequestIds = []; - -// Function to handle Axios response and extract X-Request-Id -function handleAxiosResponse(response) { - // Extract X-Request-Id from the response headers - console.log("Response Headers:", response.headers); - const requestId = response.headers["x-request-id"]; - console.log("X-Request-Id:", requestId); - if (requestId) { - addRequestIdToSelect(requestId); - } -} - -// Function to add X-Request-Id to the select element -function addRequestIdToSelect(requestId) { - // Add X-Request-Id to the global array if it's not already present - if (!xRequestIds.includes(requestId)) { - xRequestIds.push(requestId); - const select = document.getElementById("xRequestIdSelect"); - const option = document.createElement("option"); - option.value = requestId; - option.text = requestId; - select.appendChild(option); - } -} - -// Function to generate a random X-Request-Id with a prefix and specified total length -function generateRandomRequestId(prefix, totalLength) { - const characters = '0123456789'; - let result = prefix; - const charactersLength = characters.length; - const randomPartLength = totalLength; - for (let i = 0; i < randomPartLength; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return result; -} - -// Function to handle selection of an X-Request-Id -function handleRequestIdSelection() { - const select = document.getElementById("xRequestIdSelect"); - const selectedRequestId = select.value; - console.log("Selected X-Request-Id:", selectedRequestId); - - // actions based on the selected X-Request-Id - - if (selectedRequestId) { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - - var url = `${tbApiBase()}/request/${selectedRequestId}`; - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - }).then((res) => { - console.log(res); // for debug - displayJsonData(res.data, typeInfo); - }); - } else { - console.log("No X-Request-Id selected"); - } -} -window.handleRequestIdSelection = handleRequestIdSelection; - -window.onload = function () { - // Get host address and update configuration - var tbServerAp = window.location.host; - var strArray = tbServerAp.split(":"); - console.log("Host address: " + strArray[0]); - configHostname = strArray[0]; - - // Use the new connection check with retry instead of single getConnection call - setTimeout(checkConnectionWithRetry, 1000); - - updateCredentialHolderList(); - updateNsList(); - - getInfra(); - - // Add event listener for Provision tab to show map when clicked - const provisionTab = document.getElementById('provision-tab'); - if (provisionTab) { - provisionTab.addEventListener('click', function(e) { - console.log('Provision tab clicked, switching to map view'); - // Small delay to allow tab to activate first - setTimeout(function() { - if (typeof showMap === 'function') { - showMap(); - } else { - console.log('showMap function not found'); - } - }, 100); - }); - } -}; - -let drawCounter = 0; -const shuffleInterval = 200; // Shuffle every shuffleInterval draws -let shuffledKeys = Object.keys(cspIconStyles); // Initialize with original keys - -function shuffleKeys() { - shuffledKeys = Object.keys(cspIconStyles) - .map((key) => ({ key, sort: Math.random() })) // Map to array of objects with random sort values - .sort((a, b) => a.sort - b.sort) // Sort by random values - .map(({ key }) => key); // Extract the keys -} - -// Section for general tools - -function jsonToTable(jsonText) { - let arr00 = new Array(); - let arr01 = new Array(); - let arr02 = new Array(); - let arr03 = new Array(); - let arr04 = new Array(); - let arr05 = new Array(); - - let json = JSON.parse(jsonText); - - for (i = 0; i < json.length; i++) { - arr00[i] = json[i].connectionName; - arr01[i] = json[i].cspSpecName; - arr02[i] = json[i].vCPU; - arr03[i] = json[i].memoryGiB; - arr04[i] = json[i].costPerHour; - arr05[i] = json[i].evaluationScore09; - } - - // Header - let tr0 = document.createElement("tr"); - - let th0 = document.createElement("th"); - th0.appendChild(document.createTextNode(" cspRegion")); - let th1 = document.createElement("th"); - th1.appendChild(document.createTextNode(" cspSpecName")); - let th2 = document.createElement("th"); - th2.appendChild(document.createTextNode(" vCPU")); - let th3 = document.createElement("th"); - th3.appendChild(document.createTextNode(" memoryGiB")); - let th4 = document.createElement("th"); - th4.appendChild(document.createTextNode(" costPerHour")); - let th5 = document.createElement("th"); - th5.appendChild(document.createTextNode(" evaluationScore")); - - tr0.appendChild(th0); - tr0.appendChild(th1); - tr0.appendChild(th2); - tr0.appendChild(th3); - tr0.appendChild(th4); - tr0.appendChild(th5); - table.appendChild(tr0); - - for (i = 0; i < arr01.length; i++) { - let tr = document.createElement("tr"); - - let td0 = document.createElement("td"); - td0.appendChild(document.createTextNode(" " + arr00[i] + "")); - - let td1 = document.createElement("td"); - td1.appendChild(document.createTextNode(" " + arr01[i] + "")); - - let td2 = document.createElement("td"); - td2.appendChild(document.createTextNode(" " + arr02[i] + "")); - - let td3 = document.createElement("td"); - td3.appendChild(document.createTextNode(" " + arr03[i] + "")); - - let td4 = document.createElement("td"); - td4.appendChild(document.createTextNode(" " + arr04[i] + "")); - - let td5 = document.createElement("td"); - td5.appendChild(document.createTextNode(" " + arr05[i] + "")); - - tr.appendChild(td0); - tr.appendChild(td1); - tr.appendChild(td2); - tr.appendChild(td3); - tr.appendChild(td4); - tr.appendChild(td5); - - table.appendChild(tr); - } -} - - -function updateFirewallRules(opts) { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var nsId = configNamespace; - // opts.infraId / opts.preselectSgId let callers (e.g. the Net-graph SG group - // right-click) target a specific Infra and pre-open that SG's tab. - var infraId = (opts && opts.infraId) || infraidElement.value; - var preselectSgId = (opts && opts.preselectSgId) || ''; - var nodegroupid = getNodeGroupIdFromNodeSelection(); - var nodeid = document.getElementById("nodeid").value; - - if (!nsId) { - errorAlert("Please select a namespace first"); - return; - } - if (!infraId) { - errorAlert("Please select an Infra first"); - return; - } - - const assocUrl = `${tbApiBase()}/ns/${nsId}/infra/${infraId}/associatedResources`; - axios({ - method: "get", - url: assocUrl, - auth: { username, password }, - }).then((assocRes) => { - const sgIds = (assocRes.data?.securityGroupIds || []); - if (sgIds.length === 0) { - errorAlert("No associated Security Groups found for this Infra."); - return; - } - - Promise.all(sgIds.map(sgId => - axios({ - method: "get", - url: `${tbApiBase()}/ns/${nsId}/resources/securityGroup/${sgId}`, - auth: { username, password }, - }).then(res => res.data) - )).then((sgList) => { - - // Pre-select the tab for the requested SG (default: first). - const activeIdx = Math.max(0, sgList.findIndex(sg => sg.id === preselectSgId || sg.name === preselectSgId)); - - let summaryHtml = ` -
    - Security Group Rule Summary -
    - - - - -
    - ${sgList.map((sg, idx) => ` -
    -
    -
    -
    -
    ${sg.name}
    - Security Group ID: ${sg.id} -
    - -
    - -
    - ${(sg.firewallRules||[]).length > 0 ? ` - - - - - - - - - - - - ${sg.firewallRules.map((rule, ruleIdx) => ` - - - - - - - - `).join("")} - -
    DirectionProtocolPortCIDRActions
    - - ${(rule.Direction || rule.direction || "").toUpperCase()} - - - - ${(rule.Protocol || rule.protocol || "").toUpperCase()} - - - ${rule.Port || rule.port || ""} - - ${rule.CIDR || rule.cidr || ""} - - -
    - ` : ` -
    -
    🔒
    -
    No firewall rules defined
    -
    Click "Add Rule" to create your first rule
    -
    - `} -
    -
    -
    - `).join("")} -
    -
    -
    - `; - - let firstRule = null; - if (sgList.length > 0 && (sgList[0].firewallRules || []).length > 0) { - firstRule = sgList[0].firewallRules[0]; - } - - let rulesHtml = ` -
    -
    - - 🔧 - Apply Same Rules to All Security Groups - - -
    -
    -
    - Note: These rules will be applied to all Security Groups associated with this Infra, replacing existing rules. -
    -
    - - - - - - - - - - - - -
    DirectionProtocolPortsCIDRActions
    -
    -
    📝
    -
    No rules defined yet
    -
    Click "Add Rule" to create your first rule
    -
    -
    -
    -
    - `; - - - const presetRules = [ - { direction: "inbound", protocol: "TCP", port: "22", cidr: "0.0.0.0/0", label: "SSH (22)" }, - { direction: "inbound", protocol: "TCP", port: "80", cidr: "0.0.0.0/0", label: "HTTP (80)" }, - { direction: "inbound", protocol: "TCP", port: "443", cidr: "0.0.0.0/0", label: "HTTPS (443)" }, - { direction: "inbound", protocol: "TCP", port: "1-65535", cidr: "0.0.0.0/0", label: "All TCP Ports" }, - { direction: "inbound", protocol: "UDP", port: "1-65535", cidr: "0.0.0.0/0", label: "All UDP Ports" }, - { direction: "inbound", protocol: "ICMP", port: "", cidr: "0.0.0.0/0", label: "ICMP" }, - { direction: "inbound", protocol: "ALL", port: "", cidr: "0.0.0.0/0", label: "All Protocols" }, - // Outbound presets. This dialog REPLACES every rule on the selected security - // groups, so a rule set with no outbound entry removes egress entirely. On - // clouds that deny egress by default when a security group carries no egress - // rule (OpenStack/Neutron), that silently cuts the node off: inbound SSH keeps - // working because the groups are stateful, and the loss only surfaces later as - // DNS or download failures from the node. - { direction: "outbound", protocol: "ALL", port: "", cidr: "0.0.0.0/0", label: "↗ All Outbound" }, - { direction: "outbound", protocol: "TCP", port: "1-65535", cidr: "0.0.0.0/0", label: "↗ Outbound TCP" }, - { direction: "outbound", protocol: "UDP", port: "1-65535", cidr: "0.0.0.0/0", label: "↗ Outbound UDP" }, - ]; - // Index of the "all outbound" preset, used to seed the editor below. - const outboundAllPresetIdx = presetRules.findIndex( - (p) => p.direction === "outbound" && p.protocol === "ALL" - ); - let presetHtml = presetRules.map((p, i) => - `` - ).join(""); - - Swal.fire({ - title: "Update Security Group Rules", - html: ` -
    - ${summaryHtml} -
    -
    📋 Frequently Used Rules
    -
    ${presetHtml}
    -
    -
    ${rulesHtml}
    -
    - `, - width: 1200, - showCancelButton: true, - confirmButtonText: "Apply", - cancelButtonText: "Cancel", - didOpen: () => { - // Initialize Bootstrap tabs functionality for Security Groups - const tabButtons = document.querySelectorAll('#sgTabs button[data-bs-toggle="tab"]'); - tabButtons.forEach(button => { - button.addEventListener('click', function (e) { - e.preventDefault(); - - // Remove active class from all tabs and content - tabButtons.forEach(btn => { - btn.classList.remove('active'); - btn.style.color = '#6c757d'; - btn.style.borderBottomColor = 'transparent'; - btn.style.backgroundColor = 'transparent'; - // Update badge color - const badge = btn.querySelector('span:last-child'); - if (badge) badge.style.backgroundColor = '#6c757d'; - }); - // Scope to THIS modal's tab content only — a bare '.tab-pane' - // selector also matched the map's left Control/Provisioning panes - // (they are Bootstrap tab-panes too), wiping the Control panel. - document.querySelectorAll('#sgTabContent .tab-pane').forEach(pane => { - pane.classList.remove('show', 'active'); - }); - - // Add active class to clicked tab - this.classList.add('active'); - this.style.color = '#007bff'; - this.style.borderBottomColor = '#007bff'; - this.style.backgroundColor = 'transparent'; - // Update badge color for active tab - const activeBadge = this.querySelector('span:last-child'); - if (activeBadge) activeBadge.style.backgroundColor = '#007bff'; - - // Show corresponding content - const target = this.getAttribute('data-bs-target'); - const targetPane = document.querySelector(target); - if (targetPane) { - targetPane.classList.add('show', 'active'); - } - }); - }); - - window.addRuleRowAll = function () { - const tbody = document.getElementById("sg-rules-all"); - const emptyDiv = document.getElementById("sg-rules-all-empty"); - - // Hide empty state message when adding first rule - if (emptyDiv && tbody.rows.length === 0) { - emptyDiv.style.display = 'none'; - } - - let def = { direction: "inbound", protocol: "TCP", ports: "", cidr: "" }; - if (firstRule) { - def.direction = firstRule.Direction || firstRule.direction || "inbound"; - def.protocol = (firstRule.Protocol || firstRule.protocol || "TCP").toUpperCase(); - def.ports = firstRule.Ports || firstRule.port || ""; - def.cidr = firstRule.CIDR || firstRule.cidr || ""; - } - const row = document.createElement("tr"); - row.style.transition = "background-color 0.2s"; - row.innerHTML = ` - - - - - - - - - - - - - - - - `; - tbody.appendChild(row); - - // Check initial protocol and disable port field if needed - const protocolSelect = row.querySelector('select[name="protocol"]'); - const portInput = row.querySelector('input[name="Ports"]'); - if (protocolSelect.value === "ICMP" || protocolSelect.value === "ALL") { - portInput.disabled = true; - portInput.value = ""; - portInput.placeholder = "Not applicable for " + protocolSelect.value; - portInput.style.backgroundColor = "#f8f9fa"; - } - }; - - // Add remove function for rules - window.removeRuleRowAll = function(button) { - const row = button.closest('tr'); - const tbody = document.getElementById("sg-rules-all"); - const emptyDiv = document.getElementById("sg-rules-all-empty"); - - row.remove(); - - // Show empty state message if no rules left - if (emptyDiv && tbody.rows.length === 0) { - emptyDiv.style.display = 'block'; - } - }; - - // Function to toggle port field based on protocol selection - window.togglePortField = function(protocolSelect) { - const row = protocolSelect.closest('tr'); - const portInput = row.querySelector('input[name="Ports"]'); - const protocol = protocolSelect.value; - - if (protocol === "ICMP" || protocol === "ALL") { - portInput.disabled = true; - portInput.value = ""; - portInput.placeholder = "Not applicable for " + protocol; - portInput.style.backgroundColor = "#f8f9fa"; - portInput.style.color = "#6c757d"; - } else { - portInput.disabled = false; - portInput.placeholder = "ex: 22,80,1000-2000"; - portInput.style.backgroundColor = ""; - portInput.style.color = ""; - } - }; - - window.insertPresetRule = function (presetIdx) { - const p = presetRules[presetIdx]; - const tbody = document.getElementById("sg-rules-all"); - const emptyDiv = document.getElementById("sg-rules-all-empty"); - - // Hide empty state message when adding first rule - if (emptyDiv && tbody.rows.length === 0) { - emptyDiv.style.display = 'none'; - } - - let def = { direction: "inbound", protocol: "TCP" }; - if (firstRule) { - def.direction = firstRule.Direction || firstRule.direction || "inbound"; - def.protocol = (firstRule.Protocol || firstRule.protocol || "TCP").toUpperCase(); - } - const row = document.createElement("tr"); - row.style.transition = "background-color 0.2s"; - row.innerHTML = ` - - - - - - - - - - - - - - - - `; - tbody.appendChild(row); - - // Check protocol and disable port field if needed - const protocolSelect = row.querySelector('select[name="protocol"]'); - const portInput = row.querySelector('input[name="Ports"]'); - if (protocolSelect.value === "ICMP" || protocolSelect.value === "ALL") { - portInput.disabled = true; - portInput.value = ""; - portInput.placeholder = "Not applicable for " + protocolSelect.value; - portInput.style.backgroundColor = "#f8f9fa"; - } - }; - - // Start the editor with egress already allowed. Since applying replaces the - // whole rule set, an editor that starts empty makes "add the inbound ports I - // need" quietly drop outbound access. Seeding it keeps the common case safe; - // the row can still be deleted for a deliberately egress-restricted group. - if (outboundAllPresetIdx >= 0) { - window.insertPresetRule(outboundAllPresetIdx); - } - }, - preConfirm: () => { - - const tbody = document.getElementById("sg-rules-all"); - let rules = []; - for (let row of tbody.rows) { - const direction = row.querySelector('select[name="direction"]').value; - const protocol = row.querySelector('select[name="protocol"]').value; - const port = row.querySelector('input[name="Ports"]').value.trim(); - const cidr = row.querySelector('input[name="cidr"]').value.trim(); - - // For ICMP and ALL protocols, port is not required - const isPortRequired = (protocol === "TCP" || protocol === "UDP"); - const isValidRule = direction && protocol && cidr && (!isPortRequired || (isPortRequired && port)); - - if (isValidRule) { - rules.push({ - Ports: port || "", // Use empty string for ICMP/ALL protocols - Protocol: protocol, - Direction: direction, - CIDR: cidr - }); - } - } - if (rules.length === 0) { - Swal.showValidationMessage("Add at least one rule before applying."); - return false; - } - // Applying replaces every existing rule, so a set without an outbound entry - // takes egress away. Say so rather than letting the node lose outbound access - // silently — inbound SSH keeps working, so the loss is easy to miss. - if (!rules.some(r => String(r.Direction).toLowerCase() === "outbound")) { - Swal.showValidationMessage( - "No outbound rule defined. Applying would remove outbound access from these Security Groups " + - "(inbound SSH would still work, so this is easy to miss). Add an outbound rule, " + - "or use the \"↗ All Outbound\" preset." - ); - return false; - } - return sgList.map(sg => ({ id: sg.id, name: sg.name, firewallRules: rules })); - } - }).then((result) => { - if (result.isConfirmed) { - - // Show loading message - Swal.fire({ - title: 'Updating Security Groups...', - text: 'Please wait while we update the security group rules.', - allowOutsideClick: false, - allowEscapeKey: false, - showConfirmButton: false, - didOpen: () => { - Swal.showLoading(); - } - }); - - // Since we now use UpdateMultipleFirewallRules, make a single API call instead of multiple calls - const updatePromise = axios({ - method: "put", - url: `${tbApiBase()}/ns/${nsId}/infra/${infraId}/associatedSecurityGroups`, - headers: { "Content-Type": "application/json" }, - data: JSON.stringify({ - firewallRules: result.value[0].firewallRules // All SGs get the same rules - }), - auth: { username, password }, - }).then(response => { - // Check if response has the new batch format with response array and summary - const responseData = response.data; - if (responseData && responseData.response && Array.isArray(responseData.response)) { - // New batch response format - extract individual results - return responseData.response.map(sgResponse => ({ - id: sgResponse.id, - name: sgResponse.name, - success: sgResponse.success, - message: sgResponse.message || "Successfully updated", - response: sgResponse, - summary: responseData.summary // Include summary information - })); - } else { - // Fallback for unexpected response format - return sgList.map(sg => ({ - id: sg.id, - name: sg.name, - success: false, - message: "Unexpected response format", - response: responseData - })); - } - }).catch(error => { - // Return error result for each security group - return sgList.map(sg => ({ - id: sg.id, - name: sg.name, - success: false, - message: error.response?.data?.message || error.message || "Unknown error", - error: error - })); - }); - - updatePromise.then((results) => { - const successCount = results.filter(r => r.success).length; - const failedCount = results.filter(r => r.success === false).length; - const allSuccess = failedCount === 0; - - // Extract summary if available - const summary = results.length > 0 && results[0].summary ? results[0].summary : null; - - // Calculate update time and other details - const updateTime = new Date().toLocaleString(); - - // Create detailed result table with summary information - let resultHtml = ` -
    -
    -
    - 📊 - Security Group Update Results Summary - ${summary ? '(Parallel Processing)' : ''} -
    -
    -
    -
    ${summary ? summary.total : results.length}
    -
    Total
    -
    -
    -
    ${summary ? summary.success : successCount}
    -
    Success
    -
    -
    -
    ${summary ? summary.failed : failedCount}
    -
    Failed
    -
    -
    -
    ${updateTime}
    -
    Updated
    -
    -
    - ${summary && summary.allSuccess ? - '
    ✅ All security groups updated successfully!
    ' : - summary && !summary.allSuccess ? - '
    ⚠️ Some security groups failed to update
    ' : '' - } -
    - -
    -
    - 📋 - Detailed Update Results -
    -
    - - - - - - - - - - - - - `; - - results.forEach((result, index) => { - const statusIcon = result.success ? '✅' : '❌'; - const statusText = result.success ? 'Success' : 'Failed'; - const statusColor = result.success ? '#28a745' : '#dc3545'; - const rowBgColor = result.success ? '#f8fff8' : '#fff5f5'; - const rulesCount = result.response?.firewallRules?.length || result.response?.updated?.firewallRules?.length || 'N/A'; - - resultHtml += ` - - - - - - - - - `; - }); - - resultHtml += ` - -
    #StatusSecurity GroupIDMessageDetails
    ${index + 1} -
    - ${statusIcon} - ${statusText} -
    -
    -
    ${result.name}
    -
    Rules: ${rulesCount}
    -
    - ${result.id} - -
    - ${result.message} -
    -
    - -
    -
    -
    -
    - `; - - // Add global function for showing details - window.showSgDetails = function(sgId, sgName, index) { - const result = results[index]; - let detailsHtml = ` -
    -
    - Security Group: ${sgName} - (${sgId}) -
    - `; - - if (result.success && result.response) { - const sgData = result.response.updated || result.response; - if (sgData.firewallRules && sgData.firewallRules.length > 0) { - detailsHtml += ` -
    - ✅ Updated Rules (${sgData.firewallRules.length}): -
    - - - - - - - - - - - `; - - sgData.firewallRules.forEach(rule => { - detailsHtml += ` - - - - - - - `; - }); - - detailsHtml += ` - -
    DirectionProtocolPortCIDR
    ${rule.Direction || rule.direction || 'N/A'}${rule.Protocol || rule.protocol || 'N/A'}${rule.Port || rule.port || rule.Ports || 'N/A'}${rule.CIDR || rule.cidr || 'N/A'}
    -
    -
    - `; - } - } else { - detailsHtml += ` -
    - ❌ Update Failed:
    - ${result.message} -
    - `; - - if (result.error && result.error.response) { - detailsHtml += ` -
    - Error Details:
    - ${JSON.stringify(result.error.response.data, null, 2)} -
    - `; - } - } - - detailsHtml += `
    `; - - Swal.fire({ - title: `Security Group Details`, - html: detailsHtml, - width: 600, - confirmButtonText: "Close", - confirmButtonColor: "#6c757d" - }); - }; - - if (allSuccess) { - Swal.fire({ - title: "🎉 All Updates Successful!", - html: resultHtml, - icon: "success", - width: 900, - confirmButtonText: "Excellent!", - confirmButtonColor: "#28a745" - }); - } else if (successCount > 0) { - Swal.fire({ - title: "⚠️ Partial Success", - html: resultHtml, - icon: "warning", - width: 900, - confirmButtonText: "Got it", - confirmButtonColor: "#ffc107" - }); - } else { - Swal.fire({ - title: "❌ Update Failed", - html: resultHtml, - icon: "error", - width: 900, - confirmButtonText: "Retry", - confirmButtonColor: "#dc3545" - }); - } - }).catch((err) => { - Swal.fire({ - title: "💥 Unexpected Error", - html: ` -
    -

    An unexpected error occurred while updating security group rules:

    -
    - ${err.response?.data?.message || err.message || err} -
    -

    Please check your connection and try again.

    -
    - `, - icon: "error", - width: 600, - confirmButtonText: "Close", - confirmButtonColor: "#dc3545" - }); - }); - } - }).catch((err) => { - if (err) errorAlert("Popup error: " + err); - }); - }).catch((err) => { - errorAlert("Failed to load security group details: " + (err.response?.data?.message || err)); - }); - }).catch((err) => { - errorAlert("Failed to load associated security group IDs: " + (err.response?.data?.message || err)); - }); -} -window.updateFirewallRules = updateFirewallRules; - -// Function to delete individual firewall rule -function deleteFirewallRule(sgId, sgName, ruleData) { - try { - const rule = JSON.parse(decodeURIComponent(ruleData)); - - Swal.fire({ - title: "Delete Firewall Rule", - html: ` -
    -

    Security Group: ${sgName}

    -

    Rule to delete:

    -
    - Direction: ${rule.Direction || rule.direction || 'N/A'}
    - Protocol: ${rule.Protocol || rule.protocol || 'N/A'}
    - Port: ${rule.Port || rule.port || rule.Ports || 'N/A'}
    - CIDR: ${rule.CIDR || rule.cidr || 'N/A'} -
    -

    ⚠️ This action cannot be undone.

    -
    - `, - icon: "warning", - showCancelButton: true, - confirmButtonText: "Delete", - confirmButtonColor: "#dc3545", - cancelButtonText: "Cancel", - cancelButtonColor: "#6c757d" - }).then((result) => { - if (result.isConfirmed) { - // Show loading - Swal.fire({ - title: 'Deleting Rule...', - text: 'Please wait while we delete the firewall rule.', - allowOutsideClick: false, - allowEscapeKey: false, - showConfirmButton: false, - didOpen: () => { - Swal.showLoading(); - } - }); - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var nsId = configNamespace; - - // Prepare request data for deletion - const deleteRule = { - Direction: rule.Direction || rule.direction, - Protocol: rule.Protocol || rule.protocol, - Ports: rule.Port || rule.port || rule.Ports, - CIDR: rule.CIDR || rule.cidr - }; - - axios({ - method: "delete", - url: `${tbApiBase()}/ns/${nsId}/resources/securityGroup/${sgId}/rules`, - headers: { "Content-Type": "application/json" }, - data: JSON.stringify({ - firewallRules: [deleteRule] - }), - auth: { username, password }, - }).then((response) => { - const responseData = response.data; - // Check if the response indicates success - const isSuccess = responseData.success !== false; - - if (isSuccess) { - Swal.fire({ - title: "✅ Rule Deleted Successfully", - html: ` -
    -

    The firewall rule has been successfully deleted from security group ${sgName}.

    -
    - Deleted Rule:
    - Direction: ${deleteRule.Direction}
    - Protocol: ${deleteRule.Protocol}
    - Port: ${deleteRule.Ports}
    - CIDR: ${deleteRule.CIDR} -
    - ${responseData.message ? `

    Message: ${responseData.message}

    ` : ''} -
    - `, - icon: "success", - confirmButtonText: "Refresh View", - confirmButtonColor: "#28a745" - }).then(() => { - // Refresh the firewall rules view - updateFirewallRules(); - }); - } else { - throw new Error(responseData.message || 'Failed to delete rule'); - } - }).catch((error) => { - Swal.fire({ - title: "❌ Delete Failed", - html: ` -
    -

    Failed to delete the firewall rule:

    -
    - ${error.response?.data?.message || error.message || 'Unknown error'} -
    -
    - `, - icon: "error", - confirmButtonText: "Close", - confirmButtonColor: "#dc3545" - }); - }); - } - }); - } catch (err) { - errorAlert("Error parsing rule data: " + err.message); - } -} -window.deleteFirewallRule = deleteFirewallRule; - -// Function to add new firewall rule to specific security group -function addNewRuleToSg(sgId, sgName) { - Swal.fire({ - title: `Add New Rule to ${sgName}`, - html: ` -
    -
    - - -
    -
    - - -
    -
    - - - Enter single port (22), port range (80-100), or multiple (22,80,443) -
    -
    - - - IP address range in CIDR notation -
    -
    - Quick Templates:
    - - - - - - - - - -
    -
    - `, - width: 500, - showCancelButton: true, - confirmButtonText: "Add Rule", - confirmButtonColor: "#28a745", - cancelButtonText: "Cancel", - cancelButtonColor: "#6c757d", - didOpen: () => { - // Function to toggle port field for individual rule addition - window.togglePortFieldIndividual = function() { - const protocolSelect = document.getElementById("newRuleProtocol"); - const portInput = document.getElementById("newRulePort"); - const protocol = protocolSelect.value; - - if (protocol === "ICMP" || protocol === "ALL") { - portInput.disabled = true; - portInput.value = ""; - portInput.placeholder = "Not applicable for " + protocol; - portInput.style.backgroundColor = "#f8f9fa"; - portInput.style.color = "#6c757d"; - } else { - portInput.disabled = false; - portInput.placeholder = "ex: 22, 80-100, 22,80,443"; - portInput.style.backgroundColor = ""; - portInput.style.color = ""; - } - }; - - // Initialize port field state - togglePortFieldIndividual(); - - // Define quick template function - window.setQuickTemplate = function(template) { - const protocolSelect = document.getElementById("newRuleProtocol"); - const portInput = document.getElementById("newRulePort"); - const cidrInput = document.getElementById("newRuleCidr"); - - switch(template) { - case 'SSH': - protocolSelect.value = "TCP"; - portInput.value = "22"; - cidrInput.value = "0.0.0.0/0"; - break; - case 'HTTP': - protocolSelect.value = "TCP"; - portInput.value = "80"; - cidrInput.value = "0.0.0.0/0"; - break; - case 'HTTPS': - protocolSelect.value = "TCP"; - portInput.value = "443"; - cidrInput.value = "0.0.0.0/0"; - break; - case 'MYSQL': - protocolSelect.value = "TCP"; - portInput.value = "3306"; - cidrInput.value = "0.0.0.0/0"; - break; - case 'POSTGRES': - protocolSelect.value = "TCP"; - portInput.value = "5432"; - cidrInput.value = "0.0.0.0/0"; - break; - case 'ALL_TCP': - protocolSelect.value = "TCP"; - portInput.value = "1-65535"; - cidrInput.value = "0.0.0.0/0"; - break; - case 'ALL_UDP': - protocolSelect.value = "UDP"; - portInput.value = "1-65535"; - cidrInput.value = "0.0.0.0/0"; - break; - case 'ICMP': - protocolSelect.value = "ICMP"; - portInput.value = ""; - cidrInput.value = "0.0.0.0/0"; - break; - case 'ALL_PROTOCOLS': - protocolSelect.value = "ALL"; - portInput.value = ""; - cidrInput.value = "0.0.0.0/0"; - break; - } - - // Update port field state after template selection - togglePortFieldIndividual(); - }; - }, - preConfirm: () => { - const direction = document.getElementById("newRuleDirection").value; - const protocol = document.getElementById("newRuleProtocol").value; - const port = document.getElementById("newRulePort").value.trim(); - const cidr = document.getElementById("newRuleCidr").value.trim(); - - // For ICMP and ALL protocols, port is not required - if (!direction || !protocol || !cidr) { - Swal.showValidationMessage("Direction, Protocol, and CIDR are required"); - return false; - } - - // Check port requirement based on protocol - if ((protocol === "TCP" || protocol === "UDP") && !port) { - Swal.showValidationMessage("Port is required for TCP and UDP protocols"); - return false; - } - - // Basic CIDR validation - if (!cidr.includes('/')) { - Swal.showValidationMessage("CIDR must include network prefix (e.g., 0.0.0.0/0)"); - return false; - } - - return { direction, protocol, port: port || "", cidr }; - } - }).then((result) => { - if (result.isConfirmed) { - const { direction, protocol, port, cidr } = result.value; - - // Show loading - Swal.fire({ - title: 'Adding Rule...', - text: 'Please wait while we add the new firewall rule.', - allowOutsideClick: false, - allowEscapeKey: false, - showConfirmButton: false, - didOpen: () => { - Swal.showLoading(); - } - }); - - var config = getConfig(); var hostname = config.hostname; - var portVal = config.port; - var username = config.username; - var password = config.password; - var nsId = configNamespace; - - // Prepare request data for addition - const newRule = { - Direction: direction, - Protocol: protocol, - Ports: port, - CIDR: cidr - }; - - axios({ - method: "post", - url: `${tbApiBase()}/ns/${nsId}/resources/securityGroup/${sgId}/rules`, - headers: { "Content-Type": "application/json" }, - data: JSON.stringify({ - firewallRules: [newRule] - }), - auth: { username, password }, - }).then((response) => { - const responseData = response.data; - // Check if the response indicates success - const isSuccess = responseData.success !== false; - - if (isSuccess) { - Swal.fire({ - title: "✅ Rule Added Successfully", - html: ` -
    -

    The new firewall rule has been successfully added to security group ${sgName}.

    -
    - Added Rule:
    - Direction: ${newRule.Direction}
    - Protocol: ${newRule.Protocol}
    - ${newRule.Ports ? `Port: ${newRule.Ports}
    ` : ''} - CIDR: ${newRule.CIDR} -
    - ${responseData.message ? `

    Message: ${responseData.message}

    ` : ''} -
    - `, - icon: "success", - confirmButtonText: "Refresh View", - confirmButtonColor: "#28a745" - }).then(() => { - // Refresh the firewall rules view - updateFirewallRules(); - }); - } else { - throw new Error(responseData.message || 'Failed to add rule'); - } - }).catch((error) => { - Swal.fire({ - title: "❌ Add Failed", - html: ` -
    -

    Failed to add the new firewall rule:

    -
    - ${error.response?.data?.message || error.message || 'Unknown error'} -
    -
    - `, - icon: "error", - confirmButtonText: "Close", - confirmButtonColor: "#dc3545" - }); - }); - } - }); -} -window.addNewRuleToSg = addNewRuleToSg; - -// Function for Scale Out NodeGroup -function scaleOutNodeGroup() { - var infraid = document.getElementById("infraid").value; - var nodegroupid = getNodeGroupIdFromNodeSelection(); - - if (!infraid) { - errorAlert("Please select an Infra first"); - return; - } - - if (!nodegroupid) { - errorAlert("Please select a NodeGroup first"); - return; - } - - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - // Show dialog to get number of Nodes to add - Swal.fire({ - title: "Scale Out NodeGroup", - width: 600, - html: - "" + - "
    " + - "

    Target Infra: " + infraid + "

    " + - "

    Target NodeGroup: " + nodegroupid + "

    " + - "
    " + - "

    Enter the number of Nodes to add:

    " + - "
    ", - input: "number", - inputValue: 1, - inputAttributes: { - min: 1, - max: 20, - step: 1, - autocapitalize: "off" - }, - showCancelButton: true, - confirmButtonText: "Scale Out", - confirmButtonColor: "#28a745", - cancelButtonText: "Cancel", - position: "top", - backdrop: `rgba(0, 0, 0, 0.4)`, - inputValidator: (value) => { - if (!value || value < 1) { - return 'Please enter a valid number (minimum 1)'; - } - if (value > 20) { - return 'Maximum 20 Nodes can be added at once'; - } - } - }).then((result) => { - if (result.isConfirmed) { - var numNodesToAdd = parseInt(result.value); - - // Confirmation dialog - Swal.fire({ - title: "Confirm Scale Out", - html: - "
    " + - "

    You are about to add " + numNodesToAdd + " Node(s) to:

    " + - "
      " + - "
    • Infra: " + infraid + "
    • " + - "
    • NodeGroup: " + nodegroupid + "
    • " + - "
    " + - "

    ⚠️ Warning: This will incur additional costs.

    " + - "
    ", - icon: "warning", - showCancelButton: true, - confirmButtonText: "Proceed with Scale Out", - cancelButtonText: "Cancel", - confirmButtonColor: "#28a745", - cancelButtonColor: "#dc3545" - }).then((confirmResult) => { - if (confirmResult.isConfirmed) { - executeScaleOut(namespace, infraid, nodegroupid, numNodesToAdd, hostname, port, username, password); - } - }); - } - }); -} -window.scaleOutNodeGroup = scaleOutNodeGroup; - -// Improved Scale Out NodeGroup function with Infra and NodeGroup selection -function scaleOutNodeGroupWithSelection() { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - // Use the common Infra selection dialog for ScaleOut operations - showInfraSelectionForScaleOut( - "Select Infra for Scale Out", - "Select the Infra to scale out", - (selectedInfraId) => { - showNodeGroupSelectionForScaleOut(selectedInfraId, namespace, hostname, port, username, password); - } - ); -} -window.scaleOutNodeGroupWithSelection = scaleOutNodeGroupWithSelection; - -// Scale Out function for context menu - bypasses Infra selection -function scaleOutInfraFromContext(infraId) { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - if (!infraId) { - errorAlert("Infra ID is required"); - return; - } - - // Directly show NodeGroup selection for the specified Infra - showNodeGroupSelectionForScaleOut(infraId, namespace, hostname, port, username, password); -} -window.scaleOutInfraFromContext = scaleOutInfraFromContext; - -// Copy Infra Configuration - extract Infra config and populate left panel for re-creation -function copyInfraConfig(infraId) { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - if (!infraId) { - errorAlert("Infra ID is required"); - return; - } - - var spinnerId = addSpinnerTask("Copying Infra configuration"); - - // Fetch both configCopy and Infra info in parallel - var configCopyUrl = `${tbApiBase()}/ns/${namespace}/infra/${infraId}/configCopy`; - var infraInfoUrl = `${tbApiBase()}/ns/${namespace}/infra/${infraId}`; - var authConfig = { username: username, password: password }; - - Promise.all([ - axios({ method: "get", url: configCopyUrl, auth: authConfig }), - axios({ method: "get", url: infraInfoUrl, auth: authConfig }) - ]).then(function([configRes, infoRes]) { - removeSpinnerTask(spinnerId); - - var infraReq = configRes.data; - var infraInfo = infoRes.data; - - if (!infraReq || !infraReq.nodeGroups || infraReq.nodeGroups.length === 0) { - errorAlert("No NodeGroup configuration found in Infra: " + infraId); - return; - } - - // Clear existing configuration - clearCircle(''); - - // Group VMs from Infra info by nodeGroupId for spec metadata extraction - var nodeByGroup = {}; - if (infraInfo && infraInfo.node) { - infraInfo.node.forEach(function(nd) { - var sgId = nd.nodeGroupId || nd.id; - if (!nodeByGroup[sgId]) { - nodeByGroup[sgId] = nd; - } - }); - } - - // Populate nodeGroupRequestFromSpecList and recommendedSpecList - infraReq.nodeGroups.forEach(function(sg) { - var nodeConfig = $.extend({}, createInfraReqVmTmplt); - nodeConfig.name = sg.name || ("g" + (nodeGroupRequestFromSpecList.length + 1)); - nodeConfig.specId = sg.specId || ""; - nodeConfig.imageId = sg.imageId || "ubuntu22.04"; - nodeConfig.rootDiskType = sg.rootDiskType || "default"; - nodeConfig.rootDiskSize = sg.rootDiskSize || 0; - nodeConfig.nodeGroupSize = sg.nodeGroupSize || 1; - nodeConfig.description = sg.description || "mapui"; - nodeConfig.connectionName = sg.connectionName || ""; - nodeConfig.zone = sg.zone || ""; - if (sg.label && Object.keys(sg.label).length > 0) { - nodeConfig.label = sg.label; - } - - nodeGroupRequestFromSpecList.push(nodeConfig); - - // Build recommendedSpec from Infra Node info for display in the review panel - // Look up by sg.name first (matches nodeGroupId), then try specId-based fallback - var repVm = nodeByGroup[sg.name] || nodeByGroup[nodeConfig.name]; - var specInfo = { - id: sg.specId || "", - providerName: repVm?.connectionConfig?.providerName || extractProviderFromSpecId(sg.specId), - regionName: repVm?.region?.region || extractRegionFromSpecId(sg.specId), - cspSpecName: repVm?.spec?.cspSpecName || repVm?.cspSpecName || sg.specId, - vCPU: repVm?.spec?.vCPU || "N/A", - memoryGiB: repVm?.spec?.memoryGiB || "N/A", - costPerHour: repVm?.spec?.costPerHour || 0, - acceleratorType: repVm?.spec?.acceleratorType || "", - acceleratorModel: repVm?.spec?.acceleratorModel || "", - acceleratorCount: repVm?.spec?.acceleratorCount || 0, - acceleratorMemoryGB: repVm?.spec?.acceleratorMemoryGB || "", - connectionName: sg.connectionName || "", - rootDiskType: sg.rootDiskType || "default", - regionLatitude: repVm?.location?.latitude ?? repVm?.connectionConfig?.regionDetail?.location?.latitude ?? "", - regionLongitude: repVm?.location?.longitude ?? repVm?.connectionConfig?.regionDetail?.location?.longitude ?? "" - }; - recommendedSpecList.push(specInfo); - }); - - // Update the left panel NodeGroup review - renderMapFromConfig(); - updateNodeGroupReview(); - - // Switch to Provision tab to show the configuration - var provisionTab = document.getElementById('provision-tab'); - if (provisionTab) { - provisionTab.click(); - } - - // Show the configCopy response in the standard JSON viewer - outputAlert(infraReq, "success"); - - // Offer to save as template with a toast notification - Swal.fire({ - toast: true, - position: 'bottom-end', - icon: 'success', - title: 'Config copied to Provision panel', - html: '', - showConfirmButton: false, - timer: 6000, - timerProgressBar: true, - didOpen: (toast) => { - var btn = toast.querySelector('.save-as-template-btn'); - if (btn) { - btn.addEventListener('click', function() { - Swal.close(); - saveConfigAsTemplate(namespace, infraId, infraReq); - }); - } - } - }); - - }).catch(function(err) { - removeSpinnerTask(spinnerId); - console.error("Failed to copy Infra config:", err); - errorAlert("Failed to copy Infra configuration: " + (err.response?.data?.message || err.message)); - }); -} -window.copyInfraConfig = copyInfraConfig; - -// Helper: extract provider name from specId (e.g., "aws+ap-southeast-1+t3.medium" -> "aws") -function extractProviderFromSpecId(specId) { - if (!specId) return "Unknown"; - var parts = specId.split("+"); - return parts.length > 0 ? parts[0] : "Unknown"; -} - -// Helper: extract region from specId (e.g., "aws+ap-southeast-1+t3.medium" -> "ap-southeast-1") -function extractRegionFromSpecId(specId) { - if (!specId) return "Unknown"; - var parts = specId.split("+"); - return parts.length > 1 ? parts[1] : "Unknown"; -} - -// Save Infra config as template directly (context menu shortcut) -function saveInfraAsTemplate(infraId) { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - var spinnerId = addSpinnerTask("Extracting Infra configuration"); - var configCopyUrl = `${tbApiBase()}/ns/${namespace}/infra/${infraId}/configCopy`; - - axios({ - method: "get", - url: configCopyUrl, - auth: { username: username, password: password } - }).then(function(res) { - removeSpinnerTask(spinnerId); - var infraReq = res.data; - if (!infraReq || !infraReq.nodeGroups || infraReq.nodeGroups.length === 0) { - errorAlert("No NodeGroup configuration found in Infra: " + infraId); - return; - } - saveConfigAsTemplate(namespace, infraId, infraReq); - }).catch(function(err) { - removeSpinnerTask(spinnerId); - errorAlert("Failed to extract Infra configuration: " + (err.response?.data?.message || err.message)); - }); -} -window.saveInfraAsTemplate = saveInfraAsTemplate; - -// Step 2: Show NodeGroup selection dialog -function showNodeGroupSelectionForScaleOut(selectedInfraId, namespace, hostname, port, username, password) { - var url = `${tbApiBase()}/ns/${namespace}/infra/${selectedInfraId}/nodegroup`; - - var spinnerId = addSpinnerTask("Loading NodeGroup list"); - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000, - }) - .then((res) => { - var nodeGroupOptions = ''; - - if (res.data.output && res.data.output.length > 0) { - res.data.output.forEach((nodeGroupName) => { - if (nodeGroupName && nodeGroupName.trim() !== "") { - nodeGroupOptions += ``; - } - }); - - // Show NodeGroup selection dialog - Swal.fire({ - title: "Select NodeGroup for Scale Out", - width: 600, - html: - "
    " + - "

    Step 2: Select the NodeGroup to scale out

    " + - "

    Selected Infra: " + selectedInfraId + "

    " + - "
    " + - "
    " + - "" + - "" + - "
    " + - "
    ", - showCancelButton: true, - confirmButtonText: "Next: Configure Scale Out", - cancelButtonText: "Back", - confirmButtonColor: "#007bff", - preConfirm: () => { - const selectedNodeGroup = document.getElementById('nodegroup-select').value; - if (!selectedNodeGroup) { - Swal.showValidationMessage('Please select a NodeGroup'); - return false; - } - return selectedNodeGroup; - } - }).then((result) => { - if (result.isConfirmed) { - showScaleOutConfiguration(selectedInfraId, result.value, namespace, hostname, port, username, password); - } else if (result.dismiss === Swal.DismissReason.cancel) { - // Go back to Infra selection - scaleOutNodeGroupWithSelection(); - } - }); - } else { - errorAlert("No NodeGroups found in the selected Infra"); - } - }) - .catch(function (error) { - console.log("Failed to get NodeGroup list:", error); - errorAlert("Failed to load NodeGroup list. Please check your connection."); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} - -// Step 3: Show scale out configuration dialog -function showScaleOutConfiguration(infraId, nodeGroupId, namespace, hostname, port, username, password) { - Swal.fire({ - title: "Configure Scale Out", - width: 600, - html: - "
    " + - "

    Step 3: Configure the scale out operation

    " + - "

    Selected Infra: " + infraId + "

    " + - "

    Selected NodeGroup: " + nodeGroupId + "

    " + - "
    " + - "

    Enter the number of Nodes to add:

    " + - "
    ", - input: "number", - inputValue: 1, - inputAttributes: { - min: 1, - max: 20, - step: 1, - autocapitalize: "off" - }, - showCancelButton: true, - confirmButtonText: "Scale Out", - confirmButtonColor: "#28a745", - cancelButtonText: "Back", - inputValidator: (value) => { - if (!value || value < 1) { - return 'Please enter a valid number (minimum 1)'; - } - if (value > 20) { - return 'Maximum 20 Nodes can be added at once'; - } - } - }).then((result) => { - if (result.isConfirmed) { - var numNodesToAdd = parseInt(result.value); - - // Final confirmation dialog - Swal.fire({ - title: "Confirm Scale Out", - html: - "
    " + - "

    You are about to add " + numNodesToAdd + " Node(s) to:

    " + - "
      " + - "
    • Infra: " + infraId + "
    • " + - "
    • NodeGroup: " + nodeGroupId + "
    • " + - "
    " + - "

    ⚠️ Warning: This will incur additional costs.

    " + - "
    ", - icon: "warning", - showCancelButton: true, - confirmButtonText: "Proceed with Scale Out", - cancelButtonText: "Cancel", - confirmButtonColor: "#28a745", - cancelButtonColor: "#dc3545" - }).then((confirmResult) => { - if (confirmResult.isConfirmed) { - executeScaleOut(namespace, infraId, nodeGroupId, numNodesToAdd, hostname, port, username, password); - } - }); - } else if (result.dismiss === Swal.DismissReason.cancel) { - // Go back to NodeGroup selection - showNodeGroupSelectionForScaleOut(infraId, namespace, hostname, port, username, password); - } - }); -} - -// Function to execute the scale out operation -function executeScaleOut(namespace, infraid, nodegroupid, numNodesToAdd, hostname, port, username, password) { - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraid}/nodegroup/${nodegroupid}`; - - var scaleOutReq = { - numNodesToAdd: numNodesToAdd - }; - - var jsonBody = JSON.stringify(scaleOutReq, undefined, 4); - - console.log(` Scaling out NodeGroup ${nodegroupid} by adding ${numNodesToAdd} Node(s)...`); - var spinnerId = addSpinnerTask(`Scale Out: ${infraid}/${nodegroupid} (+${numNodesToAdd} Nodes)`); - infoAlert(`Starting Scale Out: Adding ${numNodesToAdd} Node(s) to ${nodegroupid}`); - - var requestId = generateRandomRequestId("scaleout-" + infraid + "-" + nodegroupid + "-", 10); - addRequestIdToSelect(requestId); - - axios({ - method: "post", - url: url, - headers: { - "Content-Type": "application/json", - "x-request-id": requestId - }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000 // 10 minutes timeout for scale out operation - }) - .then((res) => { - console.log("Scale out response:", res); - - displayJsonData(res.data, typeInfo); - handleAxiosResponse(res); - - console.log(`Successfully scaled out NodeGroup ${nodegroupid} by adding ${numNodesToAdd} Node(s)`); - - Swal.fire({ - icon: "success", - title: "Scale Out Successful!", - html: - "
    " + - "

    " + numNodesToAdd + " Node(s) have been successfully added to:

    " + - "
      " + - "
    • Infra: " + infraid + "
    • " + - "
    • NodeGroup: " + nodegroupid + "
    • " + - "
    " + - "

    ✓ The new Nodes are being provisioned.

    " + - "
    ", - confirmButtonText: "OK" - }); - - // Refresh Infra status after scale out - setTimeout(() => { - getInfra(); - updateNodeGroupList(); - updateVmList(); - }, 3000); - }) - .catch(function (error) { - var errorMsg = "Failed to scale out NodeGroup"; - - if (error.response) { - console.log(error.response.data); - console.log(error.response.status); - - if (error.response.data) { - if (typeof error.response.data === 'string') { - errorMsg = error.response.data; - } else if (error.response.data.message) { - errorMsg = error.response.data.message; - } else if (error.response.data.error) { - errorMsg = error.response.data.error; - } - } - - displayJsonData(error.response.data, typeError); - } else if (error.request) { - errorMsg = "No response from server. Please check the connection."; - console.log(error.request); - } else { - errorMsg = error.message; - console.log('Error', error.message); - } - - console.log(errorMsg); - - Swal.fire({ - icon: "error", - title: "Scale Out Failed", - html: - "
    " + - "

    Failed to scale out NodeGroup " + nodegroupid + "

    " + - "

    Error: " + errorMsg + "

    " + - "
    ", - confirmButtonText: "OK", - confirmButtonColor: "#dc3545" - }); - - console.log(error.config); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} - -// Function to show Infra Actions menu in SweetAlert -function showActionsMenu() { - var namespace = configNamespace; - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - if (!infraid) { - errorAlert("Please select an Infra first"); - return; - } - - Swal.fire({ - title: "Control Infra", - width: 600, - showCancelButton: true, - showConfirmButton: false, - cancelButtonText: "Cancel", - cancelButtonColor: "#6c757d", - position: "center", - backdrop: `rgba(0, 0, 0, 0.4)`, - html: ` -
    -

    Selected Infra: ${safeInfraid}

    -
    -

    Choose a lifecycle control action:

    - - -
    - - - -
    - - -
    - - -
    - - -
    - - -
    -
    - `, - customClass: { - popup: 'swal-wide' - } - }); -} -window.showActionsMenu = showActionsMenu; - -// Hold-gate sub-menu: only valid right after creating an Infra with option=hold. -// Continue / Withdraw signal an in-memory holding goroutine. They will fail -// after a server restart — for that, use Reconcile or Abort from the parent menu. -function showHoldControlMenu() { - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - if (!infraid) { - errorAlert("Please select an Infra first"); - return; - } - Swal.fire({ - title: "🔒 Hold Control", - width: 560, - showCancelButton: true, - showConfirmButton: false, - cancelButtonText: "Cancel", - cancelButtonColor: "#6c757d", - html: ` -
    -

    Selected Infra: ${safeInfraid}

    -
    -

    For Infras created with the hold option, decide whether to proceed or cancel - the held provisioning.

    -

    Note: these only work while a holding goroutine - is alive in memory. If the server was restarted, use Reconcile or Abort instead.

    - -
    - - -
    -
    - ` - }); -} -window.showHoldControlMenu = showHoldControlMenu; - -// Confirm + dispatch a hold-gate action (continue / withdraw) -function executeHoldAction(action) { - Swal.close(); - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - var isContinue = (action === 'continue'); - Swal.fire({ - title: isContinue ? "Confirm Continue" : "⚠️ Confirm Withdraw", - html: ` -
    -

    You are about to ${action.toUpperCase()} - the held Infra:

    -

    ${safeInfraid}

    -
    -

    ${isContinue - ? 'The held provisioning will proceed and Nodes will be created.' - : 'The held provisioning will be cancelled. No Nodes will be created.'}

    -
    - `, - icon: isContinue ? "question" : "warning", - showCancelButton: true, - confirmButtonText: `Yes, ${action.charAt(0).toUpperCase() + action.slice(1)}`, - cancelButtonText: "Cancel", - confirmButtonColor: isContinue ? "#28a745" : "#ffc107", - cancelButtonColor: "#6c757d" - }).then((result) => { - if (result.isConfirmed) { - controlInfra(action); - } - }); -} -window.executeHoldAction = executeHoldAction; - -// Recovery Control sub-menu: groups Reconcile / Abort. -// Use these after a server restart or when an Infra is stuck. -function showRecoveryControlMenu() { - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - if (!infraid) { - errorAlert("Please select an Infra first"); - return; - } - Swal.fire({ - title: "🛠️ Recovery Control", - width: 600, - showCancelButton: true, - showConfirmButton: false, - cancelButtonText: "Cancel", - cancelButtonColor: "#6c757d", - html: ` -
    -

    Selected Infra: ${safeInfraid}

    -
    -

    Recover an Infra that is stuck after a server restart or partial provisioning failure.

    -

    For normal teardown, use Terminate instead. These actions are exceptional.

    - -
    - - -
    -
    - ` - }); -} -window.showRecoveryControlMenu = showRecoveryControlMenu; - -// Function to execute selected action and close SweetAlert -function executeAction(action) { - Swal.close(); // Close the current SweetAlert - - // Hold Control sub-menu: groups Continue / Withdraw (in-memory hold gate only) - if (action === 'holdControl') { - showHoldControlMenu(); - return; - } - - // Recovery Control sub-menu: groups Reconcile / Abort (for held / stuck Infras) - if (action === 'recoveryControl') { - showRecoveryControlMenu(); - return; - } - - // Add confirmation for dangerous actions - if (action === 'terminate') { - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - Swal.fire({ - title: "⚠️ Confirm Termination", - html: ` -
    -

    You are about to TERMINATE Infra:

    -

    ${safeInfraid}

    -
    -

    ⚠️ WARNING: This action is IRREVERSIBLE!

    -

    All Nodes and associated resources will be permanently deleted.

    -
    - `, - icon: "warning", - showCancelButton: true, - confirmButtonText: "Yes, Terminate", - cancelButtonText: "Cancel", - confirmButtonColor: "#dc3545", - cancelButtonColor: "#6c757d" - }).then((result) => { - if (result.isConfirmed) { - controlInfra(action); - } - }); - } else if (action === 'withdraw') { - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - Swal.fire({ - title: "⚠️ Confirm Withdraw", - html: ` -
    -

    You are about to WITHDRAW Infra:

    -

    ${safeInfraid}

    -
    -

    ⚠️ WARNING: This will shut down all Nodes in the Infra.

    -
    - `, - icon: "warning", - showCancelButton: true, - confirmButtonText: "Yes, Withdraw", - cancelButtonText: "Cancel", - confirmButtonColor: "#ffc107", - cancelButtonColor: "#6c757d" - }).then((result) => { - if (result.isConfirmed) { - controlInfra(action); - } - }); - } else if (action === 'abort') { - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - Swal.fire({ - title: "⚠️ Confirm Abort", - html: ` -
    -

    You are about to ABORT Infra:

    -

    ${safeInfraid}

    -
    -

    ⚠️ WARNING: This force-terminates every non-final Node in parallel - (with orphan rescue) and sweeps any Failed remnants.

    -

    This is intended for Infras that are stuck after a server restart or partial provisioning failure.

    -

    The final DELETE is not issued automatically — run it after termination completes.

    -
    - `, - icon: "warning", - showCancelButton: true, - confirmButtonText: "Yes, Abort", - cancelButtonText: "Cancel", - confirmButtonColor: "#dc3545", - cancelButtonColor: "#6c757d" - }).then((result) => { - if (result.isConfirmed) { - controlInfra(action); - } - }); - } else if (action === 'reconcile') { - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - Swal.fire({ - title: "🩹 Confirm Reconcile", - html: ` -
    -

    Reconcile Infra: ${safeInfraid}

    -
    -

    This queries Spider for the real CSP status of every transient Node and absorbs CSP-side - orphan VMs created before a server crash. Nodes that cannot be matched are marked - Failed so a subsequent Refine can clean them up.

    -

    No new VMs are created.

    -
    - `, - icon: "info", - showCancelButton: true, - confirmButtonText: "Yes, Reconcile", - cancelButtonText: "Cancel", - confirmButtonColor: "#17a2b8", - cancelButtonColor: "#6c757d" - }).then((result) => { - if (result.isConfirmed) { - controlInfra(action); - } - }); - } else if (action === 'delete') { - var infraid = getSelectedInfraId(); - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - Swal.fire({ - title: "⚠️ Confirm Delete", - html: ` -
    -

    You are about to DELETE Infra:

    -

    ${safeInfraid}

    -
    -

    ⚠️ WARNING: This action is IRREVERSIBLE!

    -

    The Infra and all associated resources will be permanently removed.

    -
    - `, - icon: "warning", - showCancelButton: true, - confirmButtonText: "Yes, Delete", - cancelButtonText: "Cancel", - confirmButtonColor: "#dc3545", - cancelButtonColor: "#6c757d" - }).then((result) => { - if (result.isConfirmed) { - deleteInfra(); - } - }); - } else { - // For other actions, execute directly with brief confirmation - var infraid = document.getElementById("infraid").value; - var safeInfraid = window.escapeHtml ? window.escapeHtml(infraid) : infraid; - var actionName = action.charAt(0).toUpperCase() + action.slice(1); - - Swal.fire({ - title: `Confirm ${actionName}`, - html: ` -
    -

    Execute ${actionName} on Infra: ${safeInfraid}?

    -
    - `, - icon: "question", - showCancelButton: true, - confirmButtonText: `Yes, ${actionName}`, - cancelButtonText: "Cancel", - confirmButtonColor: "#007bff", - cancelButtonColor: "#6c757d" - }).then((result) => { - if (result.isConfirmed) { - controlInfra(action); - } - }); - } -} -window.executeAction = executeAction; - -// Common function for ScaleOut operations - Infra selection dialog -function showInfraSelectionForScaleOut(title, description, successCallback) { - // Get Infra list specifically for ScaleOut operations - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (!namespace || namespace === "") { - errorAlert("Please select a namespace first"); - return; - } - - var url = `${tbApiBase()}/ns/${namespace}/infra?option=id`; - var spinnerId = addSpinnerTask("Loading Infra list for ScaleOut"); - - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000, - }) - .then((res) => { - var infraOptions = ''; - - if (res.data.output && res.data.output.length > 0) { - res.data.output.forEach((infraId) => { - if (infraId && infraId.trim() !== "") { - infraOptions += ``; - } - }); - - if (infraOptions) { - // Show Infra selection dialog - Swal.fire({ - title: title, - width: 600, - html: - "
    " + - "

    Step 1: " + description + "

    " + - (nodeGroupRequestFromSpecList && nodeGroupRequestFromSpecList.length > 0 ? - "

    Available Node Configurations: " + nodeGroupRequestFromSpecList.length + " location(s)

    " : "") + - "
    " + - "
    " + - "" + - "" + - "
    " + - "
    ", - showCancelButton: true, - confirmButtonText: "Next", - cancelButtonText: "Cancel", - confirmButtonColor: "#28a745", - preConfirm: () => { - const selectedInfra = document.getElementById('infra-select').value; - if (!selectedInfra) { - Swal.showValidationMessage('Please select an Infra'); - return false; - } - return selectedInfra; - } - }).then((result) => { - if (result.isConfirmed) { - successCallback(result.value); - } - }); - } else { - errorAlert("No Infras found in the selected namespace"); - } - } else { - errorAlert("No Infras found in the selected namespace"); - } - }) - .catch(function (error) { - console.log("Failed to get Infra list for ScaleOut:", error); - errorAlert("Failed to load Infra list. Please check your connection."); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} - -// ScaleOut Infra function with current map configuration -function scaleOutInfraWithConfiguration() { - var config = getConfig(); var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - - if (!namespace) { - errorAlert("Please select a namespace first"); - return; - } - - // Check if we have any Node configuration from the map - if (!nodeGroupRequestFromSpecList || nodeGroupRequestFromSpecList.length === 0) { - errorAlert("Please configure Node specifications first by clicking on the map or using the configuration form"); - return; - } - - // Use the common Infra selection dialog for ScaleOut operations - showInfraSelectionForScaleOut( - "Select Infra for Node Addition", - "Select the Infra to add new Nodes", - (selectedInfraId) => { - showInfraScaleOutConfiguration(selectedInfraId, namespace, hostname, port, username, password); - } - ); -} -window.scaleOutInfraWithConfiguration = scaleOutInfraWithConfiguration; - -// Step 2: Show Infra scale out configuration dialog -function showInfraScaleOutConfiguration(selectedInfraId, namespace, hostname, port, username, password) { - // Collect label keys already used by this infra so the editor can suggest them - // (keeps label schemes consistent for labelSelector targeting) - window._scaleOutExistingLabelKeys = ['role']; - axios({ method: "get", url: `${tbApiBase()}/ns/${namespace}/infra/${selectedInfraId}`, - auth: { username: username, password: password } }) - .then(function (res) { - var keys = new Set(); - (res.data.node || []).forEach(function (nd) { - Object.keys(nd.label || {}).forEach(function (k) { if (!k.startsWith('sys.') && k !== 'Name') keys.add(k); }); - }); - if (keys.size > 0) window._scaleOutExistingLabelKeys = Array.from(keys); - }) - .catch(function () { /* suggestion only; keep the default */ }); - - // Build Node configuration summary from current map settings - var vmConfigSummary = ""; - var totalNodes = 0; - - if (nodeGroupRequestFromSpecList && nodeGroupRequestFromSpecList.length > 0) { - nodeGroupRequestFromSpecList.forEach((nodeConfig, index) => { - var ndCount = 1; // Default Node count per location - totalNodes += ndCount; - vmConfigSummary += - "
    " + - "Location " + (index + 1) + ":
    " + - "Spec: " + (nodeConfig.specId || "Auto-selected") + "
    " + - "Image: " + (nodeConfig.imageId || "Auto-selected") + "
    " + - "Count: " + ndCount + " Node(s)" + - "
    "; - }); - } - - Swal.fire({ - title: "➕ Add NodeGroup to " + selectedInfraId, - width: 850, - html: ` - ${POPUP_STYLES} - `, - showCancelButton: true, - confirmButtonText: "Review Configuration", - cancelButtonText: "Back", - confirmButtonColor: "#17a2b8", - didOpen: () => { - setupCommandsPopup(10); - // Update total Node count when Node count per location changes - document.getElementById('node-count').addEventListener('input', function() { - var vmPerLocation = parseInt(this.value) || 1; - var totalLocations = nodeGroupRequestFromSpecList.length; - var newTotal = vmPerLocation * totalLocations; - document.getElementById('total-nodes').textContent = newTotal; - }); - // Label editor: prefill from the map configuration, else from existing infra groups - setupNodeGroupLabelEditor( - (nodeGroupRequestFromSpecList && nodeGroupRequestFromSpecList[0] && nodeGroupRequestFromSpecList[0].label) || {}, - window._scaleOutExistingLabelKeys || ['role'] - ); - }, - preConfirm: () => { - const cfg = collectNodeGroupConfig(); - if (cfg.error) { - Swal.showValidationMessage(cfg.error); - return false; - } - const phases = collectPhases(); - const commands = phases ? [] : ((typeof collectCommands === 'function' ? collectCommands() : []) || []); - return { nodeGroupName: cfg.nodeGroupName, ndCount: cfg.ndCount, labels: cfg.labels, commands, phases }; - } - }).then((result) => { - if (result.isConfirmed) { - var config = result.value; - // Carry the dialog's labels/bootstrap commands into the review + execute steps - window.pendingNodeGroupLabels = config.labels || {}; - window.pendingNodeGroupPostCommands = (config.commands && config.commands.length > 0) ? config.commands : null; - window.pendingNodeGroupPostCommandPhases = (config.phases && config.phases.length > 0) ? config.phases : null; - showInfraScaleOutReview(selectedInfraId, config.nodeGroupName, config.ndCount, namespace, hostname, port, username, password); - } else if (result.dismiss === Swal.DismissReason.cancel) { - // Go back to Infra selection - scaleOutInfraWithConfiguration(); - } - }); -} - -// Step 2.5: Show Infra scale out review -function showInfraScaleOutReview(selectedInfraId, nodeGroupName, nodeCountPerLocation, namespace, hostname, port, username, password) { - // Use the first Node configuration from the map as the template for review - if (!nodeGroupRequestFromSpecList || nodeGroupRequestFromSpecList.length === 0) { - errorAlert("No Node configuration available for review"); - return; - } - - var vmTemplate = nodeGroupRequestFromSpecList[0]; - - // Build the review request using the template - var reviewReq = { - name: nodeGroupName, - nodeGroupSize: nodeCountPerLocation, - specId: vmTemplate.specId, - imageId: vmTemplate.imageId, - description: "Dynamically added via CB-MapUI Scale Out Infra", - // User-configured labels win; mapui provenance labels are added underneath - label: Object.assign({ - "created-by": "cb-mapui", - "creation-type": "scale-out-infra", - "timestamp": new Date().toISOString() - }, vmTemplate.label || {}, window.pendingNodeGroupLabels || {}) - }; - - // Add optional fields if available - if (vmTemplate.rootDiskType) { - reviewReq.rootDiskType = vmTemplate.rootDiskType; - } - if (vmTemplate.rootDiskSize) { - reviewReq.rootDiskSize = vmTemplate.rootDiskSize; - } - if (vmTemplate.connectionName) { - reviewReq.connectionName = vmTemplate.connectionName; - } - - var url = `${tbApiBase()}/ns/${namespace}/infra/${selectedInfraId}/nodeGroupDynamicReview`; - var jsonBody = JSON.stringify(reviewReq, undefined, 4); - - console.log("Reviewing NodeGroup configuration..."); - var spinnerId = addSpinnerTask(`Reviewing NodeGroup: ${nodeGroupName}`); - infoAlert(`Reviewing NodeGroup configuration for ${selectedInfraId}...`); - - var requestId = generateRandomRequestId("review-nodegroup-" + selectedInfraId + "-" + nodeGroupName + "-", 10); - addRequestIdToSelect(requestId); - - axios({ - method: "post", - url: url, - headers: { - "Content-Type": "application/json", - "x-request-id": requestId - }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000 - }) - .then((res) => { - console.log("NodeGroup review completed successfully"); - console.log("Review response data:", res.data); - successAlert("NodeGroup configuration reviewed successfully"); - - var reviewData = res.data; - showInfraScaleOutReviewResults(selectedInfraId, nodeGroupName, nodeCountPerLocation, reviewData, namespace, hostname, port, username, password); - }) - .catch(function (error) { - console.log("Failed to review NodeGroup configuration:", error); - console.log("Error details:", error.response ? error.response.data : error.message); - - var errorMsg = "Failed to review NodeGroup configuration"; - if (error.response && error.response.data) { - if (typeof error.response.data === 'string') { - errorMsg += ": " + error.response.data; - } else if (error.response.data.message) { - errorMsg += ": " + error.response.data.message; - } else if (error.response.data.error) { - errorMsg += ": " + error.response.data.error; - } - } else if (error.message) { - errorMsg += ": " + error.message; - } - errorAlert(errorMsg); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} - -// Step 2.6: Show review results and proceed to confirmation -function showInfraScaleOutReviewResults(selectedInfraId, nodeGroupName, nodeCountPerLocation, reviewData, namespace, hostname, port, username, password) { - console.log("Processing review results:", reviewData); - - // Safely extract data with fallbacks - var canCreate = reviewData.canCreate !== undefined ? reviewData.canCreate : true; - var status = reviewData.status || 'Unknown'; - var message = reviewData.message || 'No detailed message available'; - var estimatedCost = reviewData.estimatedCost || 'Cost estimation unavailable'; - - // Build status display - var statusColor = canCreate ? - (status === 'Ready' ? '#28a745' : '#ffc107') : '#dc3545'; - var statusIcon = canCreate ? - (status === 'Ready' ? '✅' : '⚠️') : '❌'; - - // Build warnings and errors display - var warningsHtml = ''; - if (reviewData.warnings && Array.isArray(reviewData.warnings) && reviewData.warnings.length > 0) { - warningsHtml = '
    ⚠️ Warnings:
      '; - reviewData.warnings.forEach(warning => { - warningsHtml += `
    • ${warning}
    • `; - }); - warningsHtml += '
    '; - } - - var errorsHtml = ''; - if (reviewData.errors && Array.isArray(reviewData.errors) && reviewData.errors.length > 0) { - errorsHtml = '
    ❌ Errors:
      '; - reviewData.errors.forEach(error => { - errorsHtml += `
    • ${error}
    • `; - }); - errorsHtml += '
    '; - } - - // Build resource validation display - var validationHtml = ''; - if (reviewData.specValidation) { - var specStatus = reviewData.specValidation.isAvailable ? '✅' : '❌'; - var specStatusText = reviewData.specValidation.status || 'No status'; - validationHtml += `

    Spec Validation: ${specStatus} ${specStatusText}

    `; - } - if (reviewData.imageValidation) { - var imageStatus = reviewData.imageValidation.isAvailable ? '✅' : '❌'; - var imageStatusText = reviewData.imageValidation.status || 'No status'; - validationHtml += `

    Image Validation: ${imageStatus} ${imageStatusText}

    `; - } - - // Add info section if available - var infoHtml = ''; - if (reviewData.info && Array.isArray(reviewData.info) && reviewData.info.length > 0) { - infoHtml = '
    ℹ️ Additional Information:
      '; - reviewData.info.forEach(info => { - infoHtml += `
    • ${info}
    • `; - }); - infoHtml += '
    '; - } - - var totalNodes = nodeCountPerLocation * nodeGroupRequestFromSpecList.length; - - Swal.fire({ - title: "NodeGroup Configuration Review", - width: 700, - html: - "
    " + - "

    Review Results for NodeGroup Addition

    " + - "
    " + - "
    " + - "
    📋 Configuration Summary
    " + - "

    Target Infra: " + selectedInfraId + "

    " + - "

    NodeGroup Name: " + nodeGroupName + "

    " + - "

    Nodes per location: " + nodeCountPerLocation + "

    " + - "

    Total locations: " + nodeGroupRequestFromSpecList.length + "

    " + - "

    Total Nodes to add: " + totalNodes + "

    " + - "

    Estimated Cost: " + estimatedCost + "

    " + - "
    " + - "
    " + - "
    " + statusIcon + " Review Status
    " + - "

    Status: " + status + "

    " + - "

    Message: " + message + "

    " + - validationHtml + - "
    " + - infoHtml + - warningsHtml + - errorsHtml + - "
    ", - showCancelButton: true, - confirmButtonText: canCreate ? "Proceed with Creation" : "Back to Configuration", - cancelButtonText: "Cancel", - confirmButtonColor: canCreate ? "#28a745" : "#6c757d", - cancelButtonColor: "#dc3545", - allowOutsideClick: false - }).then((result) => { - if (result.isConfirmed) { - if (canCreate) { - // Proceed to final confirmation - showInfraScaleOutConfirmation(selectedInfraId, nodeGroupName, nodeCountPerLocation, namespace, hostname, port, username, password); - } else { - // Go back to configuration - showInfraScaleOutConfiguration(selectedInfraId, namespace, hostname, port, username, password); - } - } else { - // Cancel the entire operation - infoAlert("NodeGroup addition cancelled"); - } - }); -} - -// Step 3: Show final confirmation and execute Infra scale out -function showInfraScaleOutConfirmation(infraId, nodeGroupName, nodeCountPerLocation, namespace, hostname, port, username, password) { - var totalNodes = nodeCountPerLocation * nodeGroupRequestFromSpecList.length; - - Swal.fire({ - title: "Confirm Infra Scale Out", - html: - "
    " + - "

    You are about to add " + totalNodes + " Node(s) to Infra:

    " + - "
      " + - "
    • Infra: " + infraId + "
    • " + - "
    • New NodeGroup: " + nodeGroupName + "
    • " + - "
    • Nodes per location: " + nodeCountPerLocation + "
    • " + - "
    • Total locations: " + nodeGroupRequestFromSpecList.length + "
    • " + - "
    " + - "

    ⚠️ Warning: This will incur additional costs.

    " + - "
    ", - icon: "warning", - showCancelButton: true, - confirmButtonText: "Proceed with Node Addition", - cancelButtonText: "Cancel", - confirmButtonColor: "#28a745", - cancelButtonColor: "#dc3545" - }).then((confirmResult) => { - if (confirmResult.isConfirmed) { - executeInfraScaleOut(namespace, infraId, nodeGroupName, nodeCountPerLocation, hostname, port, username, password); - } - }); -} - -// Execute Infra scale out operation -function executeInfraScaleOut(namespace, infraId, nodeGroupName, nodeCountPerLocation, hostname, port, username, password) { - var url = `${tbApiBase()}/ns/${namespace}/infra/${infraId}/nodeGroupDynamic`; - - // Build the request body using current map configuration - var templateLabel = (nodeGroupRequestFromSpecList && nodeGroupRequestFromSpecList.length > 0) - ? (nodeGroupRequestFromSpecList[0].label || {}) : {}; - var nodeGroupDynamicReq = { - name: nodeGroupName, - nodeGroupSize: nodeCountPerLocation, - description: "Dynamically added via CB-MapUI Scale Out Infra", - // User-configured labels win; mapui provenance labels are added underneath - label: Object.assign({ - "created-by": "cb-mapui", - "creation-type": "scale-out-infra", - "timestamp": new Date().toISOString() - }, templateLabel, window.pendingNodeGroupLabels || {}) - }; - - // Bootstrap for the newly added nodeGroup (optional; runs on the NEW nodes only). - // Async so the call returns once the nodes are provisioned. - if (window.pendingNodeGroupPostCommandPhases && window.pendingNodeGroupPostCommandPhases.length > 0) { - nodeGroupDynamicReq.postCommands = window.pendingNodeGroupPostCommandPhases; - nodeGroupDynamicReq.postCommandAsync = true; - window.pendingNodeGroupPostCommandPhases = null; - } else if (window.pendingNodeGroupPostCommands && window.pendingNodeGroupPostCommands.length > 0) { - nodeGroupDynamicReq.postCommands = [{ command: window.pendingNodeGroupPostCommands }]; - nodeGroupDynamicReq.postCommandAsync = true; - } - window.pendingNodeGroupPostCommands = null; - - // Use the first Node configuration from the map as the template - // In a real scenario, you might want to let users select which configuration to use - if (nodeGroupRequestFromSpecList && nodeGroupRequestFromSpecList.length > 0) { - var templateVm = nodeGroupRequestFromSpecList[0]; - - if (templateVm.specId) { - nodeGroupDynamicReq.specId = templateVm.specId; - } - if (templateVm.imageId) { - nodeGroupDynamicReq.imageId = templateVm.imageId; - } - if (templateVm.rootDiskType) { - nodeGroupDynamicReq.rootDiskType = templateVm.rootDiskType; - } - if (templateVm.rootDiskSize) { - nodeGroupDynamicReq.rootDiskSize = templateVm.rootDiskSize; - } - if (templateVm.connectionName) { - nodeGroupDynamicReq.connectionName = templateVm.connectionName; - } - } - - var jsonBody = JSON.stringify(nodeGroupDynamicReq, undefined, 4); - - console.log(`Adding Nodes to Infra ${infraId} with nodegroup ${nodeGroupName}...`); - var spinnerId = addSpinnerTask(`Scale Out Infra: ${infraId} (+${nodeCountPerLocation * nodeGroupRequestFromSpecList.length} Nodes)`); - infoAlert(`Starting Infra Scale Out: Adding ${nodeCountPerLocation * nodeGroupRequestFromSpecList.length} Node(s) to ${infraId}`); - - var requestId = generateRandomRequestId("infra-scaleout-" + infraId + "-" + nodeGroupName + "-", 10); - addRequestIdToSelect(requestId); - - axios({ - method: "post", - url: url, - headers: { - "Content-Type": "application/json", - "x-request-id": requestId - }, - data: jsonBody, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000 // 10 minutes timeout for scale out operation - }) - .then((res) => { - console.log("Infra scale out response:", res); - - displayJsonData(res.data, typeInfo); - handleAxiosResponse(res); - - // Switch to Control tab after successful scale out (like createInfra) - try { - // Deactivate all tabs first - document.querySelectorAll('.nav-link').forEach(tab => { - tab.classList.remove('active'); - }); - document.querySelectorAll('.tab-pane').forEach(pane => { - pane.classList.remove('show', 'active'); - }); - - // Activate control-tab - const controlTab = document.getElementById('control-tab'); - const controlPane = document.getElementById('control'); - - if (controlTab && controlPane) { - controlTab.classList.add('active'); - controlPane.classList.add('show', 'active'); - - // Trigger Bootstrap tab shown event if needed - if (typeof $ !== 'undefined' && $.fn.tab) { - $(controlTab).tab('show'); - } - } - } catch (error) { - console.log('Failed to activate control tab:', error); - } - - console.log(`Successfully added Nodes to Infra ${infraId}`); - - Swal.fire({ - icon: "success", - title: "Infra Scale Out Successful!", - html: - "
    " + - "

    " + (nodeCountPerLocation * nodeGroupRequestFromSpecList.length) + " Node(s) have been successfully added to:

    " + - "
      " + - "
    • Infra: " + infraId + "
    • " + - "
    • NodeGroup: " + nodeGroupName + "
    • " + - "
    " + - "

    ✓ The new Nodes are being provisioned.

    " + - "
    ", - confirmButtonText: "OK" - }); - - // Keep configuration for reuse - user can manually clear if needed - - // Refresh Infra status after scale out - setTimeout(() => { - getInfra(); - updateInfraList(); - }, 3000); - }) - .catch(function (error) { - var errorMsg = "Failed to scale out Infra"; - - if (error.response) { - console.log(error.response.data); - console.log(error.response.status); - - if (error.response.data) { - if (typeof error.response.data === 'string') { - errorMsg = error.response.data; - } else if (error.response.data.message) { - errorMsg = error.response.data.message; - } else if (error.response.data.error) { - errorMsg = error.response.data.error; - } - } - - displayJsonData(error.response.data, typeError); - } else if (error.request) { - errorMsg = "No response from server. Please check the connection."; - console.log(error.request); - } else { - errorMsg = error.message; - console.log('Error', error.message); - } - - console.log(errorMsg); - - Swal.fire({ - icon: "error", - title: "Infra Scale Out Failed", - html: - "
    " + - "

    Failed to scale out Infra " + infraId + "

    " + - "

    Error: " + errorMsg + "

    " + - "
    ", - confirmButtonText: "OK", - confirmButtonColor: "#dc3545" - }); - - console.log(error.config); - }) - .finally(function () { - removeSpinnerTask(spinnerId); - }); -} - - -// Draw Objects to the Map -function drawObjects(event) { - - // Place docked (locationless) Infras: a pixel-anchored list at the top-left, - // re-derived every frame so it stays put through pan/zoom/resize. Each slot - // is as tall as its own label block, so multi-line names never overlap. - { - let dockY = LOCATIONLESS_DOCK_TOP_PX; - for (const [, data] of infraRenderMap) { - if (!data.isLocationless || !data.isDocked) continue; - const layout = getInfraLabelLayout(data); - if (!layout) continue; - const markerY = dockY - layout.top; // layout.top is negative (block starts above the marker centre) - const coord = map.getCoordinateFromPixel([LOCATIONLESS_DOCK_LEFT_PX, markerY]); - if (coord) data.geometry.setCoordinates(coord); - dockY += layout.height + LOCATIONLESS_DOCK_GAP_PX; - } - } - - var vectorContext = getVectorContext(event); - var frameState = event.frameState; - var theta = (2 * Math.PI * frameState.time) / omegaTheta; - - // Shuffle keys every shuffleInterval draws - drawCounter++; - if (drawCounter % shuffleInterval === 0) { - shuffleKeys(); - } - - // Get the selected providers from checkboxes - var selectedProviders = getSelectedProviders(); - var isAllSelected = selectedProviders.includes("ALL") || selectedProviders.length === 0; - - // Draw CSP location first with the stored random order - shuffledKeys.forEach((key) => { - if (isAllSelected || selectedProviders.includes(key)) { - if (Array.isArray(geoCspPoints[key]) && geoCspPoints[key].length) { - const style = getCspStyle(key); - if (!style) return; - const styles = Array.isArray(style) ? style : [style]; - const validStyles = styles.filter(Boolean); - if (!validStyles.length) return; - validStyles.forEach((s) => { - vectorContext.setStyle(s); - vectorContext.drawGeometry(geoCspPoints[key][0]); - }); - } - } - }); - - // Draw Infra Geometry (polygons and points from infraRenderMap) - { - let colorIdx = 0; - for (const [, data] of infraRenderMap) { - if (data.geometry) { - var polyStyle = new Style({ - stroke: new Stroke({ - width: 1, - color: cororLineList[colorIdx % cororList.length], - }), - fill: new Fill({ - color: cororList[colorIdx % cororList.length], - }), - }); - vectorContext.setStyle(polyStyle); - vectorContext.drawGeometry(data.geometry); - colorIdx++; - } - } - } - - // Draw K8s Cluster Group Geometry (clusters with same clustergroup label) - for (i = k8sClusterGroups.length - 1; i >= 0; --i) { - var k8sGroupPolyStyle = new Style({ - stroke: new Stroke({ - width: 2, - color: [75, 0, 130, 0.8], // Indigo color for K8s groups - lineDash: [8, 4] // Dashed line to distinguish from Infra - }), - fill: new Fill({ - color: [138, 43, 226, 0.15], // BlueViolet with transparency - }), - }); - - vectorContext.setStyle(k8sGroupPolyStyle); - vectorContext.drawGeometry(k8sClusterGroups[i]); - } - - // Draw Infra Cluster Geometry (clusters within infra) - if (infraClusterPolygons.size > 0) { - for (const [infraId, polygons] of infraClusterPolygons) { - if (Array.isArray(polygons)) { - polygons.forEach((polygon, idx) => { - // Get cluster color and info - const clusterNames = infraClusterNames.get(infraId) || []; - const clusterColors = infraClusterColors.get(infraId) || new globalThis.Map(); - const clusterName = clusterNames[idx] || `Cluster ${idx}`; - const clusterColor = clusterColors.get(clusterName) || '#FF5733'; - - // Parse hex color to RGBA - const rgbColor = hexToRgb(clusterColor); - const clusterNodeCount = polygon.get('clusterNodeCount') || 0; - const isSmallCluster = clusterNodeCount <= 2; - const clusterStyle = new Style({ - stroke: new Stroke({ - width: isSmallCluster ? 3.2 : 2.8, - lineDash: isSmallCluster ? [6, 4] : undefined, - color: [...rgbColor, 0.9] - }), - fill: new Fill({ - color: [...rgbColor, isSmallCluster ? 0.2 : 0.12] - }) - }); - - vectorContext.setStyle(clusterStyle); - vectorContext.drawGeometry(polygon); - }); - } - } - } - - if (cspPointsCircle.length) { - //console.log("cspPointsCircle.length:" +cspPointsCircle.length + "cspPointsCircle["+cspPointsCircle+"]") - // Fix: Create MultiPoint with proper coordinate structure - geoCspPointsCircle[0] = new MultiPoint(cspPointsCircle); - vectorContext.setStyle(iconStyleCircle); - vectorContext.drawGeometry(geoCspPointsCircle[0]); - - // Draw convex hull polygon for configuration points (like existing Infra VMs) - if (cspPointsCircle.length >= 3) { - // Create deep copy to avoid modifying original array (convexHull sorts input) - const pointsCopy = cspPointsCircle.map(point => [point[0], point[1]]); - - // Debug: log points before convex hull - console.log("Original points:", cspPointsCircle); - console.log("Points copy:", pointsCopy); - - const hullPoints = convexHull(pointsCopy); - - // Debug: log hull result - console.log("Hull points:", hullPoints); - - if (hullPoints.length >= 3) { - // Ensure the polygon is closed by adding the first point at the end - const closedHull = [...hullPoints, hullPoints[0]]; - const configPolygon = new Polygon([closedHull]); - const configPolyStyle = new Style({ - stroke: new Stroke({ - width: 2, - color: [169, 169, 169, 0.8], // Light gray with transparency - lineDash: [5, 5] // Dashed line for config state - }), - fill: new Fill({ - color: [192, 192, 192, 0.1], // Very light gray fill - }), - }); - vectorContext.setStyle(configPolyStyle); - vectorContext.drawGeometry(configPolygon); - } - } - } - - if (geoResourceLocation.vnet[0]) { - vectorContext.setStyle(iconStyleVnet); - vectorContext.drawGeometry(geoResourceLocation.vnet[0]); - } - if (geoResourceLocation.sg[0]) { - vectorContext.setStyle(iconStyleSG); - vectorContext.drawGeometry(geoResourceLocation.sg[0]); - } - if (geoResourceLocation.sshKey[0]) { - vectorContext.setStyle(iconStyleKey); - vectorContext.drawGeometry(geoResourceLocation.sshKey[0]); - } - // Tombstoned resources: rose halo drawn behind the resource's normal icon. - const drawTombstones = (geom, iconStyle) => { - if (!geom) return; - vectorContext.setStyle(tombstoneHaloStyle); - vectorContext.drawGeometry(geom); - vectorContext.setStyle(iconStyle); - vectorContext.drawGeometry(geom); - }; - drawTombstones(geoResourceLocation.vnetTombstone && geoResourceLocation.vnetTombstone[0], iconStyleVnet); - drawTombstones(geoResourceLocation.sgTombstone && geoResourceLocation.sgTombstone[0], iconStyleSG); - drawTombstones(geoResourceLocation.sshKeyTombstone && geoResourceLocation.sshKeyTombstone[0], iconStyleKey); - if (geoResourceLocation.k8s[0]) { - vectorContext.setStyle(iconStyleK8s); - vectorContext.drawGeometry(geoResourceLocation.k8s[0]); - } - if (geoResourceLocation.vpn[0]) { - vectorContext.setStyle(iconStyleVPN); - vectorContext.drawGeometry(geoResourceLocation.vpn[0]); - } - - // Draw Infra Points and Individual Node Status Badges - for (const [, data] of infraRenderMap) { - const geometryPoint = data.geometryPoints; - - // Skip if no geometry point (e.g., preparing/prepared Infra) - if (!geometryPoint) { - continue; - } - - // Check if geometryPoint has the new structure with Node data - if (geometryPoint && typeof geometryPoint === 'object' && geometryPoint.geometry) { - const { geometry, nodePoints, nodeStatuses, nodeProviders, nodeCommandStatuses } = geometryPoint; - const vmBaseScale = changeSizeStatus(data.name + data.status); - - if (nodePoints && nodeStatuses) { - nodeStatuses.forEach((nodeStatus, nodeIndex) => { - if (nodePoints[nodeIndex]) { - const nodeCoords = nodePoints[nodeIndex]; - const vmProvider = nodeProviders ? nodeProviders[nodeIndex] : null; - const commandStatus = nodeCommandStatuses ? nodeCommandStatuses[nodeIndex] : "None"; - const vmStyles = createNodeStyleWithStatusBadge(nodeStatus, vmProvider, vmBaseScale, nodeCoords, commandStatus); - - const vmPoint = new Point(nodeCoords); - vmStyles.forEach(style => { - vectorContext.setStyle(style); - vectorContext.drawGeometry(vmPoint); - }); - } - }); - } - } else { - // Legacy structure: Draw single Infra icon (fallback) - if (data.name.includes("NLB")) { - vectorContext.setStyle(iconStyleNlb); - } else { - vectorContext.setStyle(iconStyleNode); - } - if (geometryPoint) { - vectorContext.drawGeometry(geometryPoint); - } - } - } - - // Draw K8s cluster text (name and status) - for (i = 0; i < k8sName.length; i++) { - if (k8sCoords[i] && k8sName[i]) { - // Create Point geometry from stored coordinates - const k8sPoint = new Point(k8sCoords[i]); - - // Split K8s cluster name into lines for better display - const nameLines = splitK8sNameToLines(k8sName[i]); - const lineHeight = 28; // Spacing between lines (slightly larger than Infra due to bigger font) - const baseOffsetY = 30; // Position below the icon - - // Draw each line of the K8s cluster name - nameLines.forEach((line, lineIndex) => { - const k8sNameStyle = new Style({ - text: new Text({ - text: line, - font: "bold 24px sans-serif", // Increased from 20px to 24px (20% larger) - scale: 1.0, // Fixed scale for K8s clusters - offsetY: baseOffsetY + (lineIndex * lineHeight), // Offset each line down - stroke: new Stroke({ - color: [255, 255, 255, 1], // white stroke - width: 2, // Adjusted stroke width proportionally - }), - fill: new Fill({ - color: [0, 0, 0, 1], // black text - }), - }), - }); - - vectorContext.setStyle(k8sNameStyle); - vectorContext.drawGeometry(k8sPoint); - }); - - // K8s cluster status text with appropriate color - const statusOffsetY = baseOffsetY + (nameLines.length * lineHeight) + 8; // Position below the name lines with gap - const statusColors = getK8sStatusColor(k8sStatus[i]); - const k8sStatusStyle = new Style({ - text: new Text({ - text: k8sStatus[i], - font: "bold 22px sans-serif", // Increased from 18px to 22px (20% larger) - scale: 0.9, // Slightly smaller for status - offsetY: statusOffsetY, // Use calculated offset based on name lines - stroke: new Stroke({ - color: statusColors.stroke, - width: 2, // Adjusted stroke width proportionally - }), - fill: new Fill({ - color: statusColors.fill, - }), - }), - }); - - // Draw status text - vectorContext.setStyle(k8sStatusStyle); - vectorContext.drawGeometry(k8sPoint); - } - } - - // Draw Infra name + status labels (layout shared with findNearestInfra) - { - let infraDrawIdx = 0; - for (const [, data] of infraRenderMap) { - const layout = getInfraLabelLayout(data); - if (!layout) { infraDrawIdx++; continue; } - const anchorPoint = new Point(layout.anchor); - const statusColors = getNodeStatusColor(data.status); - - // Placeholder marker for an Infra that has no Node positions yet - if (data.isLocationless) { - vectorContext.setStyle(new Style({ - image: new CircleStyle({ - radius: LOCATIONLESS_MARKER_RADIUS_PX, - fill: new Fill({ color: [255, 255, 255, 0.85] }), - stroke: new Stroke({ color: statusColors.stroke, width: 2, lineDash: [4, 3] }), - }), - })); - vectorContext.drawGeometry(anchorPoint); - vectorContext.setStyle(new Style({ - text: new Text({ text: '⏳', font: '11px sans-serif', textBaseline: 'middle' }), - })); - vectorContext.drawGeometry(anchorPoint); - } - - layout.name.forEach((line, lineIndex) => { - let displayText = line.text; - - if (lineIndex === 0 && data.targetAction) { - const spinChars = ['⠿', '⠷', '⠯', '⠟', '⠻', '⠽', '⠾', '⠷','⠿']; - const animIndex = Math.floor(drawCounter / 10 + infraDrawIdx) % spinChars.length; - displayText = spinChars[animIndex] + ' ' + displayText; - } - - const textColor = (lineIndex === 0 && data.targetAction) - ? getTargetActionColor(data.targetAction) - : [0, 0, 0, 1]; - - vectorContext.setStyle(new Style({ - text: new Text({ - text: displayText, - font: "bold 10px sans-serif", - scale: layout.nameScale, - textAlign: layout.textAlign, - textBaseline: layout.textBaseline, - offsetX: line.offsetX, - offsetY: line.offsetY, - stroke: new Stroke({ - color: [255, 255, 255, 1], - width: 1, - }), - fill: new Fill({ - color: textColor, - }), - }), - })); - vectorContext.drawGeometry(anchorPoint); - }); - - vectorContext.setStyle(new Style({ - text: new Text({ - text: data.status, - font: "bold 10px sans-serif", - scale: layout.statusScale, - textAlign: layout.textAlign, - textBaseline: layout.textBaseline, - offsetX: layout.status.offsetX, - offsetY: layout.status.offsetY, - stroke: new Stroke({ - color: statusColors.stroke, - width: 2, - }), - fill: new Fill({ - color: statusColors.fill, - }), - }), - })); - vectorContext.drawGeometry(anchorPoint); - infraDrawIdx++; - } - } - - // Draw Infra NodeGroup labels - if (showInfraNodeGroupLabels && infraNodeGroupPolygons.size > 0) { - for (const [infraId, polygons] of infraNodeGroupPolygons) { - if (Array.isArray(polygons)) { - const ngNames = infraNodeGroupNames.get(infraId) || []; - const ngColors = infraNodeGroupColors.get(infraId) || new globalThis.Map(); - polygons.forEach((polygon, idx) => { - const ngName = polygon && (polygon.get('nodeGroupId') || ngNames[idx]); - if (polygon && ngName) { - // Anchor the label to the group's first Node icon rather than the hull - // top: the hull is inflated in map units, so its top edge drifts - // relative to the icons as the zoom changes and ends up on top of them. - let anchor = polygon.get('labelAnchor'); - if (!anchor) { - const extent = polygon.getExtent(); - anchor = [(extent[0] + extent[2]) / 2, extent[3]]; - } - const labelPoint = new Point(anchor); - const ngColor = ngColors.get(ngName) || '#2196F3'; - const ngRgb = hexToRgb(ngColor); - const nodeCount = polygon.get('clusterNodeCount') || 0; - const labelText = `${ngName} (${nodeCount})`; - // Readability over map tiles: black text on a translucent white chip. - // The group colour (not drawn anywhere else) survives only as the - // chip border so groups stay distinguishable. - // Icon is 52px * (2.4 * 0.3) ≈ 37px tall, centered on the point, and - // the status badge sits ~18px below center. Pixel offsets keep this - // clearance constant at every zoom level. - vectorContext.setStyle(new Style({ image: getNodeGroupLabelChip(labelText, ngRgb) })); - vectorContext.drawGeometry(labelPoint); - vectorContext.setStyle(new Style({ - text: new Text({ - text: labelText, - font: NODEGROUP_LABEL_FONT, - textAlign: 'center', - textBaseline: 'top', - offsetY: NODEGROUP_LABEL_OFFSET_PX + 1.5 + NODEGROUP_CHIP_PAD_Y, - fill: new Fill({ color: [20, 20, 20, 1] }), - }), - })); - vectorContext.drawGeometry(labelPoint); - } - }); - } - } - } - - // Draw Infra Cluster labels (drawn last to appear on top of polygons) - if (showInfraClusterLabels && infraClusterPolygons.size > 0) { - for (const [infraId, polygons] of infraClusterPolygons) { - if (Array.isArray(polygons)) { - const clusterNames = infraClusterNames.get(infraId) || []; - const clusterColors = infraClusterColors.get(infraId) || new globalThis.Map(); - - polygons.forEach((polygon, idx) => { - if (polygon && clusterNames[idx]) { - const extent = polygon.getExtent(); - const centerX = (extent[0] + extent[2]) / 2; - const topY = extent[3]; - const labelPoint = new Point([centerX, topY]); - - const clusterName = clusterNames[idx]; - const clusterColor = clusterColors.get(clusterName) || '#FF5733'; - const rgbColor = hexToRgb(clusterColor); - - const clusterNodeCount = polygon.get('clusterNodeCount') || 0; - const infraClusterNameStyle = new Style({ - text: new Text({ - text: `${clusterName} (${clusterNodeCount})`, - font: "bold 14px sans-serif", - scale: 1.0, - offsetY: 12, - stroke: new Stroke({ - color: [255, 255, 255, 1], - width: 2, - }), - fill: new Fill({ - color: rgbColor, - }), - }), - }); - - vectorContext.setStyle(infraClusterNameStyle); - vectorContext.drawGeometry(labelPoint); - } - }); - } - } - } - - // Draw K8s Cluster Group labels (drawn last to appear on top of polygons) - for (i = k8sClusterGroups.length - 1; i >= 0; --i) { - if (k8sClusterGroupNames[i]) { - const extent = k8sClusterGroups[i].getExtent(); - const centerX = (extent[0] + extent[2]) / 2; - const topY = extent[3]; // Use top of polygon instead of center - const labelPoint = new Point([centerX, topY]); - - const k8sGroupNameStyle = new Style({ - text: new Text({ - text: `⎈ ${k8sClusterGroupNames[i]}`, // Kubernetes helm symbol - font: "bold 28px sans-serif", // Larger than individual K8s cluster labels (24px) - scale: 1.0, - offsetY: 0, // Label at the top edge of the polygon - stroke: new Stroke({ - color: [255, 255, 255, 1], // White stroke - width: 3, - }), - fill: new Fill({ - color: [75, 0, 130, 1], // Indigo text - }), - }), - }); - - vectorContext.setStyle(k8sGroupNameStyle); - vectorContext.drawGeometry(labelPoint); - } - } - - - map.render(); -} - -tileLayer.on("postrender", function (event) { - drawObjects(event); -}); - -// Function to sync Infra selection from Dashboard -function syncInfraSelectionFromDashboard(infraId) { - console.log(`[SYNC] Attempting to sync Infra selection: ${infraId}`); - console.log(`[SYNC] infraidElement exists:`, !!infraidElement); - - if (infraidElement && infraId) { - console.log(`[SYNC] Current value: ${infraidElement.value}, New value: ${infraId}`); - - // Check if the option exists in the select element - const optionExists = Array.from(infraidElement.options).some(option => option.value === infraId); - console.log(`[SYNC] Option exists in select:`, optionExists); - - if (optionExists) { - // Set the value in the Infra select element - infraidElement.value = infraId; - - // Trigger change event to update dependent dropdowns - const changeEvent = new Event('change', { bubbles: true }); - infraidElement.dispatchEvent(changeEvent); - - console.log(`[SYNC] Infra selection synced successfully: ${infraId}`); - } else { - console.log(`[SYNC] Infra ${infraId} not found in select options`); - } - } else { - console.log(`[SYNC] Failed - infraidElement:`, !!infraidElement, `infraId:`, infraId); - } -} - -// Load K8s cluster data for dashboard and map -function loadK8sClusterData() { - var hostname = configHostname; - var port = configPort; - var username = configUsername; - var password = configPassword; - var namespace = configNamespace; - - if (!namespace || namespace === "") { - console.log("No namespace specified for K8s cluster data load"); - return; - } - - // Set loading status - window.cloudBaristaCentralData.apiStatus.k8sCluster = 'loading'; - - // get k8sCluster list and put them on the map - var url = `${tbApiBase()}/ns/${namespace}/k8sCluster`; - axios({ - method: "get", - url: url, - auth: { - username: `${username}`, - password: `${password}`, - }, - timeout: 600000, - }).then((res) => { - var obj = res.data; - // console.log('K8s cluster API response:', obj); - // console.log('K8s cluster API response structure:', JSON.stringify(obj, null, 2)); - - // Update central data store - handle both response formats - let k8sClusterData = []; - if (obj.K8sClusterInfo) { - k8sClusterData = obj.K8sClusterInfo; - // console.log('Using K8sClusterInfo field'); - } else if (obj.cluster) { - k8sClusterData = obj.cluster; - // console.log('Using cluster field'); - } - - // console.log('Final k8sClusterData:', k8sClusterData); - - window.cloudBaristaCentralData.k8sCluster = k8sClusterData; - window.cloudBaristaCentralData.resourceData.k8sCluster = k8sClusterData; - - // Update API status to success - window.cloudBaristaCentralData.apiStatus.k8sCluster = 'success'; - window.cloudBaristaCentralData.apiStatus.lastK8sClusterUpdate = new Date(); - window.cloudBaristaCentralData.apiStatus.lastK8sClusterError = null; - - // Notify dashboard subscribers - notifyDataSubscribers(); - - // Update map icons and store name/status data - if (k8sClusterData != null && k8sClusterData.length > 0) { - var resourceLocation = []; - - // Clear previous K8s data - k8sName = []; - k8sStatus = []; - k8sCoords = []; - k8sClusterGroups = []; - k8sClusterGroupNames = []; - - // Temporary object to group clusters by clustergroup label - // Note: Using plain object instead of Map because 'Map' is overridden by OpenLayers import - const clusterGroupMap = {}; - - console.log("resourceLocation k8s[0]"); - for (let i = 0; i < k8sClusterData.length; i++) { - const item = k8sClusterData[i]; - if (item.connectionConfig && item.connectionConfig.regionDetail && item.connectionConfig.regionDetail.location) { - const coords = [ - item.connectionConfig.regionDetail.location.longitude * 1, - item.connectionConfig.regionDetail.location.latitude * 1 + 0.05, - ]; - resourceLocation.push(coords); - - // Store K8s cluster name, status, and coordinates - k8sName.push(item.name || item.id); - k8sStatus.push(item.status || 'Unknown'); - k8sCoords.push(coords); - - // Group by clustergroup label if present - if (item.label && item.label.clustergroup) { - const groupName = item.label.clustergroup; - if (!clusterGroupMap[groupName]) { - clusterGroupMap[groupName] = []; - } - clusterGroupMap[groupName].push(coords); - } - } - } - - // Create polygons for cluster groups with 2+ clusters - // Note: 2 points create a line connecting clusters, which is intentional - Object.entries(clusterGroupMap).forEach(([groupName, coords]) => { - if (coords.length >= 2) { - // Create deep copy for convexHull (it modifies the array) - const pointsCopy = coords.map(c => [...c]); - const hullPoints = convexHull(pointsCopy); - - if (hullPoints.length >= 2) { - // Close the polygon (or line for 2 points) - const closedHull = [...hullPoints, hullPoints[0]]; - k8sClusterGroups.push(new Polygon([closedHull])); - k8sClusterGroupNames.push(groupName); - } - } - }); - - if (resourceLocation.length > 0) { - geoResourceLocation.k8s[0] = new MultiPoint(resourceLocation); - } - - // Trigger map re-render to display updated K8s cluster data - map.render(); - } else { - // Clear k8s icons when list is empty - geoResourceLocation.k8s = []; - k8sName = []; - k8sStatus = []; - k8sCoords = []; - k8sClusterGroups = []; - k8sClusterGroupNames = []; - - // Trigger map re-render to clear K8s icons - map.render(); - } - - // console.log('K8s cluster data loaded successfully:', k8sClusterData.length, 'clusters'); - }) - .catch(function (error) { - // console.log("k8sCluster API error:", error); - // console.log("Keeping existing K8s cluster data to preserve user experience"); - - // Update API status to error but don't clear existing data - window.cloudBaristaCentralData.apiStatus.k8sCluster = 'error'; - window.cloudBaristaCentralData.apiStatus.lastK8sClusterError = { - timestamp: new Date(), - message: error.message || 'Unknown error', - code: error.code || 'UNKNOWN_ERROR' - }; - - // Don't clear existing data on API error - keep current state - // This prevents UI from showing empty state when there are temporary API issues - - // Optional: Show user notification about the error while keeping data - if (typeof updateMapConnectionStatus === 'function') { - updateMapConnectionStatus('error'); - // Reset to normal status after a short delay - setTimeout(() => { - updateMapConnectionStatus('connected'); - }, 3000); - } - - // Notify subscribers even on error so dashboard knows about the failed update attempt - notifyDataSubscribers(); - }); -} - -// Load NLB (regional CSP NLB) data into the central store so the NLB manager, the Net -// graph and the Board table can consume window.cloudBaristaCentralData.nlb. -// One namespace-wide call; each item already carries its parent infraId. -async function loadNlbData() { - try { - const config = getConfig(); - const { username, password } = config; - const namespace = configNamespace || config.username; - - const res = await axios({ - method: "get", - url: `${tbApiBase()}/ns/${namespace}/resources/nlb`, - auth: { username, password }, - timeout: 10000, - }); - window.cloudBaristaCentralData.nlb = (res.data && res.data.nlb) || []; - notifyDataSubscribers(); - } catch (e) { - console.log("loadNlbData error:", e && e.message); - window.cloudBaristaCentralData.nlb = []; - } -} -window.loadNlbData = loadNlbData; -// Kept for callers written against the previous per-Infra loader -window.loadNlbDataFromInfras = loadNlbData; - -async function loadVpnDataFromInfras() { - try { - const config = getConfig(); - const { hostname, port, username, password } = config; - const namespace = configNamespace || config.username; - - // Use existing Infra data from central store - no fallback API call. - // NOTE: the central store key is `infraData` (not `infra`); reading `.infra` - // here silently bailed, so VPN data was never loaded into the store and the - // Net graph never saw any VPN. Read the correct key. - let infraData = []; - if (window.cloudBaristaCentralData && window.cloudBaristaCentralData.infraData) { - infraData = window.cloudBaristaCentralData.infraData; - debugLog.resource('Using cached Infra data for VPN loading:', infraData.length, 'Infras'); - } else { - debugLog.resource('Central Infra data not available, skipping VPN loading'); - // Clear VPN data and return early - no point in loading VPN without Infras - window.cloudBaristaCentralData.vpn = []; - geoResourceLocation.vpn = []; - return; - } - - // If no Infras exist, no point in trying to load VPN data - if (!infraData || infraData.length === 0) { - debugLog.resource('No Infras available, skipping VPN loading'); - window.cloudBaristaCentralData.vpn = []; - geoResourceLocation.vpn = []; - return; - } - - let allVpnData = []; - let resourceLocation = []; - - // Load VPN data from each Infra - for (const infra of infraData) { - try { - // option=InfoList returns full VpnInfo objects (stored, fast) under - // vpnInfoList; the default (IdList) returns only ids under vpnIdList. - const vpnUrl = `${tbApiBase()}/ns/${namespace}/infra/${infra.id}/vpn?option=InfoList`; - const vpnResponse = await axios({ - method: "get", - url: vpnUrl, - auth: { username, password }, - timeout: 8000 - }); - - const vpnData = vpnResponse.data?.vpnInfoList || vpnResponse.data?.vpn || []; - debugLog.api(`VPN data for Infra ${infra.id}:`, vpnData.length, 'VPNs'); - - // Add Infra ID to each VPN for reference - vpnData.forEach(vpn => { - vpn.infraId = infra.id; - allVpnData.push(vpn); - - // Extract location data for map display - if (vpn.vpnSites && vpn.vpnSites.length > 0) { - for (let site of vpn.vpnSites) { - if (site.connectionConfig?.regionDetail?.location) { - resourceLocation.push([ - site.connectionConfig.regionDetail.location.longitude * 1, - site.connectionConfig.regionDetail.location.latitude * 1 + 0.05, - ]); - } - } - } else if (vpn.connectionConfig?.regionDetail?.location) { - resourceLocation.push([ - vpn.connectionConfig.regionDetail.location.longitude * 1, - vpn.connectionConfig.regionDetail.location.latitude * 1 + 0.05, - ]); - } - }); - - } catch (vpnError) { - // Silently continue if VPN API fails for individual Infra - debugLog.api(`VPN API error for Infra ${infra.id}:`, vpnError.message); - } - } - - // Store VPN data in central store - window.cloudBaristaCentralData.vpn = allVpnData; - debugLog.resource('Total VPN data stored:', allVpnData.length, 'VPNs from', infraData.length, 'Infras'); - - // Notify Dashboard subscribers about VPN data update - notifyDataSubscribers(); - - // Update map display - if (resourceLocation.length > 0) { - geoResourceLocation.vpn[0] = new MultiPoint([resourceLocation]); - debugLog.mapOp("geoResourceLocation.vpn[0] updated with", resourceLocation.length, "locations"); - } else { - geoResourceLocation.vpn = []; - } - - } catch (error) { - debugLog.api("VPN data loading error:", error); - window.cloudBaristaCentralData.vpn = []; - geoResourceLocation.vpn = []; - } -} - -// Make function available globally for Dashboard to call -window.syncInfraSelectionFromDashboard = syncInfraSelectionFromDashboard; - -// ============================================ -// Snapshot Management Functions -// ============================================ - -// Global variables for snapshot auto-refresh -window.snapshotAutoRefreshEnabled = false; -window.snapshotAutoRefreshInterval = null; -window.snapshotLastImageData = null; // Store last image data to prevent unnecessary re-renders - -// Toggle Auto-refresh for Snapshot Management -function toggleSnapshotAutoRefresh() { - window.snapshotAutoRefreshEnabled = !window.snapshotAutoRefreshEnabled; - - const btn = document.getElementById('toggleAutoRefreshBtn'); - const status = document.getElementById('autoRefreshStatus'); - - if (window.snapshotAutoRefreshEnabled) { - btn.innerHTML = '⏸️ Pause Auto-refresh'; - btn.className = 'btn btn-success btn-sm'; - status.innerHTML = '🟢 Auto-refreshing every 5 seconds'; - } else { - btn.innerHTML = '▶️ Resume Auto-refresh'; - btn.className = 'btn btn-warning btn-sm'; - status.innerHTML = '🔴 Auto-refresh paused'; - } -} - -// Show Snapshot Management Modal -async function showSnapshotManagementModal() { - const namespace = configNamespace; - if (!namespace) { - Swal.fire('Warning', 'Please select a namespace first', 'warning'); - return; - } - - // Get pre-selected Infra from control panel (if any) - const preSelectedInfra = document.getElementById('infraid')?.value || ''; - - // Load Infra list - const config = getConfig(); - let infraList = []; - try { - const response = await axios.get(`${tbApiBase()}/ns/${namespace}/infra`, { - auth: { username: config.username, password: config.password }, - headers: { 'Content-Type': 'application/json' } - }); - infraList = response.data.infra || []; - } catch (error) { - console.error('Error loading Infra list:', error); - } - - Swal.fire({ - title: '📸 Snapshot Management', - html: ` - -
    -
    Create Node Snapshot
    -
    -
    - - -
    -
    - - - Select "🌐 All Nodes" for Infra-wide snapshot -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    -
    Custom Images
    -
    - - -
    -
    -
    - 🟢 Auto-refreshing every 5 seconds | - Last refresh: - -
    -
    -

    Loading custom images...

    -
    -
    - `, - width: '80%', - showConfirmButton: false, - showCancelButton: true, - cancelButtonText: '❌ Close', - customClass: { - htmlContainer: 'swal2-html-container-compact' - }, - didOpen: async () => { - // Store namespace in window for access from modal functions - window.currentSnapshotNamespace = namespace; - - // Infra selection change handler - const loadVmsForInfra = async function(infraId) { - const nodeSelect = document.getElementById('snapshotNodeSelect'); - nodeSelect.innerHTML = ''; - - if (!infraId) { - nodeSelect.innerHTML = ''; - return; - } - - try { - const response = await axios.get( - `${tbApiBase()}/ns/${namespace}/infra/${infraId}`, - { - auth: { username: config.username, password: config.password }, - headers: { 'Content-Type': 'application/json' } - } - ); - - const nodes = response.data.node || []; - // Add "All Nodes" option for Infra-wide snapshot - nodeSelect.innerHTML = '' + - '' + - nodes.map(nd => ``).join(''); - } catch (error) { - console.error('Error loading Node list:', error); - nodeSelect.innerHTML = ''; - } - }; - - document.getElementById('snapshotInfraSelect').addEventListener('change', async function() { - await loadVmsForInfra(this.value); - }); - - // If Infra is pre-selected, auto-load its VMs - if (preSelectedInfra) { - await loadVmsForInfra(preSelectedInfra); - } - - // Auto-refresh setup (5 seconds interval) - window.snapshotAutoRefreshEnabled = true; - window.snapshotAutoRefreshInterval = null; - window.snapshotLastImageData = null; // Reset cached data - - // Setup refresh now button event listener - const refreshNowBtn = document.getElementById('refreshNowBtn'); - if (refreshNowBtn) { - refreshNowBtn.addEventListener('click', function() { - loadCustomImagesInModal(namespace); - }); - } - - // Setup toggle button event listener - const toggleBtn = document.getElementById('toggleAutoRefreshBtn'); - if (toggleBtn) { - toggleBtn.addEventListener('click', function() { - window.snapshotAutoRefreshEnabled = !window.snapshotAutoRefreshEnabled; - - const status = document.getElementById('autoRefreshStatus'); - - if (window.snapshotAutoRefreshEnabled) { - this.innerHTML = '⏸️ Pause Auto-refresh'; - this.className = 'btn btn-success btn-sm'; - if (status) status.innerHTML = '🟢 Auto-refreshing every 5 seconds'; - } else { - this.innerHTML = '▶️ Resume Auto-refresh'; - this.className = 'btn btn-warning btn-sm'; - if (status) status.innerHTML = '🔴 Auto-refresh paused'; - } - }); - } - - // Initial load with slight delay to ensure DOM is ready - setTimeout(() => { - loadCustomImagesInModal(namespace); - }, 100); - - // Start auto-refresh timer - window.snapshotAutoRefreshInterval = setInterval(() => { - if (window.snapshotAutoRefreshEnabled) { - loadCustomImagesInModal(namespace); - } - }, 5000); // 5 seconds - }, - willClose: () => { - // Cleanup: clear auto-refresh timer when modal closes - if (window.snapshotAutoRefreshInterval) { - clearInterval(window.snapshotAutoRefreshInterval); - window.snapshotAutoRefreshInterval = null; - } - window.snapshotAutoRefreshEnabled = false; - // Note: Don't clear window.currentSnapshotNamespace here - // It will be updated when a new modal opens - } - }); -} - -// Create Node Snapshot (supports both single Node and Infra-wide snapshots) -async function createNodeSnapshotFromModal() { - const namespace = configNamespace; - const infraId = document.getElementById('snapshotInfraSelect').value; - const nodeId = document.getElementById('snapshotNodeSelect').value; - const snapshotName = document.getElementById('snapshotName').value; - const description = document.getElementById('snapshotDescription').value; - - if (!infraId || !nodeId) { - Swal.fire('Warning', 'Please select Infra and Node (or All Nodes)', 'warning'); - return; - } - - const config = getConfig(); - const isInfraSnapshot = (nodeId === '__ALL_NODES__'); - - try { - Swal.fire({ - title: isInfraSnapshot ? 'Creating Infra Snapshots...' : 'Creating Node Snapshot...', - html: isInfraSnapshot ? - 'Creating snapshots for all nodegroups in parallel...
    This may take several minutes...' : - 'This may take a few minutes...', - allowOutsideClick: false, - didOpen: () => { Swal.showLoading(); } - }); - - const requestBody = { - name: snapshotName || undefined, - description: description || undefined - }; - - let response; - if (isInfraSnapshot) { - // Infra-wide snapshot (all nodegroups) - response = await axios.post( - `${tbApiBase()}/ns/${namespace}/infra/${infraId}/snapshot`, - requestBody, - { - auth: { username: config.username, password: config.password }, - headers: { 'Content-Type': 'application/json' } - } - ); - - // Display Infra snapshot results - const results = response.data.results || []; - const successCount = response.data.successCount || 0; - const failCount = response.data.failCount || 0; - - const resultsHtml = results.map(result => { - const statusIcon = result.status === 'Success' ? '✅' : '❌'; - const statusClass = result.status === 'Success' ? 'success' : 'danger'; - const statusBadge = result.imageInfo?.imageStatus ? - `${result.imageInfo.imageStatus}` : ''; - - return ` - - ${statusIcon} - ${result.nodeGroupId} - ${result.nodeId} - ${result.imageId || 'N/A'} ${statusBadge} - ${result.status} - ${result.error || '-'} - - `; - }).join(''); - - Swal.fire({ - icon: successCount > 0 ? 'success' : 'error', - title: 'Infra Snapshot Completed', - html: ` -
    -

    Infra ID: ${response.data.infraId}

    -

    Summary: - ${successCount} Success - ${failCount} Failed -

    -
    - - - - - - - - - - - - - ${resultsHtml} - -
    StatusNodeGroupNode IDImage IDResultError
    -
    - ${failCount > 0 ? - '

    ⚠️ Note: Some snapshots failed. Check error details above.

    ' : - '

    ✅ All snapshots created successfully!

    '} -
    - `, - width: '900px', - confirmButtonText: 'OK' - }).then(() => { - showSnapshotManagementModal(); - }); - - } else { - // Single Node snapshot - response = await axios.post( - `${tbApiBase()}/ns/${namespace}/infra/${infraId}/node/${nodeId}/snapshot`, - requestBody, - { - auth: { username: config.username, password: config.password }, - headers: { 'Content-Type': 'application/json' } - } - ); - - // Create status badge with color - const statusClass = response.data.imageStatus === 'Available' ? 'success' : - response.data.imageStatus === 'Creating' ? 'info' : - response.data.imageStatus === 'Failed' ? 'danger' : 'warning'; - - Swal.fire({ - icon: 'success', - title: 'Node Snapshot Created!', - html: ` -
    -

    Image ID: ${response.data.id}

    -

    Image Status: ${response.data.imageStatus}

    -

    Provider: ${response.data.providerName || 'N/A'}

    -

    Region: ${response.data.regionList ? response.data.regionList.join(', ') : 'N/A'}

    -

    Description: ${response.data.description || 'N/A'}

    - ${response.data.imageStatus !== 'Available' ? - '

    ⚠️ Note: Snapshot is being created. Status will be updated shortly.

    ' : - '

    ✅ Snapshot is ready to use!

    '} -
    - `, - confirmButtonText: 'OK' - }).then(() => { - showSnapshotManagementModal(); - }); - } - - } catch (error) { - console.error('Error creating snapshot:', error); - Swal.fire({ - icon: 'error', - title: 'Snapshot Creation Failed', - text: error.response?.data?.message || error.message || 'Unknown error occurred' - }); - } -} - -// Load Custom Images (with smart refresh to prevent flickering) -async function loadCustomImagesInModal(namespace) { - // Priority: passed parameter > window storage > input field - if (!namespace) { - namespace = window.currentSnapshotNamespace || configNamespace; - } - - console.log('loadCustomImagesInModal called with namespace:', namespace); - - if (!namespace) { - console.error('Namespace not available in loadCustomImagesInModal'); - const container = document.getElementById('customImageListContainer'); - if (container) { - container.innerHTML = '

    Error: Namespace not available

    '; - } - return; - } - - const config = getConfig(); - const container = document.getElementById('customImageListContainer'); - - if (!container) { - console.error('customImageListContainer element not found'); - return; - } - - // Update last refresh time - const lastRefreshElement = document.getElementById('lastRefreshTime'); - if (lastRefreshElement) { - const now = new Date(); - lastRefreshElement.innerHTML = `Last refresh: ${now.toLocaleTimeString()}`; - } - - // Show loading only on first load - if (!window.snapshotLastImageData) { - container.innerHTML = '

    Loading...

    '; - } - - try { - const response = await axios.get( - `${tbApiBase()}/ns/${namespace}/resources/customImage`, - { - auth: { username: config.username, password: config.password }, - headers: { 'Content-Type': 'application/json' } - } - ); - - const images = response.data.customImage || []; - - // Compare with last data to prevent unnecessary re-render - const currentDataString = JSON.stringify(images); - if (window.snapshotLastImageData === currentDataString) { - // Data hasn't changed, skip re-render - return; - } - - // Update stored data - window.snapshotLastImageData = currentDataString; - - if (images.length === 0) { - container.innerHTML = '

    No custom images found

    '; - return; - } - - let html = '
    '; - - images.forEach(img => { - // Enhanced status badge with icons and colors - let statusIcon = ''; - - if (img.imageStatus === 'Available') { - statusIcon = '✅'; - } else if (img.imageStatus === 'Unavailable') { - statusIcon = '⏳'; - } else { - statusIcon = '⚠️'; - } - - // Combine provider and region - const providerRegion = `${img.providerName || 'N/A'} (${img.regionList && img.regionList.length > 0 ? img.regionList[0] : 'N/A'})`; - - // Combine ID and status - const idWithStatus = `${img.id.substring(0, 12)}${img.id.length > 12 ? '...' : ''} (${statusIcon})`; - - // Combine OS type and architecture - const osInfo = `${img.osType || 'N/A'} (${img.osArchitecture || 'N/A'})`; - - // Truncate long description - const descShort = img.description && img.description.length > 40 ? - img.description.substring(0, 40) + '...' : (img.description || 'N/A'); - - html += ` - - - - - - - - - - `; - }); - - html += '
    Provider (Region)ID (Status)OS (Arch)DescriptionSource Node UIDCreatedAction
    ${providerRegion}${idWithStatus}${osInfo}${descShort}${img.sourceNodeUid ? img.sourceNodeUid.substring(0, 12) + '...' : 'N/A'}${img.creationDate ? new Date(img.creationDate).toLocaleDateString() : 'N/A'} - - -
    '; - container.innerHTML = html; - - } catch (error) { - console.error('Error loading custom images:', error); - container.innerHTML = '

    Error loading custom images

    '; - } -} - -// View Custom Image Details -async function viewCustomImageDetails(imageId) { - const namespace = window.currentSnapshotNamespace || configNamespace; - if (!namespace) { - Swal.fire('Error', 'Namespace not available', 'error'); - return; - } - const config = getConfig(); - - try { - const response = await axios.get( - `${tbApiBase()}/ns/${namespace}/resources/customImage/${imageId}`, - { - auth: { username: config.username, password: config.password }, - headers: { 'Content-Type': 'application/json' } - } - ); - - const img = response.data; - - // Create enhanced status badge with icon - let statusIcon = ''; - let statusClass = 'warning'; - let statusMessage = ''; - - if (img.imageStatus === 'Available') { - statusIcon = '✅'; - statusClass = 'success'; - statusMessage = '

    This snapshot is ready to use for Node creation.

    '; - } else if (img.imageStatus === 'Creating') { - statusIcon = '🔄'; - statusClass = 'info'; - statusMessage = '

    ⏳ Snapshot is being created. Please wait until status becomes Available.

    '; - } else if (img.imageStatus === 'Failed') { - statusIcon = '❌'; - statusClass = 'danger'; - statusMessage = '

    ⚠️ Snapshot creation failed. This image cannot be used.

    '; - } else { - statusIcon = '⚠️'; - statusClass = 'warning'; - statusMessage = '

    ⚠️ Image status is ' + img.imageStatus + '. Check before using.

    '; - } - - Swal.fire({ - title: `📸 ${img.id}`, - html: ` -
    -
    -

    Image Status:

    -

    - ${statusIcon} ${img.imageStatus} -

    - ${statusMessage} -
    -

    Provider: ${img.providerName}

    -

    Region: ${img.regionList ? img.regionList.join(', ') : 'N/A'}

    -

    OS Type: ${img.osType || 'N/A'}

    -

    OS Architecture: ${img.osArchitecture || 'N/A'}

    -

    Description: ${img.description || 'N/A'}

    -

    Created: ${img.creationDate || 'N/A'}

    -

    Source Node UID: ${img.sourceNodeUid || 'N/A'}

    -
    - `, - confirmButtonText: 'Close', - width: '600px' - }).then(() => { - // Return to Snapshot Management modal after closing - showSnapshotManagementModal(); - }); - - } catch (error) { - console.error('Error loading custom image details:', error); - Swal.fire('Error', 'Failed to load image details', 'error').then(() => { - // Return to Snapshot Management modal even on error - showSnapshotManagementModal(); - }); - } -} - -// Delete Custom Image -async function deleteCustomImageFromModal(imageId) { - const result = await Swal.fire({ - title: 'Delete Custom Image?', - text: `Are you sure you want to delete "${imageId}"?`, - icon: 'warning', - showCancelButton: true, - confirmButtonColor: '#d33', - cancelButtonColor: '#3085d6', - confirmButtonText: 'Yes, delete it!', - cancelButtonText: 'Cancel' - }); - - if (!result.isConfirmed) return; - - const namespace = window.currentSnapshotNamespace || configNamespace; - if (!namespace) { - Swal.fire('Error', 'Namespace not available', 'error'); - return; - } - const config = getConfig(); - - try { - await axios.delete( - `${tbApiBase()}/ns/${namespace}/resources/customImage/${imageId}`, - { - auth: { username: config.username, password: config.password }, - headers: { 'Content-Type': 'application/json' } - } - ); - - Swal.fire('Deleted!', 'Custom image has been deleted.', 'success').then(() => { - // Return to Snapshot Management modal after deletion - showSnapshotManagementModal(); - }); - - } catch (error) { - console.error('Error deleting custom image:', error); - Swal.fire('Error', error.response?.data?.message || 'Failed to delete custom image', 'error').then(() => { - // Return to Snapshot Management modal even on error - showSnapshotManagementModal(); - }); - } -} - -// Make functions globally available -window.showSnapshotManagementModal = showSnapshotManagementModal; -window.createNodeSnapshotFromModal = createNodeSnapshotFromModal; -window.loadCustomImagesInModal = loadCustomImagesInModal; -window.viewCustomImageDetails = viewCustomImageDetails; -window.deleteCustomImageFromModal = deleteCustomImageFromModal; - -// ========================================== -// Task Management Functions -// ========================================== - -// Global variables for task auto-refresh -window.taskAutoRefreshEnabled = false; -window.taskAutoRefreshInterval = null; -window.taskLastData = null; // Store last task data to prevent unnecessary re-renders - -// Load task list and update the modal content -async function loadTaskListInModal(namespace, infraid) { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - - const url = `${tbApiBase()}/ns/${namespace}/cmd/infra/${infraid}/task`; - - try { - const res = await axios.get(url, { - auth: { username: username, password: password } - }); - - const tasks = res.data.tasks || []; - - // Check if data has changed (to avoid unnecessary re-render) - const currentDataStr = JSON.stringify(tasks); - if (window.taskLastData === currentDataStr) { - // Only update the last refresh time - const lastRefreshEl = document.getElementById('taskLastRefreshTime'); - if (lastRefreshEl) { - lastRefreshEl.textContent = `Last refresh: ${new Date().toLocaleTimeString('en-US', { hour12: false })}`; - } - return; - } - window.taskLastData = currentDataStr; - - // Sort tasks: active tasks first (Handling, Queued), then by startedAt descending - const sortedTasks = [...tasks].sort((a, b) => { - const aActive = ['handling', 'queued'].includes((a.status || '').toLowerCase()); - const bActive = ['handling', 'queued'].includes((b.status || '').toLowerCase()); - if (aActive && !bActive) return -1; - if (!aActive && bActive) return 1; - // Both same priority, sort by startedAt descending (newest first) - return new Date(b.startedAt || 0) - new Date(a.startedAt || 0); - }); - - let tasksHtml = ''; - if (sortedTasks.length === 0) { - tasksHtml = '

    No command execution history

    '; - } else { - tasksHtml = ` - - - - - - - - - - - - - `; - - sortedTasks.forEach(task => { - // Status uses CommandExecutionStatus: Queued, Handling, Completed, Failed, Timeout, Cancelled, Interrupted - const statusLower = (task.status || '').toLowerCase(); - let statusColor, statusIcon; - switch (statusLower) { - case 'handling': - statusColor = '#28a745'; // Green - statusIcon = '⏳'; - break; - case 'queued': - statusColor = '#6c757d'; // Gray - statusIcon = '⏸️'; - break; - case 'completed': - statusColor = '#17a2b8'; // Cyan - statusIcon = '✅'; - break; - case 'cancelled': - statusColor = '#ffc107'; // Yellow - statusIcon = '⚠️'; - break; - case 'interrupted': - statusColor = '#fd7e14'; // Orange - statusIcon = '🔄'; - break; - case 'failed': - statusColor = '#dc3545'; // Red - statusIcon = '❌'; - break; - case 'timeout': - statusColor = '#dc3545'; // Red - statusIcon = '⏰'; - break; - default: - statusColor = '#6c757d'; // Gray - statusIcon = '❓'; - break; - } - - // Format command - show truncated command with tooltip - const cmdArray = task.command || []; - const cmdText = Array.isArray(cmdArray) ? cmdArray.join(' ') : String(cmdArray); - const cmdTruncated = cmdText.length > 40 ? cmdText.substring(0, 40) + '...' : cmdText; - const cmdEscaped = cmdText.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); - // Also escape cmdTruncated for HTML content - const cmdTruncatedEscaped = cmdTruncated.replace(/&/g, '&').replace(//g, '>'); - - // Format target - show Node info - let targetText = task.nodeId || 'N/A'; - if (task.infraId && task.nodeId) { - targetText = `${task.infraId} / ${task.nodeId}`; - } else if (task.infraId) { - targetText = task.infraId; - } - // Escape for HTML content - const targetTextEscaped = targetText.replace(/&/g, '&').replace(//g, '>'); - - // Started At: format the start time for display - let startedAtText = '-'; - let startedAtFull = ''; - if (task.startedAt) { - const startDate = new Date(task.startedAt); - if (!isNaN(startDate.getTime())) { - // Short format for table (HH:MM:SS) - use en-US locale for consistency - startedAtText = startDate.toLocaleTimeString('en-US', { hour12: false }); - // Full format for tooltip - startedAtFull = startDate.toLocaleString('en-US'); - } - } - - // Duration: show elapsed time or calculate from start time for running tasks - let durationText = '-'; - let durationTooltip = ''; - if (task.elapsedSeconds && task.elapsedSeconds > 0) { - // Use provided elapsed time (for completed tasks) - const totalSecs = task.elapsedSeconds; - const mins = Math.floor(totalSecs / 60); - const secs = totalSecs % 60; - durationText = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; - durationTooltip = `Elapsed: ${totalSecs} seconds`; - } else if (task.startedAt && (statusLower === 'handling' || statusLower === 'queued')) { - // Calculate elapsed time for running tasks - const startDate = new Date(task.startedAt); - if (!isNaN(startDate.getTime())) { - const now = new Date(); - const elapsedMs = now - startDate; - const totalSecs = Math.floor(elapsedMs / 1000); - const mins = Math.floor(totalSecs / 60); - const secs = totalSecs % 60; - durationText = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; - durationTooltip = `Running since ${startedAtFull}`; - } - } - - // End time for tooltip - const endTime = task.completedAt ? new Date(task.completedAt).toLocaleString('en-US') : ''; - if (endTime) { - durationTooltip = `Started: ${startedAtFull}\nEnded: ${endTime}`; - } - - // Can cancel if task is actively running (Handling or Queued) - const canCancel = statusLower === 'handling' || statusLower === 'queued'; - - // Escape taskId, nsId, infraId for safe use in data attributes - const taskIdEscaped = (task.taskId || '').replace(/&/g, '&').replace(/"/g, '"'); - const nsIdEscaped = (task.nsId || '').replace(/&/g, '&').replace(/"/g, '"'); - const infraIdEscaped = (task.infraId || '').replace(/&/g, '&').replace(/"/g, '"'); - const statusEscaped = (task.status || '').replace(/&/g, '&').replace(//g, '>'); - - tasksHtml += ` - - - - - - - - - `; - }); - - tasksHtml += '
    CommandInfra / NodeStatusStarted AtDurationAction
    - ${cmdTruncatedEscaped} - ${targetTextEscaped} - ${statusIcon} -
    ${statusEscaped}
    -
    - ${startedAtText} - - ${durationText} - - ${canCancel ? - `` : - '-' - } -
    '; - } - - // Update the container - const container = document.getElementById('taskListContainer'); - if (container) { - container.innerHTML = tasksHtml; - - // Attach event listeners to cancel buttons (safer than inline onclick with interpolated values) - container.querySelectorAll('.task-cancel-btn').forEach(btn => { - btn.addEventListener('click', function() { - const taskId = this.dataset.taskId; - const nsId = this.dataset.nsId; - const infraId = this.dataset.infraId; - cancelTaskFromModal(taskId, nsId, infraId); - }); - }); - } - - // Update active task count in title - const activeTasks = sortedTasks.filter(t => ['handling', 'queued'].includes((t.status || '').toLowerCase())); - const activeCountEl = document.getElementById('taskActiveCount'); - if (activeCountEl) { - activeCountEl.innerHTML = activeTasks.length > 0 - ? `(${activeTasks.length} active)` - : ''; - } - - // Update last refresh time - const lastRefreshEl = document.getElementById('taskLastRefreshTime'); - if (lastRefreshEl) { - lastRefreshEl.textContent = `Last refresh: ${new Date().toLocaleTimeString('en-US', { hour12: false })}`; - } - - } catch (error) { - console.error("Failed to fetch tasks:", error); - const container = document.getElementById('taskListContainer'); - if (container) { - container.innerHTML = `

    Failed to load tasks: ${error.message}

    `; - } - } -} - -// Show Task Management Modal -async function showTaskManagementModal() { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - var namespace = configNamespace; - var infraid = getSelectedInfraId(); - - if (!namespace) { - Swal.fire({ - icon: 'info', - title: 'Select Namespace', - text: 'Please select a namespace first.', - confirmButtonColor: '#3085d6' - }); - return; - } - - // Fetch Infra list for selector - let infraListOptions = []; - try { - const infraListUrl = `${tbApiBase()}/ns/${namespace}/infra?option=id`; - const infraRes = await axios.get(infraListUrl, { - auth: { username: username, password: password } - }); - if (infraRes.data.output && infraRes.data.output.length > 0) { - infraListOptions = infraRes.data.output; - } - } catch (err) { - console.error("Failed to fetch Infra list:", err); - } - - if (infraListOptions.length === 0) { - Swal.fire({ - icon: 'info', - title: 'No Infra Available', - text: 'No Infra available in this namespace. Please create an Infra first.', - confirmButtonColor: '#3085d6' - }); - return; - } - - // If no Infra selected, use the first one from the list - if (!infraid) { - infraid = infraListOptions[0]; - } - - // Build Infra selector options HTML - const infraOptionsHtml = infraListOptions.map(m => - `` - ).join(''); - - Swal.fire({ - title: `📋 Command Execution History `, - html: ` -
    -
    -
    - - -
    -
    - - -
    -
    -
    - 🟢 Auto-refreshing every 3 seconds | - Last refresh: - -
    -
    -

    Loading tasks...

    -
    -
    - `, - width: '1000px', - showCancelButton: false, - confirmButtonText: '❌ Close', - didOpen: async () => { - // Store context for access from modal functions - window.currentTaskNamespace = namespace; - window.currentTaskInfraId = infraid; - - // Auto-refresh setup - window.taskAutoRefreshEnabled = true; - window.taskAutoRefreshInterval = null; - window.taskLastData = null; // Reset cached data - - // Infra selector change handler - const infraSelector = document.getElementById('taskInfraSelector'); - if (infraSelector) { - infraSelector.addEventListener('change', function() { - window.currentTaskInfraId = this.value; - window.taskLastData = null; // Force refresh on Infra change - loadTaskListInModal(window.currentTaskNamespace, window.currentTaskInfraId); - }); - } - - // Setup refresh now button event listener - const refreshNowBtn = document.getElementById('taskRefreshNowBtn'); - if (refreshNowBtn) { - refreshNowBtn.addEventListener('click', function() { - window.taskLastData = null; // Force refresh - loadTaskListInModal(window.currentTaskNamespace, window.currentTaskInfraId); - }); - } - - // Setup toggle button event listener - const toggleBtn = document.getElementById('taskToggleAutoRefreshBtn'); - if (toggleBtn) { - toggleBtn.addEventListener('click', function() { - window.taskAutoRefreshEnabled = !window.taskAutoRefreshEnabled; - - const status = document.getElementById('taskAutoRefreshStatus'); - - if (window.taskAutoRefreshEnabled) { - this.innerHTML = '⏸️ Pause Auto-refresh'; - this.className = 'btn btn-success btn-sm'; - if (status) status.innerHTML = '🟢 Auto-refreshing every 3 seconds'; - } else { - this.innerHTML = '▶️ Resume Auto-refresh'; - this.className = 'btn btn-warning btn-sm'; - if (status) status.innerHTML = '🔴 Auto-refresh paused'; - } - }); - } - - // Initial load with slight delay to ensure DOM is ready - setTimeout(() => { - loadTaskListInModal(window.currentTaskNamespace, window.currentTaskInfraId); - }, 100); - - // Clear any existing interval before creating a new one (prevent memory leaks) - if (window.taskAutoRefreshInterval) { - clearInterval(window.taskAutoRefreshInterval); - } - - // Start auto-refresh timer (3 seconds for tasks - faster than snapshots) - window.taskAutoRefreshInterval = setInterval(() => { - if (window.taskAutoRefreshEnabled) { - loadTaskListInModal(window.currentTaskNamespace, window.currentTaskInfraId); - } - }, 3000); // 3 seconds - }, - willClose: () => { - // Cleanup: clear auto-refresh timer when modal closes - if (window.taskAutoRefreshInterval) { - clearInterval(window.taskAutoRefreshInterval); - window.taskAutoRefreshInterval = null; - } - window.taskAutoRefreshEnabled = false; - window.taskLastData = null; - } - }); -} -window.showTaskManagementModal = showTaskManagementModal; - -// Cancel a specific task from the modal -async function cancelTaskFromModal(taskId, nsId, infraId) { - var config = getConfig(); - var hostname = config.hostname; - var port = config.port; - var username = config.username; - var password = config.password; - - // nsId and infraId are required (passed from task data) - if (!nsId || !infraId || nsId === 'undefined' || infraId === 'undefined') { - Swal.fire({ - icon: 'error', - title: 'Error', - text: 'Missing namespace or Infra information for this task.', - confirmButtonColor: '#3085d6' - }); - return; - } - - const url = `${tbApiBase()}/ns/${nsId}/cmd/infra/${infraId}/task/${taskId}/cancel`; - - const result = await Swal.fire({ - title: 'Cancel Task?', - text: `Are you sure you want to cancel task "${taskId.substring(0, 20)}..."?`, - icon: 'warning', - showCancelButton: true, - confirmButtonColor: '#dc3545', - cancelButtonColor: '#6c757d', - confirmButtonText: 'Yes, cancel it!', - cancelButtonText: 'No' - }); - - if (!result.isConfirmed) return; - - try { - await axios.post(url, {}, { - auth: { username: username, password: password } - }); - - // Show brief success toast - Swal.fire({ - icon: 'success', - title: 'Task Cancelled', - text: 'The task has been cancelled successfully.', - timer: 1500, - showConfirmButton: false, - toast: true, - position: 'top-end' - }); - - // Force refresh the task list immediately - window.taskLastData = null; - if (window.currentTaskNamespace && window.currentTaskInfraId) { - loadTaskListInModal(window.currentTaskNamespace, window.currentTaskInfraId); - } - - } catch (error) { - console.error("Failed to cancel task:", error); - Swal.fire({ - icon: 'error', - title: 'Failed to Cancel', - text: error.response?.data?.message || error.message, - toast: true, - position: 'top-end', - timer: 3000, - showConfirmButton: false - }); - } -} -window.cancelTaskFromModal = cancelTaskFromModal; - -// ==================== Template Management Functions ==================== - -// Template type registry — add new types here for extensibility -const TEMPLATE_TYPES = [ - { - key: 'infra', - label: 'Infra', - icon: '🚀', - badgeClass: 'badge-primary', - bodyFieldName: 'infraDynamicReq', - applyUrlFn: (baseUrl, templateId) => `${baseUrl}/infra/template/${templateId}`, - directCreateUrlFn: (baseUrl) => `${baseUrl}/infraDynamic`, - placeholder: `{ - "name": "my-infra", - "installMonAgent": "no", - "description": "My Infra", - "nodeGroups": [ - { - "name": "web", - "nodeGroupSize": 1, - "specId": "aws+ap-northeast-2+t3.small" - } - ] -}` - }, - { - key: 'vNet', - label: 'vNet', - icon: '🌐', - badgeClass: 'badge-success', - bodyFieldName: 'vNetReq', - applyUrlFn: (baseUrl, templateId) => `${baseUrl}/resources/vNet/template/${templateId}`, - directCreateUrlFn: (baseUrl) => `${baseUrl}/resources/vNet`, - placeholder: `{ - "name": "my-vnet", - "connectionName": "aws-ap-northeast-2", - "cidrBlock": "10.0.0.0/16", - "subnetInfoList": [ - { - "name": "subnet-1", - "ipv4_CIDR": "10.0.1.0/24" - } - ] -}` - }, - { - key: 'securityGroup', - label: 'SecurityGroup', - icon: '🛡️', - badgeClass: 'badge-warning', - bodyFieldName: 'securityGroupReq', - applyUrlFn: (baseUrl, templateId) => `${baseUrl}/resources/securityGroup/template/${templateId}`, - directCreateUrlFn: (baseUrl) => `${baseUrl}/resources/securityGroup`, - placeholder: `{ - "connectionName": "aws-ap-northeast-2", - "vNetId": "my-vnet", - "firewallRules": [ - { - "ports": "22", - "protocol": "TCP", - "direction": "inbound", - "cidr": "0.0.0.0/0" - }, - { - "ports": "80", - "protocol": "TCP", - "direction": "inbound", - "cidr": "0.0.0.0/0" - } - ] -}` - }, - { - key: 'k8sCluster', - label: 'K8s Cluster', - icon: '☸️', - badgeClass: 'badge-info', - bodyFieldName: 'k8sMultiClusterDynamicReq', - applyNameField: 'namePrefix', - applyUrlFn: (baseUrl, templateId) => `${baseUrl}/k8sCluster/template/${templateId}`, - directCreateUrlFn: (baseUrl) => `${baseUrl}/k8sMultiClusterDynamic`, - placeholder: `{ - "namePrefix": "across", - "clusters": [ - { - "connectionName": "aws-ap-northeast-2", - "specId": "aws+ap-northeast-2+t3a.xlarge", - "imageId": "default", - "nodeGroupName": "k8sng01", - "desiredNodeSize": 1, - "minNodeSize": 1, - "maxNodeSize": 2, - "onAutoScaling": "true" - } - ] -}` - } -]; - -function getTemplateTypeMeta(typeKey) { - return TEMPLATE_TYPES.find(t => t.key === typeKey) || { key: typeKey, label: typeKey, icon: '📦', badgeClass: 'badge-secondary', bodyFieldName: typeKey + 'Req' }; -} - -// Show the main Template Management modal -async function showTemplateManagement(overrideNs) { - const config = getConfig(); - const { hostname, port, username, password } = config; - const authConfig = { username, password }; - - // Determine initial namespace — default to "system" where init templates are stored - const currentNs = overrideNs || 'system'; - - // Load namespace list - let namespaces = []; - try { - const nsRes = await axios.get(`${tbApiBase()}/ns?option=id`, { auth: authConfig }); - namespaces = nsRes.data.output || nsRes.data.ns || []; - } catch (e) { - console.error('Error loading namespaces:', e); - } - - if (namespaces.length === 0) { - Swal.fire('Warning', 'No namespaces found. Please create a namespace first.', 'warning'); - return; - } - - // Build namespace dropdown options - const nsOptionsHtml = namespaces.map(ns => { - const nsId = typeof ns === 'string' ? ns : (ns.id || ns.name || ''); - const safeNsId = window.escapeHtml(nsId); - const selected = nsId === currentNs ? 'selected' : ''; - return ``; - }).join(''); - - // Build type tab buttons - const typeTabsHtml = [ - ``, - ...TEMPLATE_TYPES.map(t => - `` - ) - ].join(''); - - // Determine selected namespace (use first if currentNs not in list) - const selectedNs = namespaces.some(ns => (typeof ns === 'string' ? ns : ns.id) === currentNs) - ? currentNs - : (typeof namespaces[0] === 'string' ? namespaces[0] : namespaces[0].id); - - // Store state for internal reload - window._tmplMgmtNs = selectedNs; - - Swal.fire({ - title: '📄 Template Management', - html: ` - -
    -
    - - - - -
    -
    - ${typeTabsHtml} -
    -
    - -
    -
    -
    Loading templates...
    -
    -
    -
    - `, - showConfirmButton: false, - showCancelButton: true, - cancelButtonText: '❌ Close', - width: '780px', - customClass: { popup: 'swal2-template-mgmt' }, - didOpen: () => { - // Load templates for the selected namespace - tmplLoadTemplates(selectedNs); - } - }); -} -window.showTemplateManagement = showTemplateManagement; - -// Load templates for a given namespace and render them into the modal -async function tmplLoadTemplates(namespace) { - const config = getConfig(); - const { hostname, port, username, password } = config; - const authConfig = { username, password }; - const baseUrl = `${tbApiBase()}/ns/${namespace}`; - const container = document.getElementById('tmplListContainer'); - const statusBar = document.getElementById('tmplStatusBar'); - - if (!container) return; - container.innerHTML = '
    Loading...
    '; - - // Load all template types in parallel - const templatesByType = {}; - try { - const results = await Promise.all( - TEMPLATE_TYPES.map(t => - axios.get(`${baseUrl}/template/${t.key}`, { auth: authConfig }) - .then(res => ({ key: t.key, templates: res.data.templates || [] })) - .catch(() => ({ key: t.key, templates: [] })) - ) - ); - results.forEach(r => { templatesByType[r.key] = r.templates; }); - } catch (e) { - console.error('Error loading templates:', e); - } - - // Store for filtering - window._tmplData = templatesByType; - window._tmplMgmtNs = namespace; - - // Render cards - const allCards = []; - TEMPLATE_TYPES.forEach(typeMeta => { - const templates = templatesByType[typeMeta.key] || []; - templates.forEach(t => { - allCards.push(renderTemplateCard(t, typeMeta, namespace)); - }); - }); - - if (allCards.length > 0) { - container.innerHTML = allCards.join(''); - } else { - container.innerHTML = '
    No templates found in this namespace.
    '; - } - - // Status bar - if (statusBar) { - const counts = TEMPLATE_TYPES.map(t => `${(templatesByType[t.key] || []).length} ${t.label}`).join(' + '); - statusBar.innerHTML = `Total: ${counts} templates in ${namespace}`; - } - - // Re-apply current filter - filterTemplateCards(); -} -window.tmplLoadTemplates = tmplLoadTemplates; - -// Render a single template card -function renderTemplateCard(t, typeMeta, namespace) { - const typeBadge = `${typeMeta.icon} ${typeMeta.label}`; - const source = t.source || 'user'; - const createdAt = t.createdAt ? new Date(t.createdAt).toLocaleString() : '-'; - - // Escape single quotes in IDs for onclick handlers - const safeNs = namespace.replace(/'/g, "\\'"); - const safeId = (t.id || '').replace(/'/g, "\\'"); - const safeType = typeMeta.key.replace(/'/g, "\\'"); - - const safeName = window.escapeHtml(t.name || t.id || ''); - const safeDescription = t.description ? window.escapeHtml(t.description) : 'No description'; - const safeDataId = window.escapeHtml(t.id || ''); - const safeDataName = window.escapeHtml((t.name || t.id || '').toLowerCase()); - - return ` -
    -
    -
    - ${safeName} - ${typeBadge} - ${window.escapeHtml(source)} -
    -
    - - ${typeMeta.key === 'infra' ? `` : ''} - ${typeMeta.key === 'k8sCluster' ? `` : ''} - - -
    -
    -
    -
    ${safeDescription}
    -
    Created: ${createdAt}
    -
    -
    - `; -} - -// Tab-based type filtering -function tmplFilterByTab(btn) { - // Update active tab - document.querySelectorAll('.tmpl-tab').forEach(t => t.classList.remove('active')); - btn.classList.add('active'); - filterTemplateCards(); -} -window.tmplFilterByTab = tmplFilterByTab; - -// Filter template cards by active tab and search text -function filterTemplateCards() { - const activeTab = document.querySelector('.tmpl-tab.active'); - const typeFilter = activeTab ? activeTab.getAttribute('data-filter') : 'all'; - const searchFilter = (document.getElementById('tmplFilterSearch')?.value || '').toLowerCase(); - const cards = document.querySelectorAll('.tmpl-card'); - let visibleCount = 0; - cards.forEach(card => { - const type = card.getAttribute('data-type'); - const text = (card.textContent || '').toLowerCase(); - const name = card.getAttribute('data-name') || ''; - const typeMatch = typeFilter === 'all' || type === typeFilter; - const searchMatch = !searchFilter || name.includes(searchFilter) || text.includes(searchFilter); - const visible = typeMatch && searchMatch; - card.style.display = visible ? '' : 'none'; - if (visible) visibleCount++; - }); -} -window.filterTemplateCards = filterTemplateCards; - -// Namespace change handler within the modal -function tmplChangeNamespace(ns) { - window._tmplMgmtNs = ns; - // Reset search and tabs - const searchInput = document.getElementById('tmplFilterSearch'); - if (searchInput) searchInput.value = ''; - document.querySelectorAll('.tmpl-tab').forEach(t => { - t.classList.toggle('active', t.getAttribute('data-filter') === 'all'); - }); - tmplLoadTemplates(ns); -} -window.tmplChangeNamespace = tmplChangeNamespace; - -// Refresh templates for current namespace -function tmplRefresh() { - const ns = window._tmplMgmtNs || document.getElementById('tmplNsSelect')?.value; - if (ns) tmplLoadTemplates(ns); -} -window.tmplRefresh = tmplRefresh; - -// Unified "New Template" — shows a type picker first -async function tmplCreateNew() { - const ns = window._tmplMgmtNs || document.getElementById('tmplNsSelect')?.value; - if (!ns) { - Swal.fire('Warning', 'No namespace selected', 'warning'); - return; - } - - // Build type picker buttons - const typeButtons = TEMPLATE_TYPES.map(t => - `` - ).join(''); - - Swal.fire({ - title: '➕ Create New Template', - html: ` -

    Select the template type to create in namespace ${ns}:

    -
    - ${typeButtons} -
    - `, - showConfirmButton: false, - showCancelButton: true, - cancelButtonText: '⬅️ Back', - width: '500px' - }); -} -window.tmplCreateNew = tmplCreateNew; - -// View a single template's full JSON detail -async function viewTemplateDetail(namespace, type, templateId) { - const config = getConfig(); - const { hostname, port, username, password } = config; - const url = `${tbApiBase()}/ns/${namespace}/template/${type}/${templateId}`; - const typeMeta = getTemplateTypeMeta(type); - - try { - const res = await axios.get(url, { auth: { username, password } }); - const data = res.data; - - const safeTemplateId = window.escapeHtml(templateId); - const safeSource = window.escapeHtml(data.source || 'user'); - const createdAtStr = data.createdAt ? new Date(data.createdAt).toLocaleString() : '-'; - - Swal.fire({ - title: `📄 Template: ${safeTemplateId}`, - html: ` -
    -
    - ${typeMeta.icon} ${typeMeta.label} - ${safeSource} - Created: ${window.escapeHtml(createdAtStr)} -
    -
    
    -          
    - - ${type === 'infra' ? `` : ''} - ${type === 'k8sCluster' ? `` : ''} - - -
    -
    - `, - showConfirmButton: false, - showCancelButton: true, - cancelButtonText: '❌ Close', - width: '750px', - didOpen: (popup) => { - const jsonText = JSON.stringify(data, null, 2); - const jsonEl = popup.querySelector('#tmplDetailJson'); - if (jsonEl) jsonEl.textContent = jsonText; - window._lastTemplateJson = jsonText; - popup.querySelector('.tmpl-detail-apply')?.addEventListener('click', () => applyTemplate(namespace, type, templateId)); - popup.querySelector('.tmpl-detail-load')?.addEventListener('click', () => loadTemplateToInfraConfig(namespace, templateId)); - popup.querySelector('.tmpl-detail-load-k8s')?.addEventListener('click', () => loadTemplateToK8sConfig(namespace, templateId)); - popup.querySelector('.tmpl-detail-copy')?.addEventListener('click', () => copyTemplateJson()); - popup.querySelector('.tmpl-detail-back')?.addEventListener('click', () => showTemplateManagement(namespace)); - } - }); - } catch (err) { - Swal.fire({ icon: 'error', title: '❌ Error', text: `Failed to load template: ${err.response?.data?.message || err.message}` }); - } -} -window.viewTemplateDetail = viewTemplateDetail; - -// Copy template JSON to clipboard -function copyTemplateJson() { - if (window._lastTemplateJson) { - navigator.clipboard.writeText(window._lastTemplateJson).then(() => { - Swal.fire({ icon: 'success', title: 'Copied to clipboard', timer: 1200, showConfirmButton: false, toast: true, position: 'top-end' }); - }).catch(() => { - const ta = document.createElement('textarea'); - ta.value = window._lastTemplateJson; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - Swal.fire({ icon: 'success', title: 'Copied to clipboard', timer: 1200, showConfirmButton: false, toast: true, position: 'top-end' }); - }); - } -} -window.copyTemplateJson = copyTemplateJson; - -// Apply a template (create resource from template) -// sourceNs: namespace where the template is stored -async function applyTemplate(sourceNs, type, templateId) { - const typeMeta = getTemplateTypeMeta(type); - const config = getConfig(); - const { hostname, port, username, password } = config; - const authConfig = { username, password }; - - // Load namespace list for target namespace selection - let namespaces = []; - try { - const nsRes = await axios.get(`${tbApiBase()}/ns?option=id`, { auth: authConfig }); - namespaces = nsRes.data.output || nsRes.data.ns || []; - } catch (e) { - console.error('Error loading namespaces:', e); - } - - // Default target namespace: use the main panel's namespace (not the template source ns) - const mainNs = configNamespace || sourceNs; - const nsOptionsHtml = namespaces.map(ns => { - const nsId = typeof ns === 'string' ? ns : (ns.id || ns.name || ''); - const safeNsId = window.escapeHtml(nsId); - // Default to the main panel's namespace, not the template source - const selected = nsId === mainNs ? 'selected' : ''; - return ``; - }).join(''); - - const crossNsHint = sourceNs !== mainNs - ? `
    💡 Template is from namespace ${window.escapeHtml(sourceNs)}. You can select a different target namespace to create the resource in.
    ` - : ''; - - const safeTemplateIdHtml = window.escapeHtml(templateId); - const nameField = typeMeta.applyNameField || 'name'; - const nameLabel = nameField === 'namePrefix' ? 'Name Prefix' : 'Name'; - const namePlaceholder = nameField === 'namePrefix' ? 'my-k8s' : 'my-new-resource'; - const { value: formValues } = await Swal.fire({ - title: `▶️ Apply ${typeMeta.label} Template`, - html: ` -
    -

    Create a new ${typeMeta.label} from template ${safeTemplateIdHtml}

    - ${crossNsHint} -
    - - -
    -
    - - -
    -
    - - -
    -
    - `, - focusConfirm: false, - showCancelButton: true, - confirmButtonText: `▶️ Create ${typeMeta.label}`, - confirmButtonColor: '#28a745', - width: '580px', - preConfirm: () => { - const targetNs = document.getElementById('tmplApplyTargetNs')?.value?.trim(); - const name = document.getElementById('tmplApplyName')?.value?.trim(); - if (!targetNs) { - Swal.showValidationMessage('Target namespace is required'); - return false; - } - if (!name) { - Swal.showValidationMessage(`${nameLabel} is required`); - return false; - } - return { - targetNs: targetNs, - name: name, - description: document.getElementById('tmplApplyDesc')?.value?.trim() || '' - }; - } - }); - - if (!formValues) return; - - const targetNs = formValues.targetNs; - const targetBaseUrl = `${tbApiBase()}/ns/${targetNs}`; - - const spinnerId = addSpinnerTask(`Creating ${typeMeta.label} from template`); - try { - let res; - - if (targetNs === sourceNs) { - // Same namespace: use the template apply shortcut endpoint - let url; - if (typeMeta.applyUrlFn) { - url = typeMeta.applyUrlFn(targetBaseUrl, templateId); - } else { - url = `${targetBaseUrl}/resources/${type}/template/${templateId}`; - } - const applyBody = { description: formValues.description }; - applyBody[nameField] = formValues.name; - res = await axios.post(url, applyBody, { auth: authConfig }); - } else { - // Cross-namespace: GET template from source ns, then directly create in target ns - const templateUrl = `${tbApiBase()}/ns/${sourceNs}/template/${type}/${templateId}`; - const tmplRes = await axios.get(templateUrl, { auth: authConfig }); - const tmplData = tmplRes.data; - - // Extract the request body from template - const reqBody = tmplData[typeMeta.bodyFieldName]; - if (!reqBody) { - throw new Error(`Template does not contain '${typeMeta.bodyFieldName}' field`); - } - - // Apply name/namePrefix and description overrides - reqBody[nameField] = formValues.name; - if (formValues.description) { - reqBody.description = formValues.description; - } - - // POST to the target namespace's direct creation endpoint - let createUrl; - if (typeMeta.directCreateUrlFn) { - createUrl = typeMeta.directCreateUrlFn(targetBaseUrl); - } else { - createUrl = `${targetBaseUrl}/resources/${type}`; - } - res = await axios.post(createUrl, reqBody, { auth: authConfig }); - } - - removeSpinnerTask(spinnerId); - const nsNote = targetNs !== sourceNs ? ` in namespace "${targetNs}"` : ''; - Swal.fire({ - icon: 'success', - title: `${typeMeta.label} Created!`, - text: `Successfully created from template "${templateId}"${nsNote}`, - timer: 3000, - showConfirmButton: false - }); - outputAlert(res.data, "success"); - } catch (err) { - removeSpinnerTask(spinnerId); - Swal.fire('❌ Creation Failed', err.response?.data?.message || err.message, 'error'); - } -} -window.applyTemplate = applyTemplate; - -// Delete a template -async function deleteTemplate(namespace, type, templateId) { - const typeMeta = getTemplateTypeMeta(type); - const result = await Swal.fire({ - title: '🗑️ Delete Template?', - text: `Are you sure you want to delete ${typeMeta.label} template "${templateId}"?`, - icon: 'warning', - showCancelButton: true, - confirmButtonText: '🗑️ Delete', - confirmButtonColor: '#dc3545', - cancelButtonText: 'Cancel' - }); - if (!result.isConfirmed) return; - - const config = getConfig(); - const { hostname, port, username, password } = config; - const url = `${tbApiBase()}/ns/${namespace}/template/${type}/${templateId}`; - - try { - await axios.delete(url, { auth: { username, password } }); - Swal.fire({ icon: 'success', title: 'Template Deleted', timer: 1500, showConfirmButton: false, toast: true, position: 'top-end' }); - showTemplateManagement(namespace); - } catch (err) { - Swal.fire('❌ Error', `Failed to delete template: ${err.response?.data?.message || err.message}`, 'error'); - } -} -window.deleteTemplate = deleteTemplate; - -// Create a new template dialog (called from type picker) -async function createTemplateDialog(namespace, type) { - const typeMeta = getTemplateTypeMeta(type); - - const { value: formValues } = await Swal.fire({ - title: `➕ Create ${typeMeta.label} Template`, - html: ` -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - `, - focusConfirm: false, - showCancelButton: true, - confirmButtonText: '➕ Create Template', - confirmButtonColor: '#007bff', - width: '650px', - preConfirm: () => { - const name = document.getElementById('newTmplName')?.value?.trim(); - const desc = document.getElementById('newTmplDesc')?.value?.trim() || ''; - const bodyStr = document.getElementById('newTmplBody')?.value?.trim(); - if (!name) { - Swal.showValidationMessage('Template name is required'); - return false; - } - if (!bodyStr) { - Swal.showValidationMessage('Request body JSON is required'); - return false; - } - let bodyJson; - try { - bodyJson = JSON.parse(bodyStr); - } catch (e) { - Swal.showValidationMessage('Invalid JSON: ' + e.message); - return false; - } - const reqPayload = { name: name, description: desc }; - reqPayload[typeMeta.bodyFieldName] = bodyJson; - return reqPayload; - } - }); - - if (!formValues) return; - - const config = getConfig(); - const { hostname, port, username, password } = config; - const url = `${tbApiBase()}/ns/${namespace}/template/${type}`; - - const spinnerId = addSpinnerTask('Creating template'); - try { - await axios.post(url, formValues, { auth: { username, password } }); - removeSpinnerTask(spinnerId); - Swal.fire({ icon: 'success', title: 'Template Created!', text: `Template "${formValues.name}" created successfully.`, timer: 2000, showConfirmButton: false }); - setTimeout(() => showTemplateManagement(namespace), 500); - } catch (err) { - removeSpinnerTask(spinnerId); - Swal.fire('❌ Error', `Failed to create template: ${err.response?.data?.message || err.message}`, 'error'); - } -} -window.createTemplateDialog = createTemplateDialog; - -// Save Infra config as template - called from copyInfraConfig flow -async function saveConfigAsTemplate(namespace, infraId, infraReq) { - const { value: formValues } = await Swal.fire({ - title: '📄 Save as Infra Template', - html: ` -
    -

    Save the extracted Infra configuration from ${window.escapeHtml(infraId)} as a reusable template.

    -
    - - -
    -
    - - -
    -
    - -
    
    -        
    -
    - `, - focusConfirm: false, - showCancelButton: true, - confirmButtonText: '💾 Save as Template', - confirmButtonColor: '#007bff', - width: '650px', - didOpen: (popup) => { - const previewEl = popup.querySelector('#saveTmplConfigPreview'); - if (previewEl) previewEl.textContent = JSON.stringify(infraReq, null, 2); - }, - preConfirm: () => { - const name = document.getElementById('saveTmplName')?.value?.trim(); - if (!name) { - Swal.showValidationMessage('Template name is required'); - return false; - } - return { - name: name, - description: document.getElementById('saveTmplDesc')?.value?.trim() || '', - infraDynamicReq: infraReq - }; - } - }); - - if (!formValues) return; - - const config = getConfig(); - const { hostname, port, username, password } = config; - const url = `${tbApiBase()}/ns/${namespace}/template/infra`; - - const spinnerId = addSpinnerTask('Saving template'); - try { - await axios.post(url, formValues, { auth: { username, password } }); - removeSpinnerTask(spinnerId); - Swal.fire({ - icon: 'success', - title: 'Template Saved!', - text: `Template "${formValues.name}" has been saved. You can find it in Template Management.`, - timer: 2500, - showConfirmButton: false - }); - } catch (err) { - removeSpinnerTask(spinnerId); - Swal.fire('❌ Error', `Failed to save template: ${err.response?.data?.message || err.message}`, 'error'); - } -} -window.saveConfigAsTemplate = saveConfigAsTemplate; - -// Load an Infra template into the MC-Infra Configuration panel (same as Copy Config) -async function loadTemplateToInfraConfig(namespace, templateId) { - const config = getConfig(); - const { hostname, port, username, password } = config; - const url = `${tbApiBase()}/ns/${namespace}/template/infra/${templateId}`; - - const spinnerId = addSpinnerTask('Loading template to MC-Infra Configuration'); - let data; - try { - const res = await axios.get(url, { auth: { username, password } }); - data = res.data; - } catch (err) { - removeSpinnerTask(spinnerId); - Swal.fire({ icon: 'error', title: '❌ Error', text: `Failed to load template: ${err.response?.data?.message || err.message}` }); - return; - } - - const infraReq = data.infraDynamicReq; - if (!infraReq || !infraReq.nodeGroups || infraReq.nodeGroups.length === 0) { - removeSpinnerTask(spinnerId); - Swal.fire('⚠️ Warning', 'No NodeGroup configuration found in this template.', 'warning'); - return; - } - - // Close Template Management modal - Swal.close(); - - // Clear existing configuration - clearCircle(''); - - // Populate nodeGroupRequestFromSpecList and recommendedSpecList (mirrors copyInfraConfig logic) - // Fetch spec details for each nodeGroup in parallel via specId - const specFetches = infraReq.nodeGroups.map(function(sg) { - var nodeConfig = $.extend({}, createInfraReqVmTmplt); - nodeConfig.name = sg.name || ('g' + (nodeGroupRequestFromSpecList.length + 1)); - nodeConfig.specId = sg.specId || ''; - nodeConfig.imageId = sg.imageId || 'ubuntu22.04'; - nodeConfig.rootDiskType = sg.rootDiskType || 'default'; - nodeConfig.rootDiskSize = sg.rootDiskSize || 0; - nodeConfig.nodeGroupSize = sg.nodeGroupSize || 1; - nodeConfig.description = sg.description || 'mapui'; - nodeConfig.connectionName = sg.connectionName || ''; - nodeConfig.zone = sg.zone || ''; - if (sg.label && Object.keys(sg.label).length > 0) { - nodeConfig.label = sg.label; - } - nodeGroupRequestFromSpecList.push(nodeConfig); - - // Fetch spec details if specId is available - if (sg.specId) { - const specUrl = `${tbApiBase()}/ns/system/resources/spec/${sg.specId}`; - return axios.get(specUrl, { auth: { username, password } }) - .then(function(specRes) { - const s = specRes.data; - return { - id: sg.specId, - providerName: s.providerName || extractProviderFromSpecId(sg.specId), - regionName: s.regionName || extractRegionFromSpecId(sg.specId), - cspSpecName: s.cspSpecName || sg.specId, - vCPU: s.vCPU ?? 'N/A', - memoryGiB: s.memoryGiB ?? 'N/A', - costPerHour: s.costPerHour || 0, - acceleratorType: s.acceleratorType || '', - acceleratorModel: s.acceleratorModel || '', - acceleratorCount: s.acceleratorCount || 0, - acceleratorMemoryGB: s.acceleratorMemoryGB || '', - connectionName: sg.connectionName || '', - rootDiskType: sg.rootDiskType || 'default' - }; - }) - .catch(function() { - // Fallback to parsed values if spec fetch fails - return { - id: sg.specId, - providerName: extractProviderFromSpecId(sg.specId), - regionName: extractRegionFromSpecId(sg.specId), - cspSpecName: sg.specId, - vCPU: 'N/A', - memoryGiB: 'N/A', - costPerHour: 0, - acceleratorType: '', - acceleratorModel: '', - acceleratorCount: 0, - acceleratorMemoryGB: '', - connectionName: sg.connectionName || '', - rootDiskType: sg.rootDiskType || 'default' - }; - }); - } else { - return Promise.resolve({ - id: '', - providerName: '', - regionName: '', - cspSpecName: '', - vCPU: 'N/A', - memoryGiB: 'N/A', - costPerHour: 0, - acceleratorType: '', - acceleratorModel: '', - acceleratorCount: 0, - acceleratorMemoryGB: '', - connectionName: sg.connectionName || '', - rootDiskType: sg.rootDiskType || 'default' - }); - } - }); - - // Wait for all spec fetches, then render - try { - const specInfoList = await Promise.all(specFetches); - specInfoList.forEach(function(specInfo) { - recommendedSpecList.push(specInfo); - }); - updateNodeGroupReview(); - - // Switch to Provision tab - var provisionTab = document.getElementById('provision-tab'); - if (provisionTab) provisionTab.click(); - - Swal.fire({ - toast: true, - position: 'bottom-end', - icon: 'success', - title: `Template "${window.escapeHtml(templateId)}" loaded to Provision panel`, - showConfirmButton: false, - timer: 3000, - timerProgressBar: true - }); - } finally { - removeSpinnerTask(spinnerId); - } -} -window.loadTemplateToInfraConfig = loadTemplateToInfraConfig; - -async function loadTemplateToK8sConfig(namespace, templateId) { - const config = getConfig(); - const { hostname, port, username, password } = config; - const url = `${tbApiBase()}/ns/${namespace}/template/k8sCluster/${templateId}`; - - const spinnerId = addSpinnerTask('Loading K8s template to Configuration'); - let data; - try { - const res = await axios.get(url, { auth: { username, password } }); - data = res.data; - } catch (err) { - removeSpinnerTask(spinnerId); - Swal.fire({ icon: 'error', title: '❌ Error', text: `Failed to load template: ${err.response?.data?.message || err.message}` }); - return; - } - - const multiReq = data.k8sMultiClusterDynamicReq; - if (!multiReq || !multiReq.clusters || multiReq.clusters.length === 0) { - removeSpinnerTask(spinnerId); - Swal.fire('⚠️ Warning', 'No cluster configuration found in this template.', 'warning'); - return; - } - - // Close Template Management modal - Swal.close(); - - // Switch to K8s mode if not already active - const k8sModeInput = document.getElementById('k8sMode'); - if (k8sModeInput && !k8sModeInput.checked) { - // Bootstrap btn-group-toggle requires updating the active class on the label, - // setting .checked alone only changes the internal state without updating visuals. - document.getElementById('nodeMode')?.closest('label')?.classList.remove('active'); - k8sModeInput.closest('label')?.classList.add('active'); - k8sModeInput.checked = true; - await toggleWorkloadType(); - } - - // Clear existing configuration - clearCircle(''); - - // Populate nodeGroupRequestFromSpecList from k8s cluster configs - const specFetches = multiReq.clusters.map(function(cluster, idx) { - var nodeConfig = $.extend({}, createInfraReqVmTmplt); - nodeConfig.name = cluster.nodeGroupName || ('ng-' + (idx + 1)); - nodeConfig.specId = cluster.specId || ''; - nodeConfig.imageId = cluster.imageId || 'default'; - nodeConfig.rootDiskType = cluster.rootDiskType || 'default'; - nodeConfig.rootDiskSize = cluster.rootDiskSize || 0; - nodeConfig.nodeGroupSize = cluster.desiredNodeSize || 1; - nodeConfig.connectionName = cluster.connectionName || ''; - // K8s-specific fields stored for createK8sCluster() to pick up - nodeConfig.minNodeSize = cluster.minNodeSize || 1; - nodeConfig.maxNodeSize = cluster.maxNodeSize || 3; - nodeConfig.onAutoScaling = cluster.onAutoScaling || 'true'; - nodeConfig.version = cluster.version || ''; - nodeGroupRequestFromSpecList.push(nodeConfig); - - if (cluster.specId) { - const specUrl = `${tbApiBase()}/ns/system/resources/spec/${cluster.specId}`; - return axios.get(specUrl, { auth: { username, password } }) - .then(function(specRes) { - const s = specRes.data; - return { - id: cluster.specId, - providerName: s.providerName || extractProviderFromSpecId(cluster.specId), - regionName: s.regionName || extractRegionFromSpecId(cluster.specId), - cspSpecName: s.cspSpecName || cluster.specId, - vCPU: s.vCPU ?? 'N/A', - memoryGiB: s.memoryGiB ?? 'N/A', - costPerHour: s.costPerHour || 0, - acceleratorType: s.acceleratorType || '', - acceleratorModel: s.acceleratorModel || '', - acceleratorCount: s.acceleratorCount || 0, - acceleratorMemoryGB: s.acceleratorMemoryGB || '', - connectionName: cluster.connectionName || '', - rootDiskType: cluster.rootDiskType || 'default' - }; - }) - .catch(function() { - return { - id: cluster.specId, - providerName: extractProviderFromSpecId(cluster.specId), - regionName: extractRegionFromSpecId(cluster.specId), - cspSpecName: cluster.specId, - vCPU: 'N/A', - memoryGiB: 'N/A', - costPerHour: 0, - acceleratorType: '', - acceleratorModel: '', - acceleratorCount: 0, - acceleratorMemoryGB: '', - connectionName: cluster.connectionName || '', - rootDiskType: cluster.rootDiskType || 'default' - }; - }); - } else { - return Promise.resolve({ - id: '', - providerName: '', - regionName: '', - cspSpecName: '', - vCPU: 'N/A', - memoryGiB: 'N/A', - costPerHour: 0, - acceleratorType: '', - acceleratorModel: '', - acceleratorCount: 0, - acceleratorMemoryGB: '', - connectionName: cluster.connectionName || '', - rootDiskType: cluster.rootDiskType || 'default' - }); - } - }); - - try { - const specInfoList = await Promise.all(specFetches); - specInfoList.forEach(function(specInfo) { - recommendedSpecList.push(specInfo); - }); - updateNodeGroupReview(); - - var provisionTab = document.getElementById('provision-tab'); - if (provisionTab) provisionTab.click(); - - Swal.fire({ - toast: true, - position: 'bottom-end', - icon: 'success', - title: `K8s template "${window.escapeHtml(templateId)}" loaded to Provision panel`, - showConfirmButton: false, - timer: 3000, - timerProgressBar: true - }); - } finally { - removeSpinnerTask(spinnerId); - } -} -window.loadTemplateToK8sConfig = loadTemplateToK8sConfig; - -// ===================================================================== -// Global DNS Management (Route53) -// ===================================================================== - -async function showDnsManagementModal(preselectedInfraId) { - const config = getConfig(); - const namespace = configNamespace || 'default'; - - // Build Infra source section - if preselectedInfraId, pre-fill it - const infraSourceChecked = preselectedInfraId ? 'checked' : ''; - const ipsSourceChecked = preselectedInfraId ? '' : 'checked'; - const infraIdValue = preselectedInfraId || ''; - - // Load hosted zones dynamically - let hostedZoneOptions = ''; - let hostedZoneWarning = ''; - try { - const hzResp = await axios.get(`${tbApiBase()}/resources/globalDns/hostedZone`, { - auth: { username: config.username, password: config.password }, timeout: 15000 - }); - const zones = hzResp.data?.hostedZones || []; - if (zones.length > 0) { - hostedZoneOptions = zones.map(z => { - const name = z.name.replace(/\.$/, ''); - const safeName = window.escapeHtml(name); - return ``; - }).join(''); - } else { - hostedZoneOptions = ''; - hostedZoneWarning = '
    ' - + '⚠️ No hosted zones found.
    ' - + 'AWS credentials are configured, but no Route53 hosted zones exist.
    ' - + 'To use this feature, create a hosted zone in AWS Route53 Console.' - + '
    '; - } - } catch (e) { - const errMsg = e.response?.data?.message || e.message || ''; - hostedZoneOptions = ''; - if (errMsg.includes('VAULT_TOKEN') || errMsg.includes('OpenBao') || errMsg.includes('secret')) { - hostedZoneWarning = '
    ' - + '🔑 AWS credentials not configured.
    ' - + 'This feature requires AWS credentials stored in OpenBao (Vault).
    ' - + 'Setup: Store AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION at secret/csp/aws in OpenBao, and set VAULT_TOKEN env variable.' - + '
    '; - } else { - hostedZoneWarning = '
    ' - + '❌ Failed to load hosted zones.
    ' - + (errMsg ? window.escapeHtml(errMsg) : 'Could not connect to the server or an unexpected error occurred.') - + '
    '; - } - } - - const html = ` -
    - ${hostedZoneWarning} - -
    - - -
    - - -
    -
    🔍 Query DNS Records
    -
    -
    - - -
    -
    - -
    -
    - -
    - - -
    -
    ✏️ Create / Update DNS Record (UPSERT)
    - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - - - - -
    - - -
    - -
    - -
    -
    -
    - - -
    -
    - - -
    -
    - -
    -
    🏷️ Label Filter (optional — narrow down Nodes by labels)
    -
    - No filter — all Nodes in Infra will be used -
    - -
    - -
    -
    Available Labels (click to add/remove)
    -
    - Select an Infra to see available labels... -
    - -
    -
    -
    - - -
    - -
    - -
    -
    -
    -
    - -
    - -
    - -
    -
    - `; - - Swal.fire({ - title: '🌐 Global DNS Management (Route53)', - html: html, - showConfirmButton: false, - showCancelButton: true, - cancelButtonText: '❌ Close', - width: '780px', - customClass: { - popup: 'swal2-infra-context' - }, - didOpen: () => { - // Routing policy toggle - const policySelect = document.getElementById('dns-routing-policy'); - const geoInfo = document.getElementById('dns-geoproxy-info'); - const weightedInfo = document.getElementById('dns-weighted-info'); - const ipsContainer = document.getElementById('dns-source-ips-container'); - const ttlInput = document.getElementById('dns-upsert-ttl'); - - policySelect.addEventListener('change', function() { - const isGeo = this.value === 'geoproximity'; - const isWeighted = this.value === 'weighted'; - geoInfo.style.display = isGeo ? 'block' : 'none'; - weightedInfo.style.display = isWeighted ? 'block' : 'none'; - // Suggest TTL 30s for weighted, restore 300 otherwise - if (isWeighted && ttlInput && ttlInput.value === '300') ttlInput.value = '30'; - if (!isWeighted && ttlInput && ttlInput.value === '30') ttlInput.value = '300'; - ipsContainer.style.opacity = isGeo ? '0.5' : '1'; - if (isGeo) { - const ipsRadio = document.querySelector('input[name="dns-ip-source"][value="ips"]'); - if (ipsRadio.checked) { - const infraRadio = document.querySelector('input[name="dns-ip-source"][value="infra"]'); - infraRadio.checked = true; - infraRadio.dispatchEvent(new Event('change')); - } - ipsRadio.disabled = true; - } else { - document.querySelector('input[name="dns-ip-source"][value="ips"]').disabled = false; - } - }); - - // Radio button toggle for IP source fields - document.querySelectorAll('input[name="dns-ip-source"]').forEach(radio => { - radio.addEventListener('change', function() { - document.getElementById('dns-source-infra-fields').style.display = this.value === 'infra' ? 'block' : 'none'; - document.getElementById('dns-source-ips-fields').style.display = this.value === 'ips' ? 'block' : 'none'; - }); - }); - - // When Infra dropdown changes, fetch Node data and update available labels - document.getElementById('dns-infra-id').addEventListener('change', async function() { - var infraId = this.value; - var labelContainer = document.getElementById('availableLabelsContainer'); - var preview = document.getElementById('labelMatchPreview'); - // Clear label state - var labelInput = document.getElementById('labelSelector'); - if (labelInput) labelInput.value = ''; - if (window.updateSelectedLabelsDisplay) window.updateSelectedLabelsDisplay(); - if (preview) preview.style.display = 'none'; - - if (!infraId) { - if (labelContainer) labelContainer.innerHTML = 'Select an Infra to see available labels...'; - return; - } - // Always fetch fresh Infra detail (cached data may lack Node labels) - if (labelContainer) labelContainer.innerHTML = ' Loading labels...'; - try { - var nsId = document.getElementById('dns-infra-nsid').value.trim(); - var infraDetailUrl = tbApiBase() + '/ns/' + nsId + '/infra/' + infraId; - var resp = await axios.get(infraDetailUrl, { auth: { username: config.username, password: config.password }, timeout: 15000 }); - var infraDetail = resp.data; - if (infraDetail && infraDetail.node) { - if (!window.cloudBaristaCentralData) window.cloudBaristaCentralData = {}; - if (!window.cloudBaristaCentralData.infraData) window.cloudBaristaCentralData.infraData = []; - window.cloudBaristaCentralData.infraData = window.cloudBaristaCentralData.infraData.filter(function(m) { return m.id !== infraId; }); - window.cloudBaristaCentralData.infraData.push(infraDetail); - } - } catch (e) { - console.error('[DNS] Failed to fetch Infra detail for labels:', e); - if (labelContainer) labelContainer.innerHTML = 'Failed to load labels'; - } - if (window.updateAvailableLabels) { - window.updateAvailableLabels(infraId); - if (window.setupClearLabelButtonListener) window.setupClearLabelButtonListener(); - if (window.setupSelectedLabelsEventListeners) window.setupSelectedLabelsEventListeners(); - } - }); - - // Helper: Load Infra list into a Select All - - 0 selected - -
    `; - } - - let tableHtml = ''; - tableHtml += ''; - records.forEach((r, idx) => { - const esc = window.escapeHtml; - const policyBadge = r.routingPolicy === 'geoproximity' - ? `geo ${esc(r.geoLatitude || '')},${esc(r.geoLongitude || '')}` - : r.routingPolicy === 'weighted' - ? `weighted` - : `${esc(r.routingPolicy || 'simple')}`; - const setIdInfo = r.setIdentifier ? `
    id: ${esc(r.setIdentifier)}` : ''; - const isDeletable = r.type !== 'NS' && r.type !== 'SOA'; - const checkbox = isDeletable - ? `` - : ''; - const deleteBtn = isDeletable - ? `` - : ''; - tableHtml += ` - - - - - - - - `; - }); - tableHtml += '
    NameTypeTTLValuesPolicyActions
    ${checkbox}${esc(r.name)}${setIdInfo}${esc(r.type)}${r.ttl}${(r.values || []).map(v => esc(v)).join(', ')}${policyBadge}${deleteBtn}
    '; - resultDiv.innerHTML = bulkBar + tableHtml; - - // Helper: update selected count and bulk button state - function updateBulkState() { - const checked = resultDiv.querySelectorAll('.dns-row-check:checked'); - const countSpan = document.getElementById('dns-selected-count'); - const bulkBtn = document.getElementById('dns-bulk-delete-btn'); - if (countSpan) countSpan.textContent = checked.length + ' selected'; - if (bulkBtn) bulkBtn.disabled = checked.length === 0; - // Sync "select all" checkbox - const selectAll = document.getElementById('dns-select-all'); - const allChecks = resultDiv.querySelectorAll('.dns-row-check'); - if (selectAll && allChecks.length > 0) { - selectAll.checked = checked.length === allChecks.length; - selectAll.indeterminate = checked.length > 0 && checked.length < allChecks.length; - } - } - - // Select all checkbox - const selectAllEl = document.getElementById('dns-select-all'); - if (selectAllEl) { - selectAllEl.addEventListener('change', function() { - resultDiv.querySelectorAll('.dns-row-check').forEach(cb => { cb.checked = this.checked; }); - updateBulkState(); - }); - } - - // Individual checkbox change - resultDiv.querySelectorAll('.dns-row-check').forEach(cb => { - cb.addEventListener('change', updateBulkState); - }); - - // Bulk delete button - const bulkDeleteBtn = document.getElementById('dns-bulk-delete-btn'); - if (bulkDeleteBtn) { - bulkDeleteBtn.addEventListener('click', async function() { - const checked = resultDiv.querySelectorAll('.dns-row-check:checked'); - if (checked.length === 0) return; - - const items = []; - checked.forEach(cb => { - items.push({ name: cb.dataset.name, type: cb.dataset.type, setId: cb.dataset.setid }); - }); - - const confirmed = await Swal.fire({ - title: 'Bulk Delete', - html: `Delete ${items.length} selected record(s)?`, - icon: 'warning', - showCancelButton: true, - confirmButtonColor: '#d33', - confirmButtonText: 'Delete All' - }); - if (!confirmed.isConfirmed) return; - - bulkDeleteBtn.disabled = true; - bulkDeleteBtn.innerHTML = ' Deleting...'; - - try { - const records = items.map(item => { - const rec = { domainName: domain, recordName: item.name.replace(/\.$/, ''), recordType: item.type }; - if (item.setId) rec.setIdentifier = item.setId; - return rec; - }); - - const resp = await axios.delete(`${tbApiBase()}/resources/globalDns/records`, { - data: { records }, - auth: { username: config.username, password: config.password }, - timeout: 60000 - }); - - const data = resp.data; - if (data.failed === 0) { - Swal.fire({ icon: 'success', title: 'Bulk Delete Complete', text: `${data.succeeded} record(s) deleted successfully.`, timer: 2000, showConfirmButton: false }); - } else { - const failedItems = (data.results || []).filter(r => !r.success).map(r => window.escapeHtml(r.message || 'Unknown error')); - Swal.fire({ icon: 'warning', title: 'Partial Delete', html: `

    ${data.succeeded} succeeded, ${data.failed} failed.

    ${failedItems.join('
    ')}

    ` }); - } - } catch (e) { - Swal.fire({ icon: 'error', title: 'Bulk Delete Failed', text: e.response?.data?.message || e.message }); - } - setTimeout(() => showDnsManagementModal(preselectedInfraId), 2100); - }); - } - - // Attach delete handlers - resultDiv.querySelectorAll('.dns-delete-btn').forEach(btn => { - btn.addEventListener('click', async function() { - const recName = this.dataset.name.replace(/\.$/, ''); - const recType = this.dataset.type; - const setId = this.dataset.setid; - const confirmMsg = setId - ? `Delete record "${recName}" (${recType}, id: ${setId})?` - : `Delete ALL "${recName}" (${recType}) records?`; - - const confirmed = await Swal.fire({ - title: 'Confirm Delete', - text: confirmMsg, - icon: 'warning', - showCancelButton: true, - confirmButtonColor: '#d33', - confirmButtonText: 'Delete' - }); - if (!confirmed.isConfirmed) return; - - try { - const delBody = { domainName: domain, recordName: recName, recordType: recType }; - if (setId) delBody.setIdentifier = setId; - await axios.delete(`${tbApiBase()}/resources/globalDns/record`, { - data: delBody, - auth: { username: config.username, password: config.password }, - timeout: 30000 - }); - Swal.fire({ icon: 'success', title: 'Deleted', text: 'Record deleted successfully.', timer: 2000, showConfirmButton: false }); - // Re-open the DNS modal to refresh - setTimeout(() => showDnsManagementModal(preselectedInfraId), 2100); - } catch (err) { - Swal.fire({ icon: 'error', title: 'Delete Failed', text: err.response?.data?.message || err.message }); - } - }); - }); - } - } catch (err) { - resultDiv.innerHTML = `
    Error: ${window.escapeHtml(err.response?.data?.message || err.message)}
    `; - } - }); - - // Upsert button handler - document.getElementById('dns-upsert-btn').addEventListener('click', async function() { - const domain = document.getElementById('dns-hosted-zone').value; - if (!domain) { - Swal.showValidationMessage('Please select a hosted zone first.'); - return; - } - const recordName = document.getElementById('dns-upsert-record').value.trim(); - const recordType = document.getElementById('dns-upsert-type').value; - const ttl = parseInt(document.getElementById('dns-upsert-ttl').value) || 300; - const routingPolicy = document.getElementById('dns-routing-policy').value; - const source = document.querySelector('input[name="dns-ip-source"]:checked')?.value; - - if (routingPolicy === 'geoproximity' && source === 'ips') { - Swal.showValidationMessage('Geoproximity routing requires Infra or Label source (for Node location data).'); - return; - } - - const body = { - domainName: domain, - recordName: recordName || domain, - recordType: recordType, - ttl: ttl, - routingPolicy: routingPolicy, - setBy: {} - }; - - if (source === 'infra') { - const nsId = document.getElementById('dns-infra-nsid').value.trim(); - const infraId = document.getElementById('dns-infra-id').value; - if (!nsId || !infraId) { - Swal.showValidationMessage('Namespace and Infra are required.'); - return; - } - // If label filter is set, use label source; otherwise use infra source - const labelSelector = document.getElementById('labelSelector').value.trim(); - if (labelSelector) { - // Prepend sys.infraId to scope label query within the selected Infra - const scopedSelector = 'sys.infraId=' + infraId + ',' + labelSelector; - body.setBy.label = { nsId, labelSelector: scopedSelector }; - } else { - body.setBy.infra = { nsId, infraId }; - } - } else { - const ipsStr = document.getElementById('dns-manual-ips').value.trim(); - if (!ipsStr) { - Swal.showValidationMessage('At least one IP address is required.'); - return; - } - body.setBy.ips = ipsStr.split(',').map(ip => ip.trim()).filter(ip => ip); - } - - const resultDiv = document.getElementById('dns-upsert-result'); - resultDiv.style.display = 'block'; - resultDiv.innerHTML = '
    Updating DNS record...
    '; - this.disabled = true; - - try { - const url = `${tbApiBase()}/resources/globalDns/record`; - const resp = await axios.put(url, body, { auth: { username: config.username, password: config.password }, timeout: 60000 }); - - // After success, query the updated records to show detailed result - resultDiv.innerHTML = '
    Record updated. Fetching results...
    '; - try { - await new Promise(resolve => setTimeout(resolve, 500)); // brief delay for Route53 consistency - let queryUrl = `${tbApiBase()}/resources/globalDns/record?domainName=${encodeURIComponent(domain)}`; - if (recordName) queryUrl += `&recordName=${encodeURIComponent(recordName)}`; - console.log('[DNS] Auto-query URL:', queryUrl); - const qResp = await axios.get(queryUrl, { auth: { username: config.username, password: config.password }, timeout: 30000 }); - console.log('[DNS] Auto-query response:', JSON.stringify(qResp.data).substring(0, 500)); - const records = (qResp.data?.record || []).filter(r => r.type !== 'NS' && r.type !== 'SOA'); - console.log('[DNS] Filtered records count:', records.length); - if (records.length > 0) { - let html = '
    ✅ DNS record updated successfully.
    '; - html += '
    📋 Current records for ' + (recordName ? recordName + '.' + domain : domain) + ':
    '; - html += ''; - html += ''; - records.forEach(r => { - const esc = window.escapeHtml; - const policyBadge = r.routingPolicy === 'geoproximity' - ? `geo ${esc(r.geoLatitude || '')},${esc(r.geoLongitude || '')}` - : r.routingPolicy === 'weighted' - ? `weighted` - : `${esc(r.routingPolicy || 'simple')}`; - const setIdInfo = r.setIdentifier ? `
    id: ${esc(r.setIdentifier)}` : ''; - html += ` - - - - - - `; - }); - html += '
    NameTypeTTLValuesPolicy
    ${esc(r.name)}${setIdInfo}${esc(r.type)}${r.ttl}${(r.values || []).map(v => esc(v)).join(', ')}${policyBadge}
    '; - resultDiv.innerHTML = html; - } else { - resultDiv.innerHTML = `
    ✅ ${resp.data?.message || 'DNS record updated successfully.'}
    `; - } - } catch (qErr) { - // Query failed but upsert succeeded - console.warn('[DNS] Auto-query after upsert failed:', qErr); - resultDiv.innerHTML = `
    ✅ ${resp.data?.message || 'DNS record updated successfully.'}
    `; - } - } catch (err) { - resultDiv.innerHTML = `
    ❌ Error: ${window.escapeHtml(err.response?.data?.message || err.message)}
    `; - } finally { - this.disabled = false; - } - }); - } - }); -} -window.showDnsManagementModal = showDnsManagementModal; - -// ============================================================ -// Nginx Gateway (Load Balancer) -// ============================================================ -async function showGatewayModal(preselectedInfraId) { - const cfg = getConfig(); - const ns = configNamespace || 'default'; - const auth = { username: cfg.username, password: cfg.password }; - const base = `${tbApiBase()}`; - - let infraList = []; - try { - const r = await axios.get(`${base}/ns/${ns}/infra?option=id`, { auth, timeout: 15000 }); - infraList = r.data.output || []; - } catch(e) { - errorAlert(e.response?.data?.message || e.message || 'Failed to load Infra list'); - return; - } - - const makeOpts = (selected) => infraList.map(id => - `` - ).join(''); - - const result = await Swal.fire({ - title: '🔀 Nginx Gateway (Load Balancer)', - width: 700, - html: `${POPUP_STYLES} - `, - showCancelButton: true, - confirmButtonText: '🚀 Deploy nginx', - didOpen: () => { - document.querySelectorAll('input[name="gw-src"]').forEach(r => - r.addEventListener('change', function() { - document.getElementById('gw-src-infra').style.display = this.value === 'infra' ? '' : 'none'; - document.getElementById('gw-src-label').style.display = this.value === 'label' ? '' : 'none'; - document.getElementById('gw-src-ips').style.display = this.value === 'ips' ? '' : 'none'; - }) - ); - }, - preConfirm: async () => { - const gwInfraId = document.getElementById('gw-infra').value; - const nodeId = document.getElementById('gw-node').value.trim(); - const src = document.querySelector('input[name="gw-src"]:checked').value; - const lport = document.getElementById('gw-lport').value || '80'; - const bport = document.getElementById('gw-bport').value || '9120'; - if (!gwInfraId) { Swal.showValidationMessage('Select a Gateway Infra.'); return false; } - - const isValidPort = v => /^\d+$/.test(v) && +v >= 1 && +v <= 65535; - if (!isValidPort(lport)) { Swal.showValidationMessage('Listener port must be a number between 1 and 65535.'); return false; } - if (!isValidPort(bport)) { Swal.showValidationMessage('Backend port must be a number between 1 and 65535.'); return false; } - - const isValidIP = v => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) || /^[0-9a-fA-F:]+$/.test(v); - - let backends; - if (src === 'ips') { - const v = document.getElementById('gw-ips').value.trim(); - if (!v) { Swal.showValidationMessage('Enter backend IPs.'); return false; } - const ipList = v.split(/[\s,]+/).filter(Boolean); - const bad = ipList.find(ip => !isValidIP(ip)); - if (bad) { Swal.showValidationMessage(`Invalid IP address: ${bad}`); return false; } - backends = ipList.join(' '); - } else if (src === 'label') { - const v = document.getElementById('gw-label').value.trim(); - if (!v) { Swal.showValidationMessage('Enter a label selector.'); return false; } - backends = `$$Func(GetPublicIPs(separator=' ', label='${v}'))`; - } else { - // Infra source — resolve IPs from the (possibly different) backend infra - const bkInfraId = document.getElementById('gw-backend-infra').value; - if (!bkInfraId) { Swal.showValidationMessage('Select a Backend Infra.'); return false; } - try { - const r = await axios.get(`${base}/ns/${ns}/infra/${bkInfraId}`, { auth }); - const ips = (r.data.node || []).map(nd => nd.publicIP).filter(Boolean); - if (!ips.length) { Swal.showValidationMessage('No public IPs found in the selected Backend Infra.'); return false; } - backends = ips.join(' '); - } catch(e) { - Swal.showValidationMessage('Failed to fetch backend IPs: ' + (e.message || '')); return false; - } - } - return { gwInfraId, nodeId, backends, lport, bport }; - } - }); - - if (!result.isConfirmed || !result.value) return; - const { gwInfraId, nodeId, backends, lport, bport } = result.value; - - // Cookie-based sticky session: each browser gets a unique cookie on first visit. - // Works correctly behind corporate NAT (unlike ip_hash which fails when all - // users share the same external IP). The map directive assigns $request_id as - // the initial cookie value; subsequent requests reuse the existing cookie value, - // ensuring consistent backend routing per browser session. - // WebSocket headers are included for long-lived connections. - const deployCmd = - `UPSTREAMS=$(echo "${backends}" | awk -v p=${bport} '{for(i=1;i<=NF;i++) printf " server %s:%s;\\n",$i,p}') && ` + - `printf 'map $cookie_gw_id $gw_uid {\\n default "$request_id";\\n "~." "$cookie_gw_id";\\n}\\nupstream cb_gw {\\n hash $gw_uid consistent;\\n%s\\n}\\nserver {\\n listen ${lport};\\n location / {\\n add_header Set-Cookie "gw_id=$gw_uid; Path=/; Max-Age=31536000; SameSite=Lax" always;\\n proxy_pass http://cb_gw;\\n proxy_http_version 1.1;\\n proxy_set_header Upgrade $http_upgrade;\\n proxy_set_header Connection "upgrade";\\n proxy_set_header Host $host;\\n proxy_set_header X-Real-IP $remote_addr;\\n proxy_read_timeout 3600s;\\n proxy_send_timeout 3600s;\\n }\\n}\\n' "$UPSTREAMS" | sudo tee /etc/nginx/conf.d/cb-gateway.conf > /dev/null && ` + - `sudo rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf 2>/dev/null; ` + - `sudo nginx -t && sudo systemctl enable nginx && sudo systemctl restart nginx && ` + - `echo "✅ Gateway :${lport} → ${backends} (cookie sticky, WebSocket ready)"` ; - - let url = `${base}/ns/${ns}/cmd/infra/${gwInfraId}`; - if (nodeId) url += `?nodeId=${encodeURIComponent(nodeId)}`; - - try { - const r = await axios.post(url, { - command: [ - `sudo apt-get install -y nginx -q 2>/dev/null || sudo yum install -y nginx -q 2>/dev/null; true`, - deployCmd - ], - timeoutMinutes: 10 - }, { auth }); - showRemoteCmdResult(r.data, null, gwInfraId); - } catch(e) { - errorAlert(e.response?.data?.message || e.message || 'Deploy failed'); - } -} -window.showGatewayModal = showGatewayModal; - -// ═══════════════════════════════════════════════════════════════════════════════ -// REGISTER A CSP AT RUNTIME (self-hosted OpenStack, etc.) -// -// Makes a cloud that did not exist when CB-Tumblebug started usable without editing -// assets/cloudinfo.yaml, re-running `make init`, or restarting the server. The driving -// case is an OpenStack that CB-Tumblebug itself deployed onto a VM it created. -// -// Three API calls, in this order — the order matters: -// 1. POST /cloudInfo/{provider} define the provider (driver, regions, zones) -// 2. POST /credential authenticate; creates one connection per region -// 3. GET /loadAssets?providers={provider} pull ONLY this provider's specs and images -// -// Skipping (3) leaves a verified connection whose catalog is empty, so no spec or image -// can be chosen and no VM can be created on it. Scoping it to one provider is what keeps -// it interactive: a DevStack finishes in seconds, against 10-40 minutes for a full load. -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Parse the two YAML snippets that scripts/usecases/openstack/1.installDevStack.sh (and - * 2.getRegistrationInfo.sh) print at the end of a run. - * - * Deliberately line-based rather than a YAML parse: the text is pasted from a terminal, so - * it arrives with banner lines, box-drawing characters and inconsistent leading whitespace - * that a strict parser would reject. Every field is optional here — whatever is found - * pre-fills the form and the operator can correct the rest. - */ -function parseCspRegistrationSnippet(text) { - const out = { - provider: '', driver: '', cloudPlatform: '', description: '', - region: '', zone: '', display: '', latitude: '', longitude: '', - identityEndpoint: '', username: '', password: '', domainName: '', projectId: '', - }; - if (!text) return out; - - const grab = (re) => { const m = text.match(re); return m ? m[1].trim() : ''; }; - - // credentials.yaml block - out.identityEndpoint = grab(/^\s*IdentityEndpoint:\s*(\S+)/mi); - out.username = grab(/^\s*Username:\s*(\S+)/mi); - out.password = grab(/^\s*Password:\s*(\S+)/mi); - out.domainName = grab(/^\s*DomainName:\s*(\S+)/mi); - out.projectId = grab(/^\s*ProjectID:\s*(\S+)/mi); - - // cloudinfo.yaml block - out.driver = grab(/^\s*driver:\s*(\S+)/mi); - out.cloudPlatform = grab(/^\s*cloudPlatform:\s*(\S+)/mi); - out.description = grab(/^\s*description:\s*(.+)$/mi); - out.display = grab(/^\s*display:\s*(.+)$/mi); - out.latitude = grab(/^\s*latitude:\s*(-?[\d.]+)/mi); - out.longitude = grab(/^\s*longitude:\s*(-?[\d.]+)/mi); - out.region = grab(/^\s*id:\s*(\S+)/mi); // region id, e.g. RegionOne - out.zone = grab(/^\s*-\s+(\S+)\s*$/m); // first list item under zone: - - // The provider name is the key of both blocks: a line ending in ':' whose value block - // carries IdentityEndpoint or driver. Take the first such key. - const keyed = text.match(/^\s{2,}([A-Za-z][-A-Za-z0-9+]*):\s*$/gm) || []; - for (const line of keyed) { - const name = line.trim().replace(/:$/, ''); - if (name && !['region', 'location', 'zone'].includes(name)) { out.provider = name; break; } - } - return out; -} -window.parseCspRegistrationSnippet = parseCspRegistrationSnippet; - -async function showRegisterCspModal() { - // Yield once before opening. Callers in the context menus historically follow the - // handler with Swal.close(); firing synchronously would open this dialog and have that - // very call close it again. Deferring puts our fire() after any such close. - await new Promise((r) => setTimeout(r, 0)); - - const cfg = getConfig(); - const auth = { username: cfg.username, password: cfg.password }; - const base = tbApiBase(); - const esc = (v) => window.escapeHtml(String(v == null ? '' : v)); - - const field = (id, label, ph, hint) => ` - `; - - const result = await Swal.fire({ - title: '☁️ Register a CSP (runtime)', - width: 820, - html: `${POPUP_STYLES} -
    - Registers a cloud that was not present when CB-Tumblebug started — no file edit, - no restart. Paste the output of the DevStack Get Registration Info step and - the fields below fill themselves. -
    - -
    -
    - ${field('cspProvider','Provider name *','openstack-host-openstack','Becomes the provider in connection names')} - ${field('cspDriver','Driver *','openstack-driver-v1.0.so','')} - ${field('cspPlatform','Base platform *','openstack','Which driver CB-Spider loads. Required for a derived CSP')} - ${field('cspDescription','Description','DevStack on 1.2.3.4','')} - ${field('cspRegion','Region *','RegionOne','')} - ${field('cspZone','Zone *','nova','')} - ${field('cspDisplay','Location label','Europe (Milan)','Where it appears on the map')} - ${field('cspLat','Latitude','45.4','')} - ${field('cspLon','Longitude','9.1','')} - ${field('cspEndpoint','IdentityEndpoint *','http://1.2.3.4/identity/v3','')} - ${field('cspUser','Username *','admin','')} - ${field('cspPassword','Password *','','')} - ${field('cspDomain','DomainName','Default','')} - ${field('cspProject','ProjectID *','','')} -
    - `, - focusConfirm: false, - showCancelButton: true, - confirmButtonText: 'Register', - cancelButtonText: 'Cancel', - didOpen: () => { - document.getElementById('cspParseBtn').addEventListener('click', () => { - const parsed = parseCspRegistrationSnippet(document.getElementById('cspSnippet').value); - const map = { - cspProvider: parsed.provider, cspDriver: parsed.driver, cspPlatform: parsed.cloudPlatform, - cspDescription: parsed.description, cspRegion: parsed.region, cspZone: parsed.zone, - cspDisplay: parsed.display, cspLat: parsed.latitude, cspLon: parsed.longitude, - cspEndpoint: parsed.identityEndpoint, cspUser: parsed.username, - cspPassword: parsed.password, cspDomain: parsed.domainName, cspProject: parsed.projectId, - }; - let filled = 0; - Object.entries(map).forEach(([id, val]) => { - if (val) { document.getElementById(id).value = val; filled++; } - }); - // A DevStack is an instance of the openstack platform; infer it when the snippet - // omits cloudPlatform but the driver makes it obvious. - const plat = document.getElementById('cspPlatform'); - if (!plat.value && /openstack/i.test(document.getElementById('cspDriver').value)) { - plat.value = 'openstack'; filled++; - } - document.getElementById('cspParseMsg').textContent = - filled ? `Filled ${filled} field(s) — check them before registering.` : 'Nothing recognised in that text.'; - document.getElementById('cspParseMsg').style.color = filled ? '#2e7d32' : '#c62828'; - }); - }, - preConfirm: () => { - const val = (id) => (document.getElementById(id).value || '').trim(); - const req = { provider: val('cspProvider'), driver: val('cspDriver'), platform: val('cspPlatform'), - region: val('cspRegion'), zone: val('cspZone'), endpoint: val('cspEndpoint'), - user: val('cspUser'), password: val('cspPassword'), project: val('cspProject') }; - const missing = Object.entries(req).filter(([, v]) => !v).map(([k]) => k); - if (missing.length) { Swal.showValidationMessage(`Missing required: ${missing.join(', ')}`); return false; } - return { - ...req, - description: val('cspDescription'), display: val('cspDisplay'), - lat: parseFloat(val('cspLat')) || 0, lon: parseFloat(val('cspLon')) || 0, - domain: val('cspDomain') || 'Default', - fetchAssets: document.getElementById('cspFetchAssets').checked, - }; - }, - }); - - if (!result.value) return; - const v = result.value; - - const step = (n, total, text) => Swal.update({ - title: `Registering ${esc(v.provider)}`, - html: `
    Step ${n}/${total} — ${esc(text)}
    `, - showConfirmButton: false, showCancelButton: false, - }); - const total = v.fetchAssets ? 3 : 2; - Swal.fire({ title: 'Registering…', allowOutsideClick: false, didOpen: () => Swal.showLoading() }); - - try { - // 1 — define the provider - step(1, total, 'defining the provider'); - await axios.post(`${base}/cloudInfo/${encodeURIComponent(v.provider)}`, { - description: v.description || `Registered from CB-MapUI`, - cloudPlatform: v.platform, - driver: v.driver, - regions: { [v.region]: { - regionId: v.region, zones: [v.zone], - location: { display: v.display || v.region, latitude: v.lat, longitude: v.lon }, - } }, - }, { auth, timeout: 60000 }); - - // 2 — credentials. The server hands out a short-lived RSA key; each value is - // AES-encrypted and the AES key travels RSA-wrapped, so nothing is sent in the clear. - step(2, total, 'registering credentials and verifying the connection'); - const keyRes = await axios.get(`${base}/credential/publicKey`, { auth, timeout: 30000 }); - const { publicKey, publicKeyTokenId } = keyRes.data || {}; - if (!publicKey || !publicKeyTokenId) throw new Error('Could not obtain the credential encryption key'); - - const enc = await encryptCredentialValues(publicKey, { - IdentityEndpoint: v.endpoint, Username: v.user, Password: v.password, - DomainName: v.domain, ProjectID: v.project, - }); - const credRes = await axios.post(`${base}/credential`, { - credentialHolder: 'admin', - providerName: v.provider, - credentialKeyValueList: Object.entries(enc.values).map(([key, value]) => ({ key, value })), - publicKeyTokenId, - encryptedClientAesKeyByPublicKey: enc.aesKey, - }, { auth, timeout: 900000 }); - - const conns = (credRes.data?.allConnections?.connectionconfig || []) - .filter(c => (c.providerName || '').toLowerCase() === v.provider.toLowerCase()); - const verified = conns.filter(c => c.verified); - - // 3 — assets, scoped to this provider only - let assetNote = ''; - if (v.fetchAssets) { - if (!verified.length) { - assetNote = 'Skipped the asset fetch: no connection verified, and assets are only ' - + 'fetched from verified connections.'; - } else { - step(3, total, 'fetching specs and images for this provider'); - await axios.get(`${base}/loadAssets`, { - params: { providers: v.provider }, auth, timeout: 1800000, - }); - assetNote = 'Specs and images fetched for this provider.'; - } - } - - const rows = conns.map(c => - `${esc(c.configName)}` - + `${c.verified ? '✅ verified' : '❌ unverified'}` - + `${esc(c.verifiedMessage || '')}` - ).join(''); - - await Swal.fire({ - icon: verified.length ? 'success' : 'warning', - title: verified.length ? 'CSP registered' : 'Registered, but not verified', - width: 720, - html: `
    -

    ${esc(v.provider)} is now a provider — no restart needed.

    - ${rows}
    - ${assetNote ? `

    ${esc(assetNote)}

    ` : ''} - ${verified.length - ? '

    You can now create an Infra on this provider.

    ' - : '

    Read the message above. For a self-hosted ' - + 'OpenStack the usual cause is the API port (80) being closed in the security group ' - + 'of the VM hosting it.

    '} -
    `, - }); - if (typeof getConnection === 'function') { try { getConnection(); } catch (e) {} } - } catch (e) { - errorAlert(e.response?.data?.message || e.message || 'Registration failed'); - } -} -window.showRegisterCspModal = showRegisterCspModal; - - -/** - * Wrap a PKCS#1 RSAPublicKey DER in the SubjectPublicKeyInfo structure WebCrypto expects: - * - * SEQUENCE { - * SEQUENCE { OID rsaEncryption, NULL } - * BIT STRING { } - * } - */ -function pkcs1ToSpki(pkcs1) { - // DER length bytes: short form below 128, else 0x80|n followed by n big-endian bytes. - const derLength = (n) => { - if (n < 0x80) return [n]; - const bytes = []; - for (let v = n; v > 0; v >>= 8) bytes.unshift(v & 0xff); - return [0x80 | bytes.length, ...bytes]; - }; - // AlgorithmIdentifier for rsaEncryption (1.2.840.113549.1.1.1) with NULL parameters - const algorithm = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, - 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]; - // BIT STRING with a leading 0x00 "unused bits" octet - const bitString = [0x03, ...derLength(pkcs1.length + 1), 0x00, ...pkcs1]; - const body = [...algorithm, ...bitString]; - return new Uint8Array([0x30, ...derLength(body.length), ...body]); -} - /** - * Encrypt credential values the way POST /credential expects: each value under AES-256-CBC - * with a fresh IV prepended, and that AES key RSA-OAEP(SHA-256) wrapped with the server's - * public key. Uses WebCrypto, so nothing extra has to be bundled. + * cb-mapui entry point proxy + * All implementation has been modularized into src/ */ -async function encryptCredentialValues(publicKeyPem, credential) { - const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); - const pemBody = publicKeyPem.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, ''); - let der = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0)); - - // CB-Tumblebug emits PKCS#1 ("BEGIN RSA PUBLIC KEY", x509.MarshalPKCS1PublicKey), but - // WebCrypto imports RSA public keys as SPKI only. Wrap the PKCS#1 body in the SPKI - // structure rather than asking the server to change format, so this keeps working - // against existing deployments. - if (/BEGIN RSA PUBLIC KEY/.test(publicKeyPem)) der = pkcs1ToSpki(der); - - const rsaKey = await crypto.subtle.importKey( - 'spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']); - - const aesRaw = crypto.getRandomValues(new Uint8Array(32)); - const aesKey = await crypto.subtle.importKey('raw', aesRaw, { name: 'AES-CBC' }, false, ['encrypt']); - - const values = {}; - for (const [name, value] of Object.entries(credential)) { - const iv = crypto.getRandomValues(new Uint8Array(16)); - const ct = await crypto.subtle.encrypt( - { name: 'AES-CBC', iv }, aesKey, new TextEncoder().encode(String(value))); - // The server splits the IV back off the front, so order matters. - const joined = new Uint8Array(iv.length + ct.byteLength); - joined.set(iv, 0); joined.set(new Uint8Array(ct), iv.length); - values[name] = b64(joined); - } - const wrapped = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, rsaKey, aesRaw); - return { values, aesKey: b64(wrapped) }; -} - - -// ═══════════════════════════════════════════════════════════════════════════════ -// AUTOPILOT PROVISION -// API endpoints used: -// POST /ns/{nsId}/infraAutopilotReview — pre-flight validation -// POST /ns/{nsId}/infraAutopilot — actual provision (synchronous, long-running) -// GET /ns/{nsId}/infraAutopilot/{id}/status — polling during provision -// ═══════════════════════════════════════════════════════════════════════════════ - -var _apNodeSpecCounter = 0; -var _apActiveNodeSpecIds = []; -var _apPollingTimer = null; -var _apPollingStop = false; -var _apElapsedTimer = null; -var _apCurrentReq = null; - -// ── Entry point ─── -function showAutopilotDialog() { - // Reset state - _apNodeSpecCounter = 0; - _apActiveNodeSpecIds = []; - _apCurrentReq = null; - - // Reset to form panel - _apShowFormPanel(); - - // Clear node spec container and add a first card - var container = document.getElementById('apNodeSpecContainer'); - if (container) container.innerHTML = ''; - addAutopilotNodeSpec(); - - // Pre-fill infra name with editable random name (same pattern as infraDynamic) - var nameEl = document.getElementById('apInfraName'); - if (nameEl) nameEl.value = 'ap-' + generateInfraName(); - - var labelEl = document.getElementById('apInfraLabel'); - if (labelEl) labelEl.value = ''; - var postCmdEl = document.getElementById('apPostCommand'); - if (postCmdEl) postCmdEl.value = ''; - var postCmdTimeoutEl = document.getElementById('apPostCmdTimeout'); - if (postCmdTimeoutEl) postCmdTimeoutEl.value = ''; - - $('#autopilotModal').modal('show'); -} - -// ── NodeSpec card management ── -function addAutopilotNodeSpec() { - var id = ++_apNodeSpecCounter; - _apActiveNodeSpecIds.push(id); - var container = document.getElementById('apNodeSpecContainer'); - if (container) { - container.insertAdjacentHTML('beforeend', _apNodeSpecCardHtml(id)); - } -} - -function removeAutopilotNodeSpec(id) { - _apActiveNodeSpecIds = _apActiveNodeSpecIds.filter(function(x) { return x !== id; }); - var card = document.getElementById('apNsCard_' + id); - if (card) card.remove(); -} - -function _apNodeSpecCardHtml(id) { - var inputStyle = 'background:#0d1117;border-color:#30363d;color:#e6edf3'; - var labelStyle = 'color:#7d8590;font-size:10px;text-transform:uppercase;letter-spacing:.5px'; - var sectionHeaderStyle = 'font-size:10px;color:#7d8590;text-transform:uppercase;letter-spacing:1px;border-top:1px solid #21262d;padding-top:6px;margin:6px 0 4px'; - return ` -
    -
    -
    -
    - ⬡ Node Type ${id} - -
    - -
    -
    -
    - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    Spec Filter → specFilter
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    Image → imageRequirement
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    -
    `; -} - -// ── Build InfraAutopilotReq from form ─ -function buildAutopilotReq() { - var req = { - name: (document.getElementById('apInfraName').value || '').trim(), - description: (document.getElementById('apDescription').value || '').trim() || undefined, - nodeSpecs: [], - policy: { - maxAttemptsPerSpec: parseInt(document.getElementById('apMaxAttempts').value) || 10, - timeoutMinutes: parseInt(document.getElementById('apTimeoutMin').value) || 60, - parallelism: parseInt(document.getElementById('apParallelism').value) || 2, - onPartialFailure: document.getElementById('apOnPartialFailure').value || 'refine', - } - }; - - // Infra-level labels: "key=value, key2=value2" → map - var infraLabels = parseLabelsString(document.getElementById('apInfraLabel').value || ''); - if (Object.keys(infraLabels).length > 0) req.label = infraLabels; - - // Post-deployment commands: one per line; executed once after all NodeGroups complete - var postCmdText = (document.getElementById('apPostCommand').value || '').trim(); - if (postCmdText) { - var commands = postCmdText.split('\n').map(function(s) { return s.trim(); }).filter(Boolean); - if (commands.length > 0) { - var phase = { command: commands }; - var cmdTimeout = parseInt(document.getElementById('apPostCmdTimeout').value); - if (cmdTimeout > 0) phase.timeoutMinutes = Math.min(cmdTimeout, 120); - req.postCommands = [phase]; - } - } - - _apActiveNodeSpecIds.forEach(function(id) { - var filterPolicy = []; - - _apGetSelectedProviders(id).forEach(function(p) { - filterPolicy.push({ metric: 'providerName', condition: [{ operator: '==', operand: p }] }); - }); - var vcpu = document.getElementById('apNs_' + id + '_vcpu').value; - if (vcpu) { - filterPolicy.push({ metric: 'vCPU', condition: [{ operator: '>=', operand: vcpu }] }); - } - var memory = document.getElementById('apNs_' + id + '_memory').value; - if (memory) { - filterPolicy.push({ metric: 'memoryGiB', condition: [{ operator: '>=', operand: memory }] }); - } - var gpuModel = document.getElementById('apNs_' + id + '_gpuModel').value; - if (gpuModel === 'any') { - filterPolicy.push({ metric: 'acceleratorCount', condition: [{ operator: '>=', operand: '1' }] }); - } else if (gpuModel) { - filterPolicy.push({ metric: 'acceleratorModel', condition: [{ operator: '==', operand: gpuModel }] }); - } - var gpuCount = document.getElementById('apNs_' + id + '_gpuCount').value; - if (gpuCount && gpuModel) { - filterPolicy.push({ metric: 'acceleratorCount', condition: [{ operator: '>=', operand: gpuCount }] }); - } - var vram = document.getElementById('apNs_' + id + '_vram').value; - if (vram) { - filterPolicy.push({ metric: 'acceleratorMemoryGB', condition: [{ operator: '>=', operand: vram }] }); - } - var arch = document.getElementById('apNs_' + id + '_arch').value; - if (arch) { - filterPolicy.push({ metric: 'architecture', condition: [{ operator: '==', operand: arch }] }); - } - - var isGPUImageVal = document.getElementById('apNs_' + id + '_isGPUImage').value; - var isGPUImage; - if (isGPUImageVal === 'auto') { - isGPUImage = gpuModel ? true : undefined; - } else { - isGPUImage = (isGPUImageVal === 'true'); - } - - var nodeSpec = { - name: (document.getElementById('apNs_' + id + '_name').value || ('node-type-' + id)).trim(), - desiredCount: parseInt(document.getElementById('apNs_' + id + '_desiredCount').value) || 1, - specFilter: { - filter: { policy: filterPolicy }, - priority: { policy: [{ metric: 'location', weight: 1.0 }] }, - limit: 0 - }, - imageRequirement: { - osType: document.getElementById('apNs_' + id + '_osType').value, - osVersion: (document.getElementById('apNs_' + id + '_osVersion').value || '').trim() || undefined, - } - }; - - if (isGPUImage !== undefined) nodeSpec.imageRequirement.isGPUImage = isGPUImage; - - var minCount = document.getElementById('apNs_' + id + '_minCount').value; - if (minCount) nodeSpec.minCount = parseInt(minCount); - - var maxPerLoc = document.getElementById('apNs_' + id + '_maxPerLocation').value; - if (maxPerLoc) nodeSpec.maxPerLocation = parseInt(maxPerLoc); - - var strategy = document.getElementById('apNs_' + id + '_strategy').value; - if (strategy) nodeSpec.placementPolicy = { strategy: strategy }; - - var diskSize = document.getElementById('apNs_' + id + '_diskSize').value; - if (diskSize) nodeSpec.rootDiskSize = parseInt(diskSize); - - var nsLabels = parseLabelsString(document.getElementById('apNs_' + id + '_label').value || ''); - if (Object.keys(nsLabels).length > 0) nodeSpec.label = nsLabels; - - req.nodeSpecs.push(nodeSpec); - }); - - return req; -} - -// ── Review step ─── -function submitAutopilotReview() { - if (!configNamespace) { - Swal.fire({ icon: 'error', title: 'No namespace', text: 'Please select a namespace in settings.' }); - return; - } - - var req = buildAutopilotReq(); - if (!req.name) { - Swal.fire({ icon: 'warning', title: 'Infra name required', text: 'Please enter an infra name.' }); - return; - } - if (req.nodeSpecs.length === 0) { - Swal.fire({ icon: 'warning', title: 'No node types defined', text: 'Please add at least one node type.' }); - return; - } - - _apCurrentReq = req; - - // Show loading state in review panel - _apShowReviewPanel('
    ' + - '

    Reviewing candidates for ' + - req.name + '...

    '); - document.getElementById('apProvisionNowBtn').disabled = true; - - var url = tbApiBase() + '/ns/' + configNamespace + '/infraAutopilotReview'; - var requestId = generateRandomRequestId('ap-review-', 10); - - axios({ - method: 'post', - url: url, - headers: { 'Content-Type': 'application/json', 'x-request-id': requestId }, - data: JSON.stringify(req), - auth: { username: configUsername, password: configPassword } - }) - .then(function(res) { - renderAutopilotReview(res.data, req); - }) - .catch(function(err) { - var msg = err.response ? JSON.stringify(err.response.data, null, 2) : err.message; - _apShowReviewPanel('
    Review failed
    ' + msg + '
    '); - document.getElementById('apReviewFooter').style.display = ''; - }); -} - -function renderAutopilotReview(reviewResult, req) { - var summary = reviewResult.summary || {}; - var reviews = reviewResult.reviews || []; - - var fText = (summary.feasibility || '').toLowerCase(); - var feasBadge = fText === 'feasible' - ? 'FEASIBLE' - : fText === 'partial' - ? 'PARTIAL' - : 'INFEASIBLE'; - - var html = '
    ' + - '
    ' + (reviewResult.name || req.name) + '' + - '' + (summary.desiredTotal || 0) + ' nodes total · ' + - (summary.validCandidates || 0) + ' valid candidates
    ' + - feasBadge + '
    '; - - // Show labels / post-commands from the request so users can confirm before provisioning. - var reqMeta = []; - if (req.label && Object.keys(req.label).length > 0) { - reqMeta.push('🏷 ' + window.escapeHtml(labelsToString(req.label))); - } - var apPhase = (req.postCommands && req.postCommands[0]) || null; - if (apPhase && apPhase.command && apPhase.command.length > 0) { - reqMeta.push('⚡ ' + apPhase.command.length + ' post-command(s) after all nodes ready' + - (apPhase.timeoutMinutes ? ', timeout ' + apPhase.timeoutMinutes + 'min' : '')); - } - if (reqMeta.length > 0) { - html += '
    ' + - reqMeta.join('  ·  ') + '
    '; - } - - reviews.forEach(function(ns) { - var nsFeas = (ns.feasibility || '').toLowerCase(); - var nsBadge = nsFeas === 'feasible' - ? 'FEASIBLE' - : nsFeas === 'partial' - ? 'PARTIAL' - : 'INFEASIBLE'; - - html += '
    ' + - '
    ' + - '⬡ ' + ns.nodeSpecName + '' + - '
    ' + nsBadge + ' ' + ns.desiredCount + ' nodes · ' + - ns.validCandidates + ' valid · ' + (ns.expectedNodeGroups || 0) + ' NodeGroups expected
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - ''; - - (ns.candidates || []).forEach(function(c) { - var validIcon = c.isValid - ? '' - : ''; - var riskColor = c.riskLevel === 'Low' ? '#3fb950' : c.riskLevel === 'High' ? '#f85149' : '#f0a500'; - var note = (!c.isValid && c.invalidReasons) ? c.invalidReasons.join('; ') : ''; - var rowBg = !c.isValid ? 'background:rgba(248,81,73,.04)' : ''; - var gpuCell = c.acceleratorModel - ? '' + c.acceleratorModel + '' + - (c.acceleratorCount > 1 ? ' ×' + c.acceleratorCount + '' : '') - : (c.acceleratorType ? '' + c.acceleratorType + '' : '—'); - var vramCell = c.acceleratorMemoryGB - ? '' + c.acceleratorMemoryGB + ' GiB' - : '—'; - html += '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - }); - - var costLine = ns.validCandidates > 0 - ? 'Cost: $' + (ns.costPerHourMin || 0).toFixed(2) + - ' – $' + (ns.costPerHourMax || 0).toFixed(2) + '/hr per node' - : 'No valid candidates'; - - html += '
    ValidSpecLocationZoneGPUVRAMRisk$/hrNote
    ' + validIcon + '' + (c.specId || c.cspSpecName || '') + '' + (c.providerName || '') + '/' + (c.regionName || '') + '' + (c.suggestedZone || '—') + '' + gpuCell + '' + vramCell + '' + (c.riskLevel || '—') + '$' + ((c.costPerHour || 0).toFixed(3)) + '' + note + '
    ' + - '
    ' + costLine + '
    '; - }); - - if (summary.costPerHourMin != null && summary.desiredTotal > 0) { - var totalMin = ((summary.costPerHourMin || 0) * summary.desiredTotal).toFixed(2); - var totalMax = ((summary.costPerHourMax || 0) * summary.desiredTotal).toFixed(2); - html += '
    ' + - 'Total est.: $' + totalMin + ' – $' + totalMax + '/hr
    '; - } - - if (summary.unreachableSpecs && summary.unreachableSpecs.length > 0) { - html += '
    ' + - '⚠ Unreachable specs: ' + summary.unreachableSpecs.join(', ') + '
    '; - } - - _apShowReviewPanel(html); - - var canProvision = fText !== 'infeasible'; - var provBtn = document.getElementById('apProvisionNowBtn'); - if (provBtn) { - provBtn.disabled = !canProvision; - provBtn.style.opacity = canProvision ? '1' : '0.5'; - } -} - -function backToAutopilotForm() { - _apShowFormPanel(); -} - -function _apShowFormPanel() { - var formEl = document.getElementById('apFormContent'); - var reviewEl = document.getElementById('apReviewContent'); - var formFooter = document.getElementById('apFormFooter'); - var reviewFooter = document.getElementById('apReviewFooter'); - if (formEl) formEl.style.display = ''; - if (reviewEl) { reviewEl.style.display = 'none'; reviewEl.innerHTML = ''; } - if (formFooter) formFooter.style.display = ''; - if (reviewFooter) reviewFooter.style.cssText = 'display:none!important'; -} - -function _apShowReviewPanel(html) { - var formEl = document.getElementById('apFormContent'); - var reviewEl = document.getElementById('apReviewContent'); - var formFooter = document.getElementById('apFormFooter'); - var reviewFooter = document.getElementById('apReviewFooter'); - if (formEl) formEl.style.display = 'none'; - if (reviewEl) { reviewEl.innerHTML = html; reviewEl.style.display = ''; } - if (formFooter) formFooter.style.display = 'none'; - if (reviewFooter) { reviewFooter.style.cssText = 'display:flex!important;width:100%;justify-content:space-between;align-items:center'; } -} - -// ── Provision step ─ -function proceedWithAutopilotProvision() { - var req = _apCurrentReq; - if (!req) return; - - // Blur focused element before hiding modal to avoid aria-hidden warning - if (document.activeElement) document.activeElement.blur(); - $('#autopilotModal').modal('hide'); - - // Init and show progress panel after form modal hides - setTimeout(function() { - _apRenderProgressInit(req); - _apShowProgressPanel('Autopilot: ' + req.name); - }, 350); - - var url = tbApiBase() + '/ns/' + configNamespace + '/infraAutopilot'; - var requestId = generateRandomRequestId('autopilot-', 10); - var spinnerId = addSpinnerTask('Autopilot: ' + req.name); - _apPollingStop = false; - - // Start status polling every 5 seconds - _apPollingTimer = setInterval(function() { - if (_apPollingStop) { clearInterval(_apPollingTimer); return; } - _apPollStatus(req.name); - }, 5000); - - // Fire the long-running provision POST - axios({ - method: 'post', - url: url, - headers: { 'Content-Type': 'application/json', 'x-request-id': requestId }, - data: JSON.stringify(req), - auth: { username: configUsername, password: configPassword }, - timeout: ((req.policy.timeoutMinutes || 60) * 60 + 60) * 1000 - }) - .then(function(res) { - _apStopPolling(); - removeSpinnerTask(spinnerId); - _apHideProgressPanel(); - updateInfraList(); - setTimeout(function() { showAutopilotResult(res.data, req); }, 400); - }) - .catch(function(err) { - _apStopPolling(); - removeSpinnerTask(spinnerId); - _apHideProgressPanel(); - var msg = err.response ? JSON.stringify(err.response.data, null, 2) : err.message; - errorAlert('Autopilot failed: ' + msg); - }); -} - -function _apPollStatus(infraId) { - var url = tbApiBase() + '/ns/' + configNamespace + - '/infraAutopilot/' + infraId + '/status'; - axios({ - method: 'get', url: url, - auth: { username: configUsername, password: configPassword } - }) - .then(function(res) { - if (!_apPollingStop) _apRenderProgressUpdate(res.data); - }) - .catch(function() { - // Infra may not be in DB yet during early phase — silently ignore - }); -} - -function _apStopPolling() { - _apPollingStop = true; - if (_apPollingTimer) { clearInterval(_apPollingTimer); _apPollingTimer = null; } - if (_apElapsedTimer) { clearInterval(_apElapsedTimer); _apElapsedTimer = null; } -} - -// ── Progress display ── -function _apRenderProgressInit(req) { - var specsHtml = (req.nodeSpecs || []).map(function(ns) { - return '
    ' + - '
    ' + - '⬡ ' + ns.name + '' + - '0 / ' + ns.desiredCount + '
    ' + - '
    ' + - '
    ' + - '
    '; - }).join(''); - - var body = document.getElementById('autopilotProgressBody'); - if (!body) return; - body.innerHTML = - '
    ' + - 'Elapsed: 0s' + - 'Initializing...
    ' + - '
    ' + specsHtml + '
    ' + - '
    ' + - '
    Attempt Log
    ' + - '
    ' + - 'Waiting for first attempt...
    '; - - // Elapsed timer (local clock, falls back when status arrives) - var startTime = Date.now(); - _apElapsedTimer = setInterval(function() { - var el = document.getElementById('apElapsedDisplay'); - if (el) el.textContent = Math.floor((Date.now() - startTime) / 1000) + 's'; - }, 1000); -} - -function _apRenderProgressUpdate(status) { - // Elapsed (server value overrides local clock) - var elEl = document.getElementById('apElapsedDisplay'); - if (elEl && status.elapsedSeconds !== undefined) elEl.textContent = status.elapsedSeconds + 's'; - - var specs = status.specs || []; - var totalRunning = 0, totalDesired = 0, anyActiveAttempt = false; - - // Update panel header badge with aggregate progress (visible even when minimized) - var badgeEl = document.getElementById('apProgressPanelBadge'); - if (badgeEl) { - var bTotal = (specs || []).reduce(function(a, s) { return a + (s.desiredCount || 0); }, 0); - var bRunning = (specs || []).reduce(function(a, s) { return a + (s.provisionedCount || 0); }, 0); - if (bTotal > 0) { - badgeEl.textContent = bRunning + '/' + bTotal + ' nodes'; - badgeEl.style.color = bRunning >= bTotal ? '#3fb950' : '#f0a500'; - } - } - - // Per-spec progress - specs.forEach(function(spec) { - totalRunning += spec.provisionedCount || 0; - totalDesired += spec.desiredCount || 0; - - var pct = spec.desiredCount > 0 ? Math.round((spec.provisionedCount / spec.desiredCount) * 100) : 0; - var pBar = document.getElementById('apNsProgressBar_' + spec.nodeSpecName); - var pCount = document.getElementById('apNsProgressCount_' + spec.nodeSpecName); - var pStat = document.getElementById('apNsProgressStatus_' + spec.nodeSpecName); - if (pBar) { pBar.style.width = pct + '%'; pBar.style.background = pct >= 100 ? '#3fb950' : '#f0a500'; } - if (pCount) pCount.textContent = (spec.provisionedCount || 0) + ' / ' + (spec.desiredCount || '?'); - if (pStat) { - var statusColor = spec.status === 'fulfilled' ? '#3fb950' : spec.status === 'failed' ? '#f85149' : '#f0a500'; - pStat.textContent = spec.status || 'provisioning'; - pStat.style.color = statusColor; - } - - // Active attempt info (server provides this when tracking is deeper) - if (spec.activeAttempt) { - anyActiveAttempt = true; - var aa = spec.activeAttempt; - var aaEl = document.getElementById('apActiveAttemptInfo'); - if (aaEl) { - aaEl.textContent = spec.nodeSpecName + ': ' + (aa.specId || '') + - (aa.zone ? ' @' + aa.zone : '') + ' (' + (aa.elapsedSeconds || 0) + 's)'; - } - } - - // Attempt log rows - if (spec.attempts && spec.attempts.length > 0) { - var logEl = document.getElementById('apAttemptLog'); - if (logEl) { - var logHtml = spec.attempts.map(function(a) { - var icon = a.status === 'succeeded' - ? '' - : (a.status === 'csp-failed' || a.status === 'failed') - ? '' - : a.status === 'review-rejected' - ? '' - : ''; - var cnt = a.succeededCount > 0 ? a.succeededCount + ' nodes' - : (a.status === 'csp-failed' ? '0' : '...'); - var costStr = a.costPerHour > 0 ? ' $' + a.costPerHour.toFixed(3) + '/hr' : ''; - var gpuStr = a.acceleratorModel - ? '  ' + a.acceleratorModel + - ' ×' + (a.acceleratorCount || 1) + - (a.acceleratorMemoryGb ? ' (' + a.acceleratorMemoryGb + 'GB)' : '') + - '' - : ''; - return '
    ' + - icon + ' ' + - '' + (a.specId || '') + '  ' + - '' + (a.connectionName || '') + '  ' + - '' + (a.zone || '') + '  ' + - '' + cnt + costStr + '' + - gpuStr + '
    '; - }).join(''); - logEl.innerHTML = logHtml; - logEl.scrollTop = logEl.scrollHeight; - } - } - }); - - // Update top status line with aggregate progress when no active-attempt detail is available - if (specs.length > 0 && !anyActiveAttempt) { - var aaEl = document.getElementById('apActiveAttemptInfo'); - if (aaEl) { - if (totalRunning > 0) { - aaEl.textContent = totalRunning + ' / ' + totalDesired + ' nodes running'; - aaEl.style.color = totalRunning >= totalDesired ? '#3fb950' : '#f0a500'; - } else { - aaEl.textContent = 'Searching for suitable specs...'; - aaEl.style.color = '#7d8590'; - } - } - } -} - -// ── Final result ─── -function showAutopilotResult(result, req) { - _apStopPolling(); - - var stats = result.autopilotStats || {}; - var nsResults = result.nodeSpecResults || []; - var attempts = result.provisioningAttempts || []; - - var allFulfilled = nsResults.length > 0 && nsResults.every(function(ns) { return ns.fulfilled; }); - var anyProvisioned = nsResults.some(function(ns) { return ns.provisionedCount > 0; }); - var statusBadge = allFulfilled - ? '✓ Complete' - : anyProvisioned - ? '~ Partial' - : '✕ Failed'; - - var nsHtml = nsResults.map(function(ns) { - var icon = ns.fulfilled ? '' - : ns.provisionedCount > 0 ? '~' - : ''; - return '
    ' + - '' + icon + ' ' + ns.nodeSpecName + '' + - '' + ns.provisionedCount + '/' + ns.desiredCount + ' nodes' + - (ns.locationsUsed && ns.locationsUsed.length ? '  ' + ns.locationsUsed.join(', ') + '' : '') + - '
    '; - }).join(''); - - var hasGpuAttempt = attempts.some(function(a) { return a.acceleratorModel; }); - var attemptRows = attempts.map(function(a) { - var icon = a.status === 'succeeded' - ? '' - : (a.status === 'csp-failed' || a.status === 'failed') - ? '' - : ''; - var gpuCell = ''; - if (hasGpuAttempt) { - gpuCell = a.acceleratorModel - ? '' + - a.acceleratorModel + ' ×' + (a.acceleratorCount || 1) + - (a.acceleratorMemoryGb ? ' (' + a.acceleratorMemoryGb + 'GB)' : '') + - '' - : '—'; - } - return '' + - '' + icon + '' + - '' + (a.specId || '') + '' + - '' + (a.connectionName || '') + '' + - '' + (a.zone || '') + '' + - '' + (a.succeededCount || 0) + '/' + a.requestedCount + '' + - '' + (a.costPerHour > 0 ? '$' + a.costPerHour.toFixed(3) : '') + '' + - gpuCell + - ''; - }).join(''); - - Swal.fire({ - title: '', - html: '
    ' + - '
    ' + - '
    🤖 ' + (result.name || (req && req.name) || '') + '
    ' + - statusBadge + '
    ' + - '
    ' + - '
    ' + - _apStatCell('NodeGroups', stats.nodeGroupCount || 0) + - _apStatCell('Locations', (stats.locationsUsed || []).length) + - _apStatCell('Elapsed', (stats.elapsedSeconds || 0) + 's') + - _apStatCell('Attempts', (stats.totalAttempts || 0) + ' (' + (stats.succeeded || 0) + '✓/' + (stats.failed || 0) + '✕)') + - '
    ' + nsHtml + '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - (hasGpuAttempt ? '' : '') + - '' + - '' + attemptRows + '
    SpecConnectionZoneNodes$/hrGPU
    ' + - (stats.wastedCostPerHour > 0 ? '
    Wasted: $' + - stats.wastedCostPerHour.toFixed(3) + '/hr from failed nodes (refine cleaned up)
    ' : '') + - _apPostCommandSummaryHtml(result) + - '
    ', - showCloseButton: true, - confirmButtonText: '🗺 View on Map', - showCancelButton: true, - cancelButtonText: 'Close', - width: '720px', - background: '#1c2128', - color: '#e6edf3', - confirmButtonColor: '#58a6ff', - cancelButtonColor: '#30363d', - }).then(function(r) { - if (r.isConfirmed) updateInfraList(); - }); -} - -function _apStatCell(label, value) { - return '
    ' + - '
    ' + label + '
    ' + - '
    ' + value + '
    '; -} - -// Renders a compact per-node summary of post-deployment command results -// (flattened from result.postCommandResults phases of the embedded InfraInfo). -function _apPostCommandSummaryHtml(result) { - var pcResults = (result.postCommandResults || []).reduce(function(acc, ph) { - return acc.concat((ph.results && ph.results.results) || []); - }, []); - if (pcResults.length === 0) return ''; - var okCount = pcResults.filter(function(r) { return !r.error; }).length; - var rows = pcResults.map(function(r) { - var icon = r.error ? '' : ''; - var detail = window.escapeHtml(r.error ? String(r.error) : (r.nodeIp || '')); - return '
    ' + - '' + icon + ' ' + window.escapeHtml(r.nodeId || '') + '' + - '' + detail + '
    '; - }).join(''); - return '
    ' + - '
    ⚡ Post-Deployment Commands ' + - '(' + okCount + '/' + pcResults.length + ' nodes ok)
    ' + - rows + '
    '; -} - -// ── Provider multi-select helpers ── -// Returns unique provider names from loaded connection configs (same source as -// the global "☁️ Provider" dropdown in MC-Infra Recommendation). -function _apGetAvailableProviders() { - var conn = (window.cloudBaristaCentralData && window.cloudBaristaCentralData.connection) || []; - var seen = {}; - conn.forEach(function(c) { if (c.providerName) seen[c.providerName] = true; }); - return Object.keys(seen).sort(); -} - -// Returns checked provider values for a given NodeSpec card. -// Uses DOM query on #apNs__providerCsps so the list stays in sync with -// whatever providers were rendered (no separate JS array needed). -function _apGetSelectedProviders(id) { - var allCb = document.getElementById('apNs_' + id + '_providerAll'); - if (allCb && allCb.checked) return []; - var container = document.getElementById('apNs_' + id + '_providerCsps'); - if (!container) return []; - return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) - .map(function(cb) { return cb.value; }); -} - -function _apUpdateProviderText(id) { - var textEl = document.getElementById('apNs_' + id + '_providerText'); - if (!textEl) return; - var selected = _apGetSelectedProviders(id); - if (selected.length === 0) { - textEl.textContent = 'ALL'; - } else if (selected.length <= 2) { - textEl.textContent = selected.map(function(p) { return p.toUpperCase(); }).join(', '); - } else { - textEl.textContent = selected.slice(0, 2).map(function(p) { return p.toUpperCase(); }).join(', ') + ' +' + (selected.length - 2); - } -} - -function _apProviderAllToggle(id) { - var allCb = document.getElementById('apNs_' + id + '_providerAll'); - var container = document.getElementById('apNs_' + id + '_providerCsps'); - if (allCb && allCb.checked && container) { - container.querySelectorAll('input[type="checkbox"]').forEach(function(cb) { cb.checked = false; }); - } - _apUpdateProviderText(id); -} - -function _apProviderCheckboxChange(id) { - var container = document.getElementById('apNs_' + id + '_providerCsps'); - var anyChecked = container && container.querySelector('input[type="checkbox"]:checked') !== null; - var allCb = document.getElementById('apNs_' + id + '_providerAll'); - if (allCb) allCb.checked = !anyChecked; - _apUpdateProviderText(id); -} - -// ── Progress panel helpers ─ -var _apProgressPanelExpanded = true; - -function _apShowProgressPanel(title) { - var panel = document.getElementById('apProgressPanel'); - var titleEl = document.getElementById('autopilotProgressTitle'); - if (panel) panel.style.display = 'block'; - if (titleEl) titleEl.textContent = '⚙️ ' + title; - _apProgressPanelExpanded = true; - _apSyncPanelState(); -} - -function _apHideProgressPanel() { - var panel = document.getElementById('apProgressPanel'); - if (panel) panel.style.display = 'none'; -} - -function toggleApProgressPanel() { - _apProgressPanelExpanded = !_apProgressPanelExpanded; - _apSyncPanelState(); -} - -function _apSyncPanelState() { - var body = document.getElementById('autopilotProgressBody'); - var icon = document.getElementById('apProgressPanelToggle'); - if (body) body.style.display = _apProgressPanelExpanded ? '' : 'none'; - if (icon) icon.textContent = _apProgressPanelExpanded ? '▼' : '▲'; -} - -// ── Window exports ─ -window.showAutopilotDialog = showAutopilotDialog; -window.addAutopilotNodeSpec = addAutopilotNodeSpec; -window.removeAutopilotNodeSpec = removeAutopilotNodeSpec; -window.submitAutopilotReview = submitAutopilotReview; -window.backToAutopilotForm = backToAutopilotForm; -window.proceedWithAutopilotProvision = proceedWithAutopilotProvision; -window.showAutopilotResult = showAutopilotResult; -window.toggleApProgressPanel = toggleApProgressPanel; -window._apProviderAllToggle = _apProviderAllToggle; -window._apProviderCheckboxChange = _apProviderCheckboxChange; +import './src/index.js'; +export * from './src/index.js'; diff --git a/package.json b/package.json index ba7666b..c5ccc5d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "cb-mapui", + "type": "module", "dependencies": { "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", diff --git a/dashboard.html b/public/dashboard.html similarity index 100% rename from dashboard.html rename to public/dashboard.html diff --git a/dashboard.js b/public/dashboard.js similarity index 100% rename from dashboard.js rename to public/dashboard.js diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..a665620 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/circle.png b/public/img/circle.png new file mode 100644 index 0000000..040f2e9 Binary files /dev/null and b/public/img/circle.png differ diff --git a/public/img/csp-alibaba.png b/public/img/csp-alibaba.png new file mode 100644 index 0000000..852fc8b Binary files /dev/null and b/public/img/csp-alibaba.png differ diff --git a/public/img/csp-aws.png b/public/img/csp-aws.png new file mode 100644 index 0000000..52690d5 Binary files /dev/null and b/public/img/csp-aws.png differ diff --git a/public/img/csp-azure.png b/public/img/csp-azure.png new file mode 100644 index 0000000..891f317 Binary files /dev/null and b/public/img/csp-azure.png differ diff --git a/public/img/csp-gcp.png b/public/img/csp-gcp.png new file mode 100644 index 0000000..273455c Binary files /dev/null and b/public/img/csp-gcp.png differ diff --git a/public/img/csp-ibm.png b/public/img/csp-ibm.png new file mode 100644 index 0000000..9400a3f Binary files /dev/null and b/public/img/csp-ibm.png differ diff --git a/public/img/csp-kt.png b/public/img/csp-kt.png new file mode 100644 index 0000000..9f3a497 Binary files /dev/null and b/public/img/csp-kt.png differ diff --git a/public/img/csp-ncp.png b/public/img/csp-ncp.png new file mode 100644 index 0000000..73b247f Binary files /dev/null and b/public/img/csp-ncp.png differ diff --git a/public/img/csp-nhn.png b/public/img/csp-nhn.png new file mode 100644 index 0000000..819ad52 Binary files /dev/null and b/public/img/csp-nhn.png differ diff --git a/public/img/csp-openstack.png b/public/img/csp-openstack.png new file mode 100644 index 0000000..2b54962 Binary files /dev/null and b/public/img/csp-openstack.png differ diff --git a/public/img/csp-tencent.png b/public/img/csp-tencent.png new file mode 100644 index 0000000..784ce6e Binary files /dev/null and b/public/img/csp-tencent.png differ diff --git a/public/img/icon-k8s.png b/public/img/icon-k8s.png new file mode 100644 index 0000000..ed5a8fa Binary files /dev/null and b/public/img/icon-k8s.png differ diff --git a/public/img/icon-key.png b/public/img/icon-key.png new file mode 100644 index 0000000..01c281f Binary files /dev/null and b/public/img/icon-key.png differ diff --git a/public/img/icon-nlb.png b/public/img/icon-nlb.png new file mode 100644 index 0000000..b90e53a Binary files /dev/null and b/public/img/icon-nlb.png differ diff --git a/public/img/icon-sg.png b/public/img/icon-sg.png new file mode 100644 index 0000000..04c8f0e Binary files /dev/null and b/public/img/icon-sg.png differ diff --git a/public/img/icon-vm.png b/public/img/icon-vm.png new file mode 100644 index 0000000..93c8d2b Binary files /dev/null and b/public/img/icon-vm.png differ diff --git a/public/img/icon-vnet.png b/public/img/icon-vnet.png new file mode 100644 index 0000000..c9e1fa0 Binary files /dev/null and b/public/img/icon-vnet.png differ diff --git a/public/img/icon-vpn.png b/public/img/icon-vpn.png new file mode 100644 index 0000000..108f53f Binary files /dev/null and b/public/img/icon-vpn.png differ diff --git a/redoc-swagger.html b/public/redoc-swagger.html similarity index 100% rename from redoc-swagger.html rename to public/redoc-swagger.html diff --git a/public/runtime-params.json b/public/runtime-params.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/public/runtime-params.json @@ -0,0 +1 @@ +{} diff --git a/scalar.html b/public/scalar.html similarity index 100% rename from scalar.html rename to public/scalar.html diff --git a/swagger.html b/public/swagger.html similarity index 100% rename from swagger.html rename to public/swagger.html diff --git a/src/core/central-loaders.js b/src/core/central-loaders.js index 2f19faa..53a2b58 100644 --- a/src/core/central-loaders.js +++ b/src/core/central-loaders.js @@ -388,5 +388,16 @@ async function loadVpnDataFromInfras() { // Make function available globally for Dashboard to call window.syncInfraSelectionFromDashboard = syncInfraSelectionFromDashboard; +window.loadK8sClusterData = loadK8sClusterData; +window.loadNlbData = loadNlbData; +window.loadVpnDataFromInfras = loadVpnDataFromInfras; + +export { + syncInfraSelectionFromDashboard, + loadK8sClusterData, + loadNlbData, + loadVpnDataFromInfras +}; + diff --git a/src/core/connection.js b/src/core/connection.js index 48a7bc9..359fe08 100644 --- a/src/core/connection.js +++ b/src/core/connection.js @@ -22,13 +22,19 @@ const updateNsList = () => { if (window.updateNsList) window.updateNsList(); }; const getInfra = () => { if (window.getInfra) window.getInfra(); }; const resolveCloudPlatform = (p) => (window.resolveCloudPlatform ? window.resolveCloudPlatform(p) : p); const createIconStyle = (src) => (window.createIconStyle ? window.createIconStyle(src) : null); +const updateMapConnectionStatus = (s) => (window.updateMapConnectionStatus ? window.updateMapConnectionStatus(s) : undefined); + +if (!window.nodeGroupRequestFromSpecList) window.nodeGroupRequestFromSpecList = []; +if (!window.recommendedSpecList) window.recommendedSpecList = []; +if (typeof window.latLonInputPairIdx === 'undefined') window.latLonInputPairIdx = 0; const map = new Proxy({}, { get: (target, prop) => { const m = window.map; - if (!m) return () => {}; - const val = m[prop]; - return typeof val === 'function' ? val.bind(m) : val; + if (m && typeof m[prop] === 'function') { + return m[prop].bind(m); + } + return () => {}; } }); @@ -389,6 +395,7 @@ function checkConnectionWithRetry() { // Process the connection data (use existing logic) processConnectionData(connData); + updateMapConnectionStatus('connected'); // Load namespace list and Infra data now that server is ready updateNsList(); @@ -412,6 +419,7 @@ function checkConnectionWithRetry() { currentStatus = CONNECTION_STATUS.CONNECTION_FAILED; const errorMsg = error.code || error.message || 'Unknown error'; updateStatusUI(CONNECTION_STATUS.CONNECTION_FAILED, errorMsg); + updateMapConnectionStatus('disconnected'); console.log('[Connection Check] Failed:', error); // Mark hostname/port as error in main UI @@ -576,6 +584,8 @@ function checkConnectionWithRetry() { const portEl = document.getElementById('port'); if (hostnameEl) hostnameEl.style.color = ''; if (portEl) portEl.style.color = ''; + + updateMapConnectionStatus('connected'); } // Setup event listeners for the popup @@ -859,6 +869,7 @@ function getConnection() { setTimeout(() => map.render(), 500); infoAlert("Registered Cloud Regions: " + obj.connectionconfig.length); + updateMapConnectionStatus('connected'); } }) .catch(function (error) { @@ -868,6 +879,7 @@ function getConnection() { document.getElementById("hostname").style.color = "#FF0000"; document.getElementById("port").style.color = "#FF0000"; } + updateMapConnectionStatus('disconnected'); console.log('[getConnection] Error:', error); }); } diff --git a/src/features/credentials/credential-holder.js b/src/features/credentials/credential-holder.js index 7a2ef94..72cd211 100644 --- a/src/features/credentials/credential-holder.js +++ b/src/features/credentials/credential-holder.js @@ -130,6 +130,9 @@ function applyNamespace(newNs) { if (window.saveApiConfig) window.saveApiConfig(); updateNsDisplays(); updateInfraList(); + if (typeof window.getInfra === 'function') { + window.getInfra(); + } } // Change credential holder and reload connections + map diff --git a/src/features/infra-control/infra-control.js b/src/features/infra-control/infra-control.js index 4907f2d..3e88e9d 100644 --- a/src/features/infra-control/infra-control.js +++ b/src/features/infra-control/infra-control.js @@ -20,13 +20,14 @@ const displayInfraStatusGui = (...args) => { if (window.displayInfraStatusGui) w const map = new Proxy({}, { get: (target, prop) => { const m = window.map; - if (!m) return () => {}; - const val = m[prop]; - return typeof val === 'function' ? val.bind(m) : val; + if (m && typeof m[prop] === 'function') { + return m[prop].bind(m); + } + return () => {}; } }); -const infraList = new Proxy([], { +let infraList = new Proxy([], { get: (target, prop) => (window.infraList || [])[prop], set: (target, prop, val) => { if (!window.infraList) window.infraList = []; @@ -35,7 +36,7 @@ const infraList = new Proxy([], { } }); -const infraHideList = new Proxy([], { +let infraHideList = new Proxy([], { get: (target, prop) => (window.infraHideList || [])[prop], set: (target, prop, val) => { if (!window.infraHideList) window.infraHideList = []; @@ -180,7 +181,8 @@ function hideInfra() { }, }).then((res) => { if (res.data.output != null) { - infraList = res.data.output; + window.infraList = res.data.output; + infraList = window.infraList; Swal.fire({ title: "Hide/Show a Infra from the Map", @@ -207,9 +209,10 @@ function hideInfra() { confirmButtonText: "Show", }).then((result) => { if (result.isConfirmed) { - infraHideList = infraHideList.filter( - (a) => a !== infraHideList[result.value] + window.infraHideList = (window.infraHideList || []).filter( + (a) => a !== (window.infraHideList || [])[result.value] ); + infraHideList = window.infraHideList; for (let i = 0; i < infraHideList.length; i++) { var html = @@ -251,7 +254,8 @@ function hideInfra() { if (result.isConfirmed) { infraHideList.push(infraList[result.value]); // remove duplicated items - infraHideList = [...new Set(infraHideList)]; + window.infraHideList = [...new Set(window.infraHideList || infraHideList)]; + infraHideList = window.infraHideList; for (let i = 0; i < infraHideList.length; i++) { var html = diff --git a/src/features/remote-command/remote-command.js b/src/features/remote-command/remote-command.js index ab2920b..280cef4 100644 --- a/src/features/remote-command/remote-command.js +++ b/src/features/remote-command/remote-command.js @@ -2839,6 +2839,8 @@ function startStreamingSession(streamUrl, username, password, xRequestId, infraI window._cmdStreamSessions[xRequestId] = session; updateStreamingBadge(); + if (window.notifyDataSubscribers) window.notifyDataSubscribers(); + if (window.NetworkGraph?.refresh) window.NetworkGraph.refresh(); // Best-effort lookup of each Node's public IP so $$ENDPOINT[…](http://0.0.0.0:…) // can be rewritten to the actual reachable URL in the live streaming view. @@ -2879,6 +2881,8 @@ function startStreamingSession(streamUrl, username, password, xRequestId, infraI // commandIndex is required to build the per-Node taskId ({xRequestId}:{nodeId}:{index}) // for the task cancel API if (typeof event.commandIndex === 'number' && event.commandIndex > 0) nd.commandIndex = event.commandIndex; + if (window.notifyDataSubscribers) window.notifyDataSubscribers(); + if (window.NetworkGraph?.refresh) window.NetworkGraph.refresh(); } else if (event.type === 'CommandLog' && event.nodeId && event.log) { const nd = getOrCreateNode(event.nodeId); if (typeof event.commandIndex === 'number' && event.commandIndex > 0) nd.commandIndex = event.commandIndex; @@ -2896,10 +2900,14 @@ function startStreamingSession(streamUrl, username, password, xRequestId, infraI session.commandError = event.summary.error; } removeSpinnerTask(session.spinnerId); + if (window.notifyDataSubscribers) window.notifyDataSubscribers(); + if (window.NetworkGraph?.refresh) window.NetworkGraph.refresh(); // Auto-cleanup session after 5 minutes session.cleanupTimer = setTimeout(() => { delete window._cmdStreamSessions[xRequestId]; updateStreamingBadge(); + if (window.notifyDataSubscribers) window.notifyDataSubscribers(); + if (window.NetworkGraph?.refresh) window.NetworkGraph.refresh(); }, 5 * 60 * 1000); } updateStreamingBadge(); @@ -2911,6 +2919,8 @@ function startStreamingSession(streamUrl, username, password, xRequestId, infraI session.error = err.message || 'Connection failed'; removeSpinnerTask(session.spinnerId); updateStreamingBadge(); + if (window.notifyDataSubscribers) window.notifyDataSubscribers(); + if (window.NetworkGraph?.refresh) window.NetworkGraph.refresh(); if (session.rebuildCallback) session.rebuildCallback(); } }); @@ -4622,6 +4632,17 @@ async function executeRemoteCmd() { if (useSyncMode) { // Sync mode: wait for full response and show formatted result console.log('[RemoteCmd] Using sync mode'); + const syncSessionKey = 'sync-' + requestId; + window._cmdStreamSessions[syncSessionKey] = { + xRequestId: requestId, + infraId: selectedInfraId, + targetNodeId: (selectOption === 'Node' ? nodeid : null), + startTime: Date.now(), + nodeState: {}, + }; + if (window.notifyDataSubscribers) window.notifyDataSubscribers(); + if (window.NetworkGraph?.refresh) window.NetworkGraph.refresh(); + axios({ method: "post", url: url, @@ -4633,9 +4654,15 @@ async function executeRemoteCmd() { }, }).then((res) => { console.log('[RemoteCmd] Sync response:', 'status=' + res.status, res); + delete window._cmdStreamSessions[syncSessionKey]; + if (window.notifyDataSubscribers) window.notifyDataSubscribers(); + if (window.NetworkGraph?.refresh) window.NetworkGraph.refresh(); showRemoteCmdResult(res.data, appliedDnsUrl, selectedInfraId); removeSpinnerTask(spinnerId); }).catch(function (error) { + delete window._cmdStreamSessions[syncSessionKey]; + if (window.notifyDataSubscribers) window.notifyDataSubscribers(); + if (window.NetworkGraph?.refresh) window.NetworkGraph.refresh(); if (error.response) { console.log(error.response.data); console.log(error.response.status); diff --git a/src/features/resource-list/resource-list.js b/src/features/resource-list/resource-list.js index 68754b9..d892181 100644 --- a/src/features/resource-list/resource-list.js +++ b/src/features/resource-list/resource-list.js @@ -70,6 +70,9 @@ function updateNsList() { }) .finally(function () { updateInfraList(); + if (typeof window.getInfra === 'function') { + window.getInfra(); + } }); } diff --git a/src/features/templates/template-mgr.js b/src/features/templates/template-mgr.js index 1dcde47..3846ff7 100644 --- a/src/features/templates/template-mgr.js +++ b/src/features/templates/template-mgr.js @@ -18,6 +18,7 @@ $.extend = (...args) => (window.$ || window.jQuery).extend(...args); const clearCircle = (...args) => { if (window.clearCircle) window.clearCircle(...args); }; const outputAlert = (...args) => { if (window.outputAlert) window.outputAlert(...args); }; const updateNodeGroupReview = () => { if (window.updateNodeGroupReview) window.updateNodeGroupReview(); }; +const renderMapFromConfig = () => { if (window.renderMapFromConfig) window.renderMapFromConfig(); }; const toggleWorkloadType = async (...args) => { if (window.toggleWorkloadType) return await window.toggleWorkloadType(...args); }; const createInfraReqVmTmplt = new Proxy({}, { @@ -33,7 +34,8 @@ const createInfraReqVmTmplt = new Proxy({}, { const nodeGroupRequestFromSpecList = new Proxy([], { get: (target, prop) => { - const arr = window.nodeGroupRequestFromSpecList || []; + if (!window.nodeGroupRequestFromSpecList) window.nodeGroupRequestFromSpecList = []; + const arr = window.nodeGroupRequestFromSpecList; const val = arr[prop]; return typeof val === 'function' ? val.bind(arr) : val; }, @@ -52,7 +54,8 @@ const nodeGroupRequestFromSpecList = new Proxy([], { const recommendedSpecList = new Proxy([], { get: (target, prop) => { - const arr = window.recommendedSpecList || []; + if (!window.recommendedSpecList) window.recommendedSpecList = []; + const arr = window.recommendedSpecList; const val = arr[prop]; return typeof val === 'function' ? val.bind(arr) : val; }, @@ -971,11 +974,27 @@ async function loadTemplateToInfraConfig(namespace, templateId) { specInfoList.forEach(function(specInfo) { recommendedSpecList.push(specInfo); }); + renderMapFromConfig(); updateNodeGroupReview(); // Switch to Provision tab - var provisionTab = document.getElementById('provision-tab'); - if (provisionTab) provisionTab.click(); + try { + document.querySelectorAll('.nav-link').forEach(tab => tab.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('show', 'active')); + const provisionTab = document.getElementById('provision-tab'); + const provisionPane = document.getElementById('provision'); + if (provisionTab && provisionPane) { + provisionTab.classList.add('active'); + provisionPane.classList.add('show', 'active'); + if (typeof $ !== 'undefined' && $.fn && $.fn.tab) { + $(provisionTab).tab('show'); + } + } + } catch (e) { + console.warn('Failed to switch to provision tab:', e); + var provisionTab = document.getElementById('provision-tab'); + if (provisionTab) provisionTab.click(); + } Swal.fire({ toast: true, @@ -1111,10 +1130,27 @@ async function loadTemplateToK8sConfig(namespace, templateId) { specInfoList.forEach(function(specInfo) { recommendedSpecList.push(specInfo); }); + renderMapFromConfig(); updateNodeGroupReview(); - var provisionTab = document.getElementById('provision-tab'); - if (provisionTab) provisionTab.click(); + // Switch to Provision tab + try { + document.querySelectorAll('.nav-link').forEach(tab => tab.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('show', 'active')); + const provisionTab = document.getElementById('provision-tab'); + const provisionPane = document.getElementById('provision'); + if (provisionTab && provisionPane) { + provisionTab.classList.add('active'); + provisionPane.classList.add('show', 'active'); + if (typeof $ !== 'undefined' && $.fn && $.fn.tab) { + $(provisionTab).tab('show'); + } + } + } catch (e) { + console.warn('Failed to switch to provision tab:', e); + var provisionTab = document.getElementById('provision-tab'); + if (provisionTab) provisionTab.click(); + } Swal.fire({ toast: true, diff --git a/network-graph.js b/src/features/topology/network-graph.js similarity index 98% rename from network-graph.js rename to src/features/topology/network-graph.js index 31a9380..f78dcbc 100644 --- a/network-graph.js +++ b/src/features/topology/network-graph.js @@ -288,12 +288,29 @@ function buildElements(centralData) { // showed no path at all for nodes served that way. e.isBastion = bastionRefs.has(`${e.infraId}|${n.id}`); - // Active remote-command state (same source the map view uses): + // Active remote-command state (from backend commandStatus AND active streaming sessions): // marks the node label and animates the command-path edges below. const cmdStatuses = Array.isArray(n.commandStatus) ? n.commandStatus : []; - e.cmdHandling = cmdStatuses.some((c) => c.status === 'Handling'); - const cmdQueued = cmdStatuses.some((c) => c.status === 'Queued'); - const cmdMark = e.cmdHandling ? '⚡ ' : (cmdQueued ? '⏳ ' : ''); + let isHandling = cmdStatuses.some((c) => c.status === 'Handling'); + let isQueued = cmdStatuses.some((c) => c.status === 'Queued'); + + if (window._cmdStreamSessions) { + Object.values(window._cmdStreamSessions).forEach((s) => { + if (!s || s.doneSummary || s.error || s.commandError) return; + if (!s.infraId || s.infraId === e.infraId) { + const ns = s.nodeState && s.nodeState[n.id]; + if (ns) { + if (ns.status === 'Handling') isHandling = true; + else if (ns.status === 'Queued') isQueued = true; + } else if (!s.targetNodeId || s.targetNodeId === n.id) { + isHandling = true; + } + } + }); + } + + e.cmdHandling = isHandling; + const cmdMark = e.cmdHandling ? '⚡ ' : (isQueued ? '⏳ ' : ''); // Surface meaningful user labels (role, accelerator) as chips inside the // node, one per label just like the IP chips (and after them), so they never @@ -528,6 +545,18 @@ function buildElements(centralData) { if (e.isBastion) entry.bastions.push(e); }); + // Fallback: if a vNet has no explicit bastion registered, any VM with a public IP + // acts as an entrypoint for CB-TB remote commands + byVnet.forEach((entry) => { + if (entry.bastions.length === 0) { + entry.members.forEach((m) => { + if (m.node.publicIP && !entry.bastions.includes(m)) { + entry.bastions.push(m); + } + }); + } + }); + // Mirror the backend's bastion selection so the picture matches how a // command actually runs. CB-Tumblebug picks EXACTLY ONE bastion per target // node (Rendezvous / Highest-Random-Weight hashing over the target's subnet diff --git a/resource-graph.js b/src/features/topology/resource-graph.js similarity index 100% rename from resource-graph.js rename to src/features/topology/resource-graph.js diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..60ff215 --- /dev/null +++ b/src/index.js @@ -0,0 +1,51 @@ +/** + * Central module aggregator for cb-mapui + * Imports and initializes all core modules, map utilities, views, and features. + */ + +// 1. Common & Core Foundation +import './common/popup-styles.js'; +import './core/utils.js'; +import './core/api.js'; +import './core/connection.js'; +import './core/central-loaders.js'; + +// 2. Map & Geometry +import './map/geometry.js'; +import './map/gis-styling.js'; +import './map/map-core.js'; + +// 3. Views +import './views/infra-card.js'; + +// 4. Features +import './features/tombstones/tombstone-mgr.js'; +import './features/labels/label-system.js'; +import './features/base64/base64-panel.js'; +import './features/credentials/credential-holder.js'; +import './features/credentials/csp-registration.js'; +import './features/infra-control/infra-control.js'; +import './features/k8s/k8s-cluster.js'; +import './features/network/gateway-dns.js'; +import './features/network/network-services.js'; +import './features/resource-list/resource-list.js'; +import './features/security-group/security-group.js'; +import './features/spec/spec-config.js'; +import './features/provision/provision-wizard.js'; +import './features/scaleout/scaleout-mgr.js'; +import './features/remote-command/remote-command.js'; +import './features/snapshots/snapshot-mgr.js'; +import './features/schedule/schedule-mgr.js'; +import './features/tasks/task-mgr.js'; +import './features/templates/template-mgr.js'; +import './features/autopilot/autopilot.js'; + +// 5. Topology Views +import './features/topology/resource-graph.js'; +import './features/topology/network-graph.js'; + +export * from './core/api.js'; +export * from './core/utils.js'; +export * from './map/gis-styling.js'; + + diff --git a/src/map/gis-styling.js b/src/map/gis-styling.js index 2fb1089..a9e40b7 100644 --- a/src/map/gis-styling.js +++ b/src/map/gis-styling.js @@ -658,12 +658,107 @@ function changeSizeStatus(status) { } } +// Check whether a node status is a transitional (~ing) state (e.g. Creating, Terminating, etc.) +// Note: "Running" is excluded as it is a steady state. +export function isTransitionalNodeStatus(status) { + if (!status) return false; + const s = status.toString().toLowerCase(); + if (s === "running" || s.includes("running")) return false; + return ( + s.includes("creating") || + s.includes("registering") || + s.includes("reconciling") || + s.includes("resuming") || + s.includes("preparing") || + s.includes("suspending") || + s.includes("rebooting") || + s.includes("deleting") || + s.includes("terminating") + ); +} + +// Cached OpenLayers styles for transitional node backdrop halos (zero allocation in render loop) +const transitionalNodeHaloCache = {}; + +export function getNodeTransitionalHaloStyle(nodeStatus) { + if (!isTransitionalNodeStatus(nodeStatus)) return null; + + const s = nodeStatus.toString().toLowerCase(); + let key = "unknown"; + let strokeColor = "#3b82f6"; + let fillColor = "rgba(59, 130, 246, 0.45)"; + + if (s.includes("creating")) { + key = "creating"; + strokeColor = "#2563eb"; + fillColor = "rgba(59, 130, 246, 0.45)"; + } else if (s.includes("registering")) { + key = "registering"; + strokeColor = "#0d9488"; + fillColor = "rgba(20, 184, 166, 0.45)"; + } else if (s.includes("reconciling")) { + key = "reconciling"; + strokeColor = "#4f46e5"; + fillColor = "rgba(99, 102, 241, 0.45)"; + } else if (s.includes("resuming")) { + key = "resuming"; + strokeColor = "#0891b2"; + fillColor = "rgba(6, 182, 212, 0.45)"; + } else if (s.includes("preparing")) { + key = "preparing"; + strokeColor = "#ea580c"; + fillColor = "rgba(249, 115, 22, 0.45)"; + } else if (s.includes("suspending")) { + key = "suspending"; + strokeColor = "#b45309"; + fillColor = "rgba(217, 119, 6, 0.45)"; + } else if (s.includes("rebooting")) { + key = "rebooting"; + strokeColor = "#7c3aed"; + fillColor = "rgba(139, 92, 246, 0.45)"; + } else if (s.includes("deleting")) { + key = "deleting"; + strokeColor = "#e11d48"; + fillColor = "rgba(251, 113, 133, 0.45)"; + } else if (s.includes("terminating")) { + key = "terminating"; + strokeColor = "#dc2626"; + fillColor = "rgba(239, 68, 68, 0.45)"; + } else { + key = s; + const colorObj = getNodeStatusColor(nodeStatus); + const rgb = hexToRgb(colorObj.fill); + strokeColor = colorObj.fill; + fillColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.45)`; + } + + if (!transitionalNodeHaloCache[key]) { + transitionalNodeHaloCache[key] = new Style({ + image: new CircleStyle({ + radius: 22, + fill: new Fill({ color: fillColor }), + stroke: new Stroke({ color: strokeColor, width: 3 }), + }), + }); + } + + return transitionalNodeHaloCache[key]; +} + // Create Node icon style with status badge and provider icon function createNodeStyleWithStatusBadge(nodeStatus, providerName = null, baseScale = 1.0, nodeCoords = null, commandStatus = "None") { const statusColors = getNodeStatusColor(nodeStatus); - const styles = [ - // Main Node icon (center) + const styles = []; + + // Transitional halo: circular backdrop behind icon for ~ing states (like tombstone release resource) + const haloStyle = getNodeTransitionalHaloStyle(nodeStatus); + if (haloStyle) { + styles.push(haloStyle); + } + + // Main Node icon (center) + styles.push( new Style({ image: new Icon({ crossOrigin: "anonymous", @@ -674,8 +769,11 @@ function createNodeStyleWithStatusBadge(nodeStatus, providerName = null, baseSca anchorXUnits: 'fraction', anchorYUnits: 'fraction', }), - }), - // Status badge (bottom-right using displacement) + }) + ); + + // Status badge (bottom-right using displacement) + styles.push( new Style({ image: new CircleStyle({ radius: 4, @@ -689,7 +787,7 @@ function createNodeStyleWithStatusBadge(nodeStatus, providerName = null, baseSca displacement: [12, -13], // Move right and down (negative Y for down) }), }) - ]; + ); // Add command status icon if there are active commands if (commandStatus === "Queued" || commandStatus === "Handling") { @@ -766,3 +864,15 @@ window.splitInfraNameToLines = splitInfraNameToLines; window.splitK8sNameToLines = splitK8sNameToLines; window.changeSizeStatus = changeSizeStatus; window.createNodeStyleWithStatusBadge = createNodeStyleWithStatusBadge; +window.isTransitionalNodeStatus = isTransitionalNodeStatus; +window.getNodeTransitionalHaloStyle = getNodeTransitionalHaloStyle; +window.cspGenericStyles = cspGenericStyles; +window.nodeGenericCloudStyleCache = nodeGenericCloudStyleCache; +window.cspDcStyles = cspDcStyles; +window.nodeDcStyleCache = nodeDcStyleCache; +window.clearCspStyleCaches = function() { + Object.keys(cspGenericStyles).forEach(k => delete cspGenericStyles[k]); + Object.keys(nodeGenericCloudStyleCache).forEach(k => delete nodeGenericCloudStyleCache[k]); + Object.keys(cspDcStyles).forEach(k => delete cspDcStyles[k]); + Object.keys(nodeDcStyleCache).forEach(k => delete nodeDcStyleCache[k]); +}; diff --git a/src/map/map-core.js b/src/map/map-core.js new file mode 100644 index 0000000..d0c0039 --- /dev/null +++ b/src/map/map-core.js @@ -0,0 +1,3437 @@ +/* +Copyright 2019 The Cloud-Barista Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +---- +Copyright for OpenLayers (https://openlayers.org/) + +BSD 2-Clause License + +Copyright 2005-present, OpenLayers Contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +---- +*/ + +// Debug Configuration +const DEBUG_CONFIG = { + ENABLE_PERFORMANCE_LOGS: false, // Map performance related logs + ENABLE_API_RESPONSE_LOGS: false, // API response logs + ENABLE_NODE_DEBUG_LOGS: false, // Detailed Node structure logs + ENABLE_RESOURCE_LOGS: false, // Resource loading logs + ENABLE_MAP_OPERATION_LOGS: false // Map operation logs +}; + +// Debug helper functions +const debugLog = { + performance: (...args) => DEBUG_CONFIG.ENABLE_PERFORMANCE_LOGS && console.log('[Performance]', ...args), + api: (...args) => DEBUG_CONFIG.ENABLE_API_RESPONSE_LOGS && console.log('[API]', ...args), + node: (...args) => DEBUG_CONFIG.ENABLE_NODE_DEBUG_LOGS && console.log('[Node Debug]', ...args), + resource: (...args) => DEBUG_CONFIG.ENABLE_RESOURCE_LOGS && console.log('[Resource]', ...args), + mapOp: (...args) => DEBUG_CONFIG.ENABLE_MAP_OPERATION_LOGS && console.log('[Map]', ...args) +}; + +// OpenLayers CSS +import "ol/ol.css"; +// Deployment-provided parameter defaults (MAPUI_PARAM_* envs -> docker-entrypoint.sh) +import runtimeParams from "../../runtime-params.json"; +window.RUNTIME_PARAM_DEFAULTS = runtimeParams || {}; + +// OpenLayers core components +import Map from "ol/Map"; +import View from "ol/View"; +import Feature from "ol/Feature"; +import Overlay from "ol/Overlay"; + +// OpenLayers geometry types +import { MultiPoint, Point, LineString, Polygon } from "ol/geom"; + +// OpenLayers layer types +import TileLayer from "ol/layer/Tile"; +import { Vector as VectorLayer } from "ol/layer"; + +// OpenLayers source types +import OSM from "ol/source/OSM"; +import { TileJSON, Vector as VectorSource } from "ol/source"; + +// OpenLayers style components +import { + Circle as CircleStyle, + Fill, + Stroke, + Style, + Text, + Icon, +} from "ol/style"; + +// OpenLayers utilities and controls +import { getVectorContext } from "ol/render"; +import { useGeographic, toLonLat } from "ol/proj"; +import { toStringHDMS, createStringXY } from "ol/coordinate"; +import MousePosition from "ol/control/MousePosition"; +import { defaults as defaultControls } from "ol/control"; + +// Third-party libraries +import Swal from "sweetalert2"; +import axios, { AxiosError } from "axios"; +import JSONFormatter from "json-formatter-js"; +import JSZip from "jszip"; + +// Expose Swal globally for inline onclick handlers +window.Swal = Swal; + +import { cspIconImg } from './gis-styling.js'; + +// Pin emoji style for Infra configuration points +var iconStyleCircle = new Style({ + text: new Text({ + text: '📍', + font: '32px Arial', + fill: new Fill({ + color: '#ff4444' + }), + stroke: new Stroke({ + color: '#ffffff', + width: 4 + }), + offsetY: -16, + scale: 1.0 + }) +}); +window.iconStyleCircle = iconStyleCircle; + +var cspIconMode = 'logo'; +window.cspIconMode = cspIconMode; + +const getCspStyle = (...args) => window.getCspStyle ? window.getCspStyle(...args) : null; +const getSelectedProviders = () => window.getSelectedProviders ? window.getSelectedProviders() : []; +const getNodeStatusColor = (...args) => window.getNodeStatusColor ? window.getNodeStatusColor(...args) : { fill: '#9ca3af', stroke: '#6b7280' }; +const getK8sStatusColor = (...args) => window.getK8sStatusColor ? window.getK8sStatusColor(...args) : { fill: '#9ca3af', stroke: '#6b7280' }; +const createNodeStyleWithStatusBadge = (...args) => window.createNodeStyleWithStatusBadge ? window.createNodeStyleWithStatusBadge(...args) : []; +const splitInfraNameToLines = (...args) => window.splitInfraNameToLines ? window.splitInfraNameToLines(...args) : ['']; +const splitK8sNameToLines = (...args) => window.splitK8sNameToLines ? window.splitK8sNameToLines(...args) : ['']; +const changeSizeByName = (...args) => window.changeSizeByName ? window.changeSizeByName(...args) : 1; +const changeSizeStatus = (...args) => window.changeSizeStatus ? window.changeSizeStatus(...args) : 1; +const hexToRgb = (...args) => window.hexToRgb ? window.hexToRgb(...args) : [100, 100, 100]; +const isTombstoneResource = (...args) => window.isTombstoneResource ? window.isTombstoneResource(...args) : false; +const updateTombstoneBanner = (...args) => window.updateTombstoneBanner && window.updateTombstoneBanner(...args); +const checkConnectionWithRetry = (...args) => window.checkConnectionWithRetry && window.checkConnectionWithRetry(...args); +const updateCredentialHolderList = (...args) => window.updateCredentialHolderList && window.updateCredentialHolderList(...args); +const updateNsList = (...args) => window.updateNsList && window.updateNsList(...args); +const loadK8sClusterData = (...args) => window.loadK8sClusterData && window.loadK8sClusterData(...args); +const loadNlbData = (...args) => window.loadNlbData && window.loadNlbData(...args); +const loadVpnDataFromInfras = (...args) => window.loadVpnDataFromInfras && window.loadVpnDataFromInfras(...args); +const applyNamespace = (...args) => window.applyNamespace && window.applyNamespace(...args); +const applyCredentialHolder = (...args) => window.applyCredentialHolder && window.applyCredentialHolder(...args); +const showActionsMenu = (...args) => window.showActionsMenu && window.showActionsMenu(...args); +const executeAction = (...args) => window.executeAction && window.executeAction(...args); +const downloadAllSshKeys = (...args) => window.downloadAllSshKeys && window.downloadAllSshKeys(...args); +const scaleOutInfraFromContext = (...args) => window.scaleOutInfraFromContext && window.scaleOutInfraFromContext(...args); +const copyInfraConfig = (...args) => window.copyInfraConfig && window.copyInfraConfig(...args); +const saveInfraAsTemplate = (...args) => window.saveInfraAsTemplate && window.saveInfraAsTemplate(...args); +const manageNLB = (...args) => window.manageNLB && window.manageNLB(...args); +const manageMCNLB = (...args) => window.manageMCNLB && window.manageMCNLB(...args); +const manageVPN = (...args) => window.manageVPN && window.manageVPN(...args); +const showDnsManagementModal = (...args) => window.showDnsManagementModal && window.showDnsManagementModal(...args); +const showGatewayModal = (...args) => window.showGatewayModal && window.showGatewayModal(...args); +const showMap = (...args) => window.showMap && window.showMap(...args); +const pollExternalRequests = (...args) => window.pollExternalRequests && window.pollExternalRequests(...args); +const handleInfraWithoutNodes = (...args) => window.handleInfraWithoutNodes && window.handleInfraWithoutNodes(...args); +const guiActivityStart = (...args) => window.guiActivityStart && window.guiActivityStart(...args); +const guiActivityEnd = (...args) => window.guiActivityEnd && window.guiActivityEnd(...args); +const isNormalInteger = (str) => { + var n = Math.floor(Number(str)); + return n !== Infinity && String(n) === str && n > 0; +}; +window.isNormalInteger = isNormalInteger; + +var refreshInterval = 5; +window.refreshInterval = refreshInterval; + +var xRequestIds = []; +window.xRequestIds = xRequestIds; + +var infraHideList = []; +window.infraHideList = infraHideList; + +const displayJsonData = (...args) => window.displayJsonData ? window.displayJsonData(...args) : console.log(...args); +const hudChipsContainer = (...args) => window.hudChipsContainer ? window.hudChipsContainer(...args) : null; +const changePoints = (...args) => window.changePoints ? window.changePoints(...args) : null; +const updateNodeGroupReview = (...args) => window.updateNodeGroupReview && window.updateNodeGroupReview(...args); + + +useGeographic(); +var i, j; +var cnti, cntj; + +function getActionAnimation(targetAction) { + if (!targetAction || targetAction === "None" || targetAction === "") { + return ""; + } + + const spinChars = ['◐', '◓', '◑', '◒']; + const index = Math.floor(Date.now() / 150) % spinChars.length; + return ' ' + spinChars[index]; +} + +// Get color for target action spinner +function getTargetActionColor(targetAction) { + if (!targetAction) return [0, 0, 0, 1]; // black for default/no action + + const action = targetAction.toLowerCase(); + + switch (action) { + case 'create': + return [59, 130, 246, 1]; // blue #3b82f6 + case 'terminate': + return [239, 68, 68, 1]; // red #ef4444 + case 'suspend': + return [107, 114, 128, 1]; // gray #6b7280 + case 'resume': + return [59, 130, 246, 1]; // blue #3b82f6 + case 'restart': + case 'reboot': + return [249, 115, 22, 1]; // orange #f97316 + default: + return [0, 0, 0, 1]; // black for unknown actions + } +} + +// Map-based Infra render data store: Map +// Each entry holds all render data for a single Infra, enabling O(1) add/remove. +// InfraRenderData = { id, name, status, targetAction, geometry, geometryPoints, geo, isLocationless } +// Note: Use globalThis.Map to avoid collision with OpenLayers' Map import (ol/Map) +var infraRenderMap = new globalThis.Map(); +window.infraRenderMap = infraRenderMap; + +// Constants for positioning locationless Infras (preparing, prepared, failed, empty states) +// Infra name/status label placement (all in screen pixels, so the clearance +// from the Node icons is the same at every zoom level). +// Located Infra: the label block sits BELOW the bottommost Node, centred on the +// mean Node x. The VM icon's lower half (~19px) and the status badge (~18px) +// end just under the icon centre, so 24px clears them with a margin. When a +// NodeGroup label already hangs under that same Node, the block moves down by +// the chip height so the two never overlap. +const INFRA_LABEL_CLEARANCE_BELOW_PX = 24; +const INFRA_LABEL_LINE_GAP_PX = 6; +// Preferred placement is the centre of the Node cluster (mean x / mean y). It +// is used only when no Node icon would be covered; otherwise the block falls +// back to hanging below the bottommost Node. Icon box around a Node centre +// (px): VM icon ±19, provider icon up to 27 above, status badge to 24 below. +const NODE_ICON_HALF_W_PX = 19; +const NODE_ICON_UP_PX = 27; +const NODE_ICON_DOWN_PX = 24; +const INFRA_LABEL_CENTER_MARGIN_PX = 4; +// Infra without Node locations (Preparing/Prepared/Failed/Empty): docked in a +// pixel-anchored list at the top-left of the map, to the right of the +// OpenLayers zoom control (~50px wide), each with a placeholder marker. +const LOCATIONLESS_DOCK_LEFT_PX = 64; +const LOCATIONLESS_DOCK_TOP_PX = 22; +const LOCATIONLESS_DOCK_GAP_PX = 14; +const LOCATIONLESS_MARKER_RADIUS_PX = 9; + +var k8sName = new Array(); +var k8sStatus = new Array(); +var k8sCoords = new Array(); // Store individual coordinates for text rendering +var k8sClusterGroups = new Array(); // Store cluster group polygons (from clustergroup label) +var k8sClusterGroupNames = new Array(); // Store cluster group names + +// Infra VNet cluster visualization storage +var infraClusterPolygons = new globalThis.Map(); // Map> +var infraClusterNames = new globalThis.Map(); // Map> +var infraClusterColors = new globalThis.Map(); // Map> + +// Infra NodeGroup visualization storage +var infraNodeGroupPolygons = new globalThis.Map(); // Map> +// Vertical gap (px) from a Node icon's center to the top of its NodeGroup label: +// clears the icon's lower half (~19px) and the status badge (~18px) with margin. +const NODEGROUP_LABEL_OFFSET_PX = 24; +const NODEGROUP_LABEL_FONT = 'bold 13px sans-serif'; +const NODEGROUP_LABEL_FONT_PX = 13; +const NODEGROUP_CHIP_PAD_X = 6; +const NODEGROUP_CHIP_PAD_Y = 3; +// Chip background behind each NodeGroup label. The immediate renderer used in +// drawObjects ignores Text.backgroundFill/padding, so the chip is a cached +// canvas drawn once per label text and handed to OpenLayers as an Icon. +const nodeGroupChipCache = new globalThis.Map(); // "text|r,g,b" -> Icon +function getNodeGroupLabelChip(text, borderRgb) { + const key = text + '|' + borderRgb.join(','); + let icon = nodeGroupChipCache.get(key); + if (icon) return icon; + if (nodeGroupChipCache.size > 500) nodeGroupChipCache.clear(); + + const ratio = 2; // render at 2x and scale down for crisp edges on HiDPI + const border = 1.5, radius = 6; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = NODEGROUP_LABEL_FONT; + const w = Math.ceil(ctx.measureText(text).width) + NODEGROUP_CHIP_PAD_X * 2; + const h = NODEGROUP_LABEL_FONT_PX + NODEGROUP_CHIP_PAD_Y * 2 + 2; + canvas.width = (w + border * 2) * ratio; + canvas.height = (h + border * 2) * ratio; + ctx.scale(ratio, ratio); + ctx.translate(border, border); + ctx.beginPath(); + ctx.moveTo(radius, 0); + ctx.lineTo(w - radius, 0); + ctx.arcTo(w, 0, w, radius, radius); + ctx.lineTo(w, h - radius); + ctx.arcTo(w, h, w - radius, h, radius); + ctx.lineTo(radius, h); + ctx.arcTo(0, h, 0, h - radius, radius); + ctx.lineTo(0, radius); + ctx.arcTo(0, 0, radius, 0, radius); + ctx.closePath(); + ctx.fillStyle = 'rgba(255,255,255,0.88)'; + ctx.fill(); + ctx.lineWidth = border; + ctx.strokeStyle = `rgba(${borderRgb[0]},${borderRgb[1]},${borderRgb[2]},0.9)`; + ctx.stroke(); + + icon = new Icon({ + img: canvas, + scale: 1 / ratio, + anchor: [0.5, 0], // top-centre of the chip sits on the anchor point... + anchorXUnits: 'fraction', + anchorYUnits: 'fraction', + displacement: [0, -NODEGROUP_LABEL_OFFSET_PX], // ...then shifted down below the Node icon + }); + nodeGroupChipCache.set(key, icon); + return icon; +} +var infraNodeGroupNames = new globalThis.Map(); // Map> +var infraNodeGroupColors = new globalThis.Map(); // Map> + +var cspListDisplayEnabled = document.getElementById("displayOn"); +var recommendPolicy = document.getElementById("recommendPolicy"); +var selectApp = document.getElementById("selectApp"); +var showInfraClusterLabels = false; +var showInfraNodeGroupLabels = false; + +// Configuration variables (previously from removed form elements) +// Bracket bare IPv6 literals so http://${host}:${port} URLs stay valid +function normalizeHostname(h) { + return h.includes(":") && !h.startsWith("[") ? "[" + h + "]" : h; +} +// Default hostname follows the page origin so remote/ingress access works without manual setup +var configHostname = normalizeHostname(window.location.hostname || "localhost"); +var configPort = "1323"; +var configUsername = "default"; +var configPassword = "default"; +var configCredentialHolder = "admin"; +var configNamespace = window.configNamespace || "default"; +window.configNamespace = configNamespace; +var cachedNamespaceList = window.cachedNamespaceList || []; +window.cachedNamespaceList = cachedNamespaceList; + +// Persist API endpoint settings across reloads (password excluded) +// Values are validated on load to keep stored strings out of HTML/URL injection range +const API_CONFIG_KEY = "mapui-api-config"; +const isValidHostname = (v) => typeof v === "string" && /^[A-Za-z0-9.\-:[\]]{1,253}$/.test(v); +const isValidPort = (v) => typeof v === "string" && /^\d{1,5}$/.test(v) && Number(v) <= 65535; +const isValidName = (v) => typeof v === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(v); +try { + const saved = JSON.parse(localStorage.getItem(API_CONFIG_KEY) || "{}"); + if (isValidHostname(saved.hostname)) configHostname = normalizeHostname(saved.hostname); + if (isValidPort(saved.port)) configPort = saved.port; + if (isValidName(saved.username)) configUsername = saved.username; + if (isValidName(saved.credentialHolder)) configCredentialHolder = saved.credentialHolder; +} catch (e) { + console.warn("Failed to load saved API config:", e); +} + +function saveApiConfig() { + try { + localStorage.setItem(API_CONFIG_KEY, JSON.stringify({ + hostname: configHostname, + port: configPort, + username: configUsername, + credentialHolder: configCredentialHolder, + apiBaseUrl: configApiBaseUrl, + })); + } catch (e) { + console.warn("Failed to save API config:", e); + } +} + +// --- API base URL (single-entrypoint / same-origin support) ----------------- +// All Tumblebug API calls go through tbApiBase(). Default behavior: +// - mapui on its canonical port 1324 (compose / direct port-forward): +// legacy model http://:/tumblebug (unchanged) +// - any other port (served behind a gateway, e.g. Gateway API entrypoint): +// same-origin /tumblebug (fixes CORS/mixed-content, no setup) +// A persisted explicit Base URL (settings popup) overrides both. +const isValidBaseUrl = (v) => + typeof v === "string" && + /^https?:\/\/[A-Za-z0-9.\-:[\]]+(\/[A-Za-z0-9._\-/]*)?$/.test(v) && + !/[<>"'\s]/.test(v); +var configApiBaseUrl = ""; +try { + const savedBase = JSON.parse(localStorage.getItem(API_CONFIG_KEY) || "{}").apiBaseUrl; + if (isValidBaseUrl(savedBase)) configApiBaseUrl = savedBase.replace(/\/+$/, ""); +} catch (e) { /* ignore */ } +if (!configApiBaseUrl && window.location.port !== "1324" && window.location.protocol !== "file:") { + configApiBaseUrl = `${window.location.protocol}//${window.location.host}/tumblebug`; +} +function tbApiBase() { + return configApiBaseUrl || `http://${configHostname}:${configPort}/tumblebug`; +} + +// Helper function to get current configuration +function getConfig() { + return { + hostname: configHostname, + port: configPort, + // Gateway/base URL override (e.g. https://host/tumblebug). When set, callers + // must prefer this over hostname:port so requests go through the gateway + // instead of the direct TB port, which may be unreachable from the browser. + apiBaseUrl: configApiBaseUrl, + username: configUsername, + password: configPassword, + credentialHolder: configCredentialHolder, + namespace: configNamespace + }; +} +window.getConfig = getConfig; + +// Axios interceptor: inject X-Credential-Holder header into all requests +axios.interceptors.request.use(function (axiosConfig) { + if (configCredentialHolder && configCredentialHolder !== "") { + if (!axiosConfig.headers) { + axiosConfig.headers = {}; + } + axiosConfig.headers["X-Credential-Holder"] = configCredentialHolder; + } + return axiosConfig; +}); + +var infraidElement = document.getElementById("infraid"); + +// Central Data Store for sharing with Dashboard +window.cloudBaristaCentralData = { + infraData: [], + vmData: [], + resourceData: {}, + vNet: [], + securityGroup: [], + sshKey: [], + k8sCluster: [], + connection: [], + vpn: [], + nlb: [], + customImage: [], + dataDisk: [], + objectStorage: [], + sqlDb: [], + lastUpdated: null, + subscribers: [], + // API status tracking for better error handling + apiStatus: { + k8sCluster: 'unknown', // 'loading', 'success', 'error', 'unknown' + lastK8sClusterUpdate: null, + lastK8sClusterError: null + } +}; + +// Subscribe to data updates +window.subscribeToDataUpdates = function(callback) { + window.cloudBaristaCentralData.subscribers.push(callback); +}; + +// Notify all subscribers when data changes +function notifyDataSubscribers() { + window.cloudBaristaCentralData.lastUpdated = new Date(); + window.cloudBaristaCentralData.subscribers.forEach(callback => { + try { + callback(window.cloudBaristaCentralData); + } catch (error) { + console.log('Error notifying subscriber:', error); + } + }); +} + +// Initialize map's Last Updated display (no-op: timestamp removed from banner) +function initializeMapLastUpdated() { + // Timestamp display was removed from the map controls banner. + // This function is kept as a no-op to avoid breaking callers. +} + +// Update map connection status +function updateMapConnectionStatus(status) { + const statusElement = document.getElementById('mapConnectionStatus'); + if (!statusElement) return; + + // Set consistent styling for all states (icon-only) + statusElement.style.fontSize = '10px'; + statusElement.style.textAlign = 'center'; + statusElement.style.display = 'inline-block'; + + switch (status) { + case 'connected': + statusElement.className = 'badge badge-success'; + statusElement.innerHTML = ''; + statusElement.title = 'Connected'; + break; + case 'connecting': + statusElement.className = 'badge badge-warning'; + statusElement.innerHTML = ''; + statusElement.title = 'Updating'; + break; + case 'disconnected': + statusElement.className = 'badge badge-danger'; + statusElement.innerHTML = ''; + statusElement.title = 'No Data'; + break; + default: + statusElement.className = 'badge badge-secondary'; + statusElement.innerHTML = ''; + statusElement.title = 'Unknown'; + } +} + +// Show/hide map refresh indicator +function showMapRefreshIndicator(show) { + const indicator = document.getElementById('mapRefreshIndicator'); + if (indicator) { + indicator.style.visibility = show ? 'visible' : 'hidden'; + } +} + +function updateRunningCostDisplay(infraList) { + hudChipsContainer(); // adopt the cost pill into the shared bottom-right chip stack + const el = document.getElementById('running-cost-display'); + const valEl = document.getElementById('running-cost-value'); + const badgeEl = document.getElementById('running-cost-unknown-badge'); + if (!el || !valEl || !badgeEl) return; + + let total = 0; + let runningCount = 0; + let unknownCount = 0; + + (infraList || []).forEach(infra => { + (infra.node || []).forEach(nd => { + if (nd.status !== 'Running') return; + runningCount++; + const cost = nd.spec?.costPerHour; + if (cost == null || cost < 0) { + unknownCount++; + } else { + total += cost; + } + }); + }); + + if (runningCount === 0) { + el.style.display = 'none'; + return; + } + + el.style.display = 'block'; + valEl.textContent = `$${total.toFixed(4)}/h+`; + if (unknownCount > 0) { + badgeEl.style.display = 'inline'; + badgeEl.title = `${unknownCount} running node${unknownCount > 1 ? 's have' : ' has'} no cost info`; + } else { + badgeEl.style.display = 'none'; + } +} + +// Show map settings +function showMapSettings() { + // Get current refresh interval from global variable + const currentRefreshInterval = refreshInterval.toString(); + + // Define available refresh intervals + const intervals = [1, 5, 10, 20, 30, 40, 50, 100]; + + // Generate interval pill options + const intervalPills = intervals.map(interval => { + return ``; + }).join(''); + + // CSP icon mode + const curIconMode = window.cspIconMode || cspIconMode || 'logo'; + const infraClusterLabelChecked = showInfraClusterLabels ? 'checked' : ''; + const infraNodeGroupLabelChecked = showInfraNodeGroupLabels ? 'checked' : ''; + + // Build namespace options + const activeNsList = (window.cachedNamespaceList && window.cachedNamespaceList.length > 0) ? window.cachedNamespaceList : (cachedNamespaceList || []); + const curNs = window.configNamespace || configNamespace || 'default'; + const nsOptions = activeNsList.map(ns => { + const safeNs = window.escapeHtml ? window.escapeHtml(ns) : ns; + const selected = ns === curNs ? 'selected' : ''; + return ``; + }).join(''); + const nsSelectHtml = nsOptions || ``; + + // Build credential holder options + const cachedHolders = window.cachedCredentialHolderList || (typeof cachedCredentialHolderList !== 'undefined' ? cachedCredentialHolderList : []); + const holderOptions = cachedHolders.map(holder => { + const holderId = window.escapeHtml(holder.credentialHolder || holder.id || ''); + const connCount = holder.verifiedConnectionCount || holder.connectionCount || 0; + const providers = window.escapeHtml((holder.providers || []).join(', ')); + const selected = (holder.credentialHolder || holder.id || '') === configCredentialHolder ? 'selected' : ''; + return ``; + }).join(''); + const holderSelectHtml = holderOptions || ``; + + Swal.fire({ + title: '', + html: ` + +
    + +
    Settings
    +
    + +
    +
    Namespace
    + +
    Active namespace for Provision and Control panels
    +
    + +
    + +
    +
    Credential Holder
    + +
    Filter connections and map icons by holder
    +
    + +
    + +
    +
    Refresh Interval
    +
    ${intervalPills}
    +
    + +
    + +
    +
    CSP Icon Style
    + +
    + ${Object.keys(window.cspGenericColors || {}).map(csp => { + const color = (window.cspGenericColors || {})[csp]; + return '' + + '' + + csp.toUpperCase() + ''; + }).join('')} +
    +
    + +
    + +
    +
    Cluster Labels
    + +
    Display cluster IDs above cluster boundaries
    +
    + +
    +
    NodeGroup Labels
    + +
    Display nodegroup IDs above nodegroup boundaries
    +
    + `, + showCancelButton: true, + confirmButtonText: 'Apply', + cancelButtonText: 'Cancel', + customClass: { + popup: 'swal2-popup', + confirmButton: 'swal2-confirm', + cancelButton: 'swal2-cancel' + }, + width: 380, + preConfirm: () => { + const selectedInterval = document.querySelector('input[name="refreshInterval"]:checked'); + if (!selectedInterval) { + Swal.showValidationMessage('Please select a refresh interval'); + return false; + } + const selectedIconMode = document.getElementById('cspIconModeSelect')?.value || 'logo'; + const infraClusterLabelEnabled = document.getElementById('infraClusterLabelToggle')?.checked || false; + const infraNodeGroupLabelEnabled = document.getElementById('infraNodeGroupLabelToggle')?.checked || false; + const selectedHolder = document.getElementById('settings-credentialHolder')?.value || configCredentialHolder; + const selectedNs = document.getElementById('settings-namespace')?.value || configNamespace; + return { + refreshInterval: selectedInterval.value, + iconMode: selectedIconMode, + infraClusterLabels: infraClusterLabelEnabled, + infraNodeGroupLabels: infraNodeGroupLabelEnabled, + credentialHolder: selectedHolder, + namespace: selectedNs + }; + } + }).then((result) => { + if (result.isConfirmed) { + const newRefreshInterval = parseInt(result.value.refreshInterval); + const holderChanged = result.value.credentialHolder !== configCredentialHolder; + + // Update global refresh interval variable + refreshInterval = newRefreshInterval; + + // Update CSP icon mode + cspIconMode = result.value.iconMode || 'logo'; + window.cspIconMode = cspIconMode; + showInfraClusterLabels = result.value.infraClusterLabels; + showInfraNodeGroupLabels = result.value.infraNodeGroupLabels; + // Clear all cached generic styles so they are regenerated with the new mode + if (typeof window.clearCspStyleCaches === 'function') { + window.clearCspStyleCaches(); + } else { + const cspGenericStyles = window.cspGenericStyles || {}; + const nodeGenericCloudStyleCache = window.nodeGenericCloudStyleCache || {}; + const cspDcStyles = window.cspDcStyles || {}; + const nodeDcStyleCache = window.nodeDcStyleCache || {}; + Object.keys(cspGenericStyles).forEach(k => delete cspGenericStyles[k]); + Object.keys(nodeGenericCloudStyleCache).forEach(k => delete nodeGenericCloudStyleCache[k]); + Object.keys(cspDcStyles).forEach(k => delete cspDcStyles[k]); + Object.keys(nodeDcStyleCache).forEach(k => delete nodeDcStyleCache[k]); + } + // Force map re-render to apply icon change + map.render(); + const view = map.getView(); + if (view) view.changed(); + + // Apply namespace change + const curConfigNs = window.configNamespace || configNamespace; + const nsChanged = result.value.namespace !== curConfigNs; + if (nsChanged) { + configNamespace = result.value.namespace; + window.configNamespace = result.value.namespace; + if (typeof applyNamespace === 'function') { + applyNamespace(result.value.namespace); + } else if (typeof window.applyNamespace === 'function') { + window.applyNamespace(result.value.namespace); + } + if (typeof getInfra === 'function') { + getInfra(); + } else if (typeof window.getInfra === 'function') { + window.getInfra(); + } + } + + // Apply credential holder change (triggers connection reload + map refresh) + if (holderChanged) { + configCredentialHolder = result.value.credentialHolder; + window.configCredentialHolder = result.value.credentialHolder; + if (typeof applyCredentialHolder === 'function') { + applyCredentialHolder(result.value.credentialHolder); + } else if (typeof window.applyCredentialHolder === 'function') { + window.applyCredentialHolder(result.value.credentialHolder); + } + } + + // Show brief confirmation + var statusParts = []; + statusParts.push(`NS: ${result.value.namespace}`); + statusParts.push(`Holder: ${result.value.credentialHolder}`); + statusParts.push(`Refresh: ${newRefreshInterval}s`); + if (cspIconMode !== 'logo') statusParts.push('Icons: ' + cspIconMode); + if (showInfraClusterLabels) statusParts.push('Cluster labels: ON'); + if (showInfraNodeGroupLabels) statusParts.push('NodeGroup labels: ON'); + + Swal.fire({ + icon: 'success', + text: statusParts.join(' · '), + timer: 1500, + showConfirmButton: false, + toast: true, + position: 'top-end' + }); + } + }); +} + +// Performance monitoring and memory management +let mapPerformanceMetrics = { + layerCount: 0, + featureCount: 0, + lastCleanupTime: Date.now(), + renderCount: 0 +}; + +// Map performance cleanup function +function performMapCleanup() { + const now = Date.now(); + const timeSinceLastCleanup = now - mapPerformanceMetrics.lastCleanupTime; + + // Run cleanup every 10 minutes or when layer count is high + if (timeSinceLastCleanup > 600000 || mapPerformanceMetrics.layerCount > 50) { + debugLog.performance('Running map cleanup...'); + + // Count current layers + let currentLayerCount = 0; + map.getLayers().forEach(() => currentLayerCount++); + + // If too many layers, clear and refresh + if (currentLayerCount > 50) { + debugLog.performance(`Too many layers (${currentLayerCount}), clearing map...`); + clearMap(); + } + + mapPerformanceMetrics.lastCleanupTime = now; + mapPerformanceMetrics.layerCount = currentLayerCount; + + debugLog.performance(`Cleanup completed. Current layers: ${currentLayerCount}`); + } +} + +// Map cleanup on page unload +function performMapFinalCleanup() { + debugLog.performance('Performing final map cleanup...'); + + // Clear all timers + if (window.mapRenderTimeout) { + clearTimeout(window.mapRenderTimeout); + } + + // Clear map properly + clearMap(); + + // Reset performance metrics + mapPerformanceMetrics = { + layerCount: 0, + featureCount: 0, + lastCleanupTime: Date.now(), + renderCount: 0 + }; + + debugLog.performance('Final cleanup completed'); +} + +// Add cleanup events +window.addEventListener('beforeunload', performMapFinalCleanup); +window.addEventListener('unload', performMapFinalCleanup); +window.addEventListener('pagehide', performMapFinalCleanup); + +// Periodic map performance monitoring +setInterval(performMapCleanup, 300000); // Check every 5 minutes + +// Export functions for global access +window.updateMapConnectionStatus = updateMapConnectionStatus; +window.showMapSettings = showMapSettings; +window.showMapRefreshIndicator = showMapRefreshIndicator; +window.performMapCleanup = performMapCleanup; +window.getInfra = getInfra; + +const typeStringConnection = "connection"; +const typeStringProvider = "provider"; +const typeStringImage = "image"; +const typeStringSpec = "spec"; +const typeStringSG = "securityGroup"; +const typeStringSshKey = "sshKey"; +const typeStringVNet = "vNet"; +const typeInfo = "info"; +const typeError = "error"; + +var tileLayer = new TileLayer({ + source: new OSM(), +}); + +/* + * Create the map. + */ +var map = new Map({ + layers: [tileLayer], + target: "map", + view: new View({ + center: [30, 30], + zoom: 3, + }), + //projection: 'EPSG:4326' +}); +window.map = map; + +// Optimized clearMap function to prevent memory leaks +function clearMap() { + debugLog.mapOp("Map cleared - optimized"); + + // Clear Infra render data + infraRenderMap.clear(); + + // Clear resource location data + geoResourceLocation.k8s = []; + geoResourceLocation.sg = []; + geoResourceLocation.sshKey = []; + geoResourceLocation.vnet = []; + geoResourceLocation.vpn = []; + + // Remove all layers except the base tile layer to prevent memory leaks + const layersToRemove = []; + map.getLayers().forEach(function(layer) { + if (layer !== tileLayer) { + layersToRemove.push(layer); + } + }); + + layersToRemove.forEach(function(layer) { + map.removeLayer(layer); + if (layer.getSource && typeof layer.getSource === 'function') { + const source = layer.getSource(); + if (source && source.clear && typeof source.clear === 'function') { + source.clear(); + } + if (source && source.getFeatures && typeof source.getFeatures === 'function') { + const features = source.getFeatures(); + features.forEach(feature => { + if (feature.dispose && typeof feature.dispose === 'function') { + feature.dispose(); + } + }); + } + } + }); + + debugLog.performance(`Removed ${layersToRemove.length} layers`); + map.render(); +} +window.clearMap = clearMap; + +function clearCircle(option) { + //document.getElementById("latLonInputPairArea").innerHTML = ''; + if (option == "clearText") { + debugLog.mapOp("Circle configuration cleared"); + } + latLonInputPairIdx = 0; + window.latLonInputPairIdx = 0; + nodeGroupRequestFromSpecList.length = 0; + recommendedSpecList.length = 0; + window.nodeGroupRequestFromSpecList = nodeGroupRequestFromSpecList; + window.recommendedSpecList = recommendedSpecList; + renderMapFromConfig(); + + // Update NodeGroup review panel + if (typeof window.updateNodeGroupReview === 'function') { + window.updateNodeGroupReview(); + } else if (typeof updateNodeGroupReview === 'function') { + updateNodeGroupReview(); + } +} +window.clearCircle = clearCircle; + +// Asks before wiping the whole MC-Infra configuration (all NodeGroups at once). +// Individual NodeGroup removal stays confirmation-free — this guards only the +// bulk clear. Skips the prompt when there is nothing to clear. +function confirmClearInfraConfiguration() { + const currentList = window.nodeGroupRequestFromSpecList || nodeGroupRequestFromSpecList; + if (!currentList || currentList.length === 0) { + clearCircle("clearText"); + return; + } + Swal.fire({ + title: 'Remove Infra Configuration?', + text: `This will remove all ${currentList.length} configured NodeGroup(s).`, + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Yes, Remove All', + cancelButtonText: 'Cancel', + confirmButtonColor: '#dc3545' + }).then((result) => { + if (result.isConfirmed) { + clearCircle("clearText"); + } + }); +} +window.confirmClearInfraConfiguration = confirmClearInfraConfiguration; + +function writeLatLonInputPair(idx, lat, lon) { + var fn = window.getRecommendedSpec || (typeof getRecommendedSpec === 'function' ? getRecommendedSpec : null); + var recommendedSpec = fn ? fn(idx, lat, lon) : null; + var latf = lat.toFixed(4); + var lonf = lon.toFixed(4); + + //document.getElementById("latLonInputPairArea").innerHTML += + `Node ${idx + 1}: (${latf}, ${lonf}) / `; + if (idx == 0) { + debugLog.mapOp("Started Infra configuration"); + } + debugLog.mapOp(`Node-${idx + 1} Location: ${latf}, ${lonf} | Best Spec: `); +} +window.writeLatLonInputPair = writeLatLonInputPair; + +var latLonInputPairIdx = 0; +var nodeGroupRequestFromSpecList = new Array(); +var recommendedSpecList = new Array(); +window.latLonInputPairIdx = latLonInputPairIdx; +window.nodeGroupRequestFromSpecList = nodeGroupRequestFromSpecList; +window.recommendedSpecList = recommendedSpecList; + +map.on("singleclick", function (event) { + const coord = event.coordinate; + + // Activate provision-tab when user clicks on map to place circle + try { + // Remove active class from all tabs + document.querySelectorAll('.nav-link').forEach(tab => { + tab.classList.remove('active'); + }); + document.querySelectorAll('.tab-pane').forEach(pane => { + pane.classList.remove('show', 'active'); + }); + + // Activate provision-tab + const provisionTab = document.getElementById('provision-tab'); + const provisionPane = document.getElementById('provision'); + + if (provisionTab && provisionPane) { + provisionTab.classList.add('active'); + provisionPane.classList.add('show', 'active'); + + // Trigger Bootstrap tab shown event if needed + if (typeof $ !== 'undefined' && $.fn && $.fn.tab) { + $(provisionTab).tab('show'); + } + } + } catch (error) { + console.log('Failed to activate provision tab:', error); + } + + let currentIdx = (typeof window.latLonInputPairIdx === 'number') ? window.latLonInputPairIdx : latLonInputPairIdx; + writeLatLonInputPair(currentIdx, coord[1], coord[0]); + currentIdx++; + latLonInputPairIdx = currentIdx; + window.latLonInputPairIdx = currentIdx; +}); + +// Right-click context menu for Infra control +map.on("contextmenu", function (event) { + event.preventDefault(); // Prevent default browser context menu + + const coord = event.coordinate; + const nearestInfra = findNearestInfra(coord); + + if (nearestInfra) { + showInfraContextMenu(event.pixel, nearestInfra); + } else { + // Show general utility menu when no Infra is nearby + Swal.fire({ + title: '🛠️ Utilities', + html: ` +
    + + + +
    + `, + showConfirmButton: false, + showCancelButton: true, + cancelButtonText: '❌ Close', + width: '400px', + customClass: { + popup: 'swal2-infra-context' + } + }); + } +}); + +// Mouse hover effect to show when Infra is selectable +map.on("pointermove", function (event) { + const coord = event.coordinate; + const nearestInfra = findNearestInfra(coord); + const mapElement = map.getTargetElement(); + const tooltip = document.getElementById('mouseTooltip'); + + if (nearestInfra) { + // Change cursor to pointer when Infra is nearby + mapElement.style.cursor = 'pointer'; + + // Update tooltip to show Infra name and hint + if (tooltip) { + tooltip.innerHTML = `➕ ┃ 🕹️ ${nearestInfra.name}`; + } + } else { + // Reset cursor to default crosshair + mapElement.style.cursor = 'crosshair'; + + // Reset tooltip to original content + if (tooltip) { + tooltip.innerHTML = '➕ ┃ 🕹️'; + } + } +}); + +function findNearestInfra(clickCoord) { + let nearestInfra = null; + let minDistance = Infinity; + + const clickPixel = map.getPixelFromCoordinate(clickCoord); + + // Search through all Infra entries in the render map. Distance is measured to + // the centre of the label block exactly as drawObjects lays it out. + for (const [infraId, data] of infraRenderMap) { + if (!data.name) continue; + const layout = getInfraLabelLayout(data); + if (!layout) continue; + const anchorPixel = map.getPixelFromCoordinate(layout.anchor); + if (!anchorPixel) continue; + const textPixel = [anchorPixel[0] + layout.center[0], anchorPixel[1] + layout.center[1]]; + const dx = clickPixel[0] - textPixel[0]; + const dy = clickPixel[1] - textPixel[1]; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < minDistance) { + minDistance = distance; + nearestInfra = { + name: data.name, + status: data.status, + id: infraId, + distance: distance + }; + } + } + + return (minDistance < 100) ? nearestInfra : null; +} + +// Store the Infra selected via right-click context menu +let contextMenuSelectedInfra = null; + +// Helper function to get the currently selected Infra ID +// Prioritizes context menu selection over dropdown selection +function getSelectedInfraId() { + if (contextMenuSelectedInfra) { + return contextMenuSelectedInfra; + } + return infraidElement ? infraidElement.value : null; +} + +// Function to show Infra context menu +function showInfraContextMenu(pixel, infraInfo) { + // Store the selected Infra for use in control actions + contextMenuSelectedInfra = infraInfo.name; + + // Namespace is managed globally via configNamespace + + // Set the selected Infra in the control panel + const infraSelect = document.getElementById('infraid'); + if (infraSelect) { + // Check if the Infra option exists, if not, add it + let optionExists = false; + for (let option of infraSelect.options) { + if (option.value === infraInfo.name) { + optionExists = true; + break; + } + } + if (!optionExists) { + // Add the Infra option if it doesn't exist (for failed Infras that weren't loaded to control tab) + const newOption = document.createElement('option'); + newOption.value = infraInfo.name; + newOption.text = infraInfo.name; + infraSelect.add(newOption); + } + // Set value without triggering change event + infraSelect.value = infraInfo.name; + } + + // Show context menu using SweetAlert + Swal.fire({ + title: `🕹️ Control Infra: ${infraInfo.name}`, + html: ` +
    +

    Status: ${infraInfo.status}

    +

    Distance: ${infraInfo.distance.toFixed(3)} units

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    + `, + showConfirmButton: false, + showCancelButton: true, + cancelButtonText: '❌ Close', + width: '850px', + customClass: { + popup: 'swal2-infra-context' + }, + didOpen: (popup) => { + const infraName = infraInfo.name; + popup.querySelectorAll('.btn-infra-action').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.action; + // Functions that open their own Swal (no explicit close needed) + if (action === 'control') { showActionsMenu(); return; } + if (action === 'nlb') { manageNLB(); return; } + if (action === 'mcnlb') { manageMCNLB(); return; } + if (action === 'vpn') { manageVPN(); return; } + if (action === 'delete') { executeAction('delete'); return; } + if (action === 'sshKeys') { downloadAllSshKeys(); return; } + // Functions that don't open Swal — close context menu after + if (action === 'scaleOut') scaleOutInfraFromContext(infraName); + else if (action === 'copyConfig') copyInfraConfig(infraName); + else if (action === 'saveTemplate') saveInfraAsTemplate(infraName); + else if (action === 'dns') showDnsManagementModal(infraName); + else if (action === 'gateway') showGatewayModal(infraName); + Swal.close(); + }); + }); + // Auto-span Delete Infra button to fill remaining columns in last row + const grid = popup.querySelector('.infra-context-grid'); + if (grid) { + const cols = 4; + const total = grid.children.length; + const span = cols - ((total - 1) % cols); + if (span > 1) grid.lastElementChild.style.gridColumn = 'span ' + span; + } + }, + willClose: () => { + // Clear context menu selection when popup closes + // This allows normal dropdown selection to work again + setTimeout(() => { + contextMenuSelectedInfra = null; + }, 100); + } + }); +} + +// Initialize an object to keep track of the active spinner tasks +let spinnerStack = {}; +// A counter to generate unique IDs for spinner tasks +let currentSpinnerId = 0; + +// Function to create a unique spinner task ID based on the function name +function generateSpinnerId(functionName) { + currentSpinnerId++; // Increment the ID + return "[" + currentSpinnerId + "] " + functionName; // Return the unique task ID +} + +// Add a task: render a live "GUI" card in the unified activity feed (top-right). +// The taskId/return contract is unchanged, so existing call sites are untouched. +function addSpinnerTask(functionName) { + const taskId = generateSpinnerId(functionName); + spinnerStack[taskId] = true; + guiActivityStart(taskId, functionName); // defined in the activity-feed module below + return taskId; +} + +// Remove a task: mark its activity card done so it settles and fades out. +// guiActivityEnd is idempotent, so this is safe to call more than once per task. +function removeSpinnerTask(taskId) { + delete spinnerStack[taskId]; + guiActivityEnd(taskId, true); +} +window.addSpinnerTask = addSpinnerTask; +window.removeSpinnerTask = removeSpinnerTask; + +// Display Icon for Cloud locations +const csvPath = + "https://raw.githubusercontent.com/cloud-barista/cb-tumblebug/main/assets/cloudlocation.csv"; +var cloudLocation = []; +var cspPointsCircle = []; +var geoCspPointsCircle = new Array(); +var geoResourceLocation = { + sshKey: [], + sg: [], + k8s: [], + vnet: [], + vpn: [] +}; +window.geoResourceLocation = geoResourceLocation; + +var cspPoints = {}; +var geoCspPoints = {}; +window.cspPoints = cspPoints; +window.geoCspPoints = geoCspPoints; + + +async function displayCSPListOn() { + const checkbox = typeof cspListDisplayEnabled !== 'undefined' ? cspListDisplayEnabled : document.getElementById('cspListDisplayEnabled'); + if (checkbox && checkbox.checked) { + cloudLocation = []; + try { + const response = await fetch(csvPath); + if (!response.ok) { + debugLog.resource('Failed to load cloud location CSV:', response.status); + return; + } + const text = await response.text(); + const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0); + if (lines.length > 1) { + const headers = lines[0].split(",").map(h => h.trim()); + for (let idx = 1; idx < lines.length; idx++) { + const values = lines[idx].split(",").map(v => v.trim()); + const row = {}; + headers.forEach((h, i) => { row[h] = values[i]; }); + cloudLocation.push(row); + } + } + debugLog.resource('Loaded cloud location data:', cloudLocation.length, 'regions'); + debugLog.mapOp("[Complete] Display Known Cloud Regions: " + cloudLocation.length); + + cloudLocation.forEach((location) => { + const { CloudType, Longitude, Latitude } = location; + if (!CloudType || !Longitude || !Latitude) return; + const cloudTypeLower = CloudType.toLowerCase(); + if (!cspPoints[cloudTypeLower]) { + cspPoints[cloudTypeLower] = []; + } + if (!geoCspPoints[cloudTypeLower]) { + geoCspPoints[cloudTypeLower] = []; + } + + cspPoints[cloudTypeLower].push([ + parseFloat(Longitude), + parseFloat(Latitude), + ]); + }); + + Object.keys(cspPoints).forEach((csp) => { + if (cspPoints[csp].length > 0) { + geoCspPoints[csp][0] = new MultiPoint(cspPoints[csp]); + } + }); + } catch (err) { + debugLog.resource('Error loading cloud location CSV:', err); + } + } else { + Object.keys(cspPoints).forEach((csp) => { + cspPoints[csp] = []; + geoCspPoints[csp] = []; + }); + } +} +window.displayCSPListOn = displayCSPListOn; + +function endpointChanged() { + //getInfra(); + var iframe = document.getElementById('iframe'); + var iframe2 = document.getElementById('iframe2'); + + // Same-origin: swagger.html is served by mapui itself (works at :1324 and behind a gateway) + iframe.src = "/swagger.html"; + iframe2.src = "http://" + configHostname + ":1024/spider/adminweb"; +} +window.endpointChanged = endpointChanged; + + +var alpha = 0.3; +var cororList = [ + [153, 255, 51, alpha], + [210, 210, 10, alpha], + [0, 176, 244, alpha], + [200, 10, 10, alpha], + [0, 162, 194, alpha], + [38, 63, 143, alpha], + [58, 58, 58, alpha], + [81, 45, 23, alpha], + [225, 136, 65, alpha], + [106, 34, 134, alpha], + [255, 162, 191, alpha], + [239, 45, 53, alpha], + [255, 255, 255, alpha], + [154, 135, 199, alpha], +]; + +alpha = 0.6; +var cororLineList = [ + [0, 255, 0, alpha], + [210, 210, 10, alpha], + [0, 176, 244, alpha], + [200, 10, 10, alpha], + [0, 162, 194, alpha], + [38, 63, 143, alpha], + [58, 58, 58, alpha], + [81, 45, 23, alpha], + [225, 136, 65, alpha], + [106, 34, 134, alpha], + [255, 162, 191, alpha], + [239, 45, 53, alpha], + [255, 255, 255, alpha], + [154, 135, 199, alpha], +]; + +var polygonFeature = new Feature( + new Polygon([ + [ + [10, -3], + [-5, 2], + [-1, 1], + ], + ]) +); + +function createStyle(src) { + return new Style({ + image: new Icon({ + anchor: [0.5, 0.5], + crossOrigin: "anonymous", + src: src, + imgSize: [50, 50], + scale: 0.1, + }), + }); +} + +// temporary point +var pnt = new Point([-68, -50]); + +addIconToMap("img/icon-vm.png", pnt, "001"); +var iconStyleNode = new Style({ + image: new Icon({ + crossOrigin: "anonymous", + src: "img/icon-vm.png", + opacity: 1.0, + scale: 0.7, + }), +}); +addIconToMap("img/icon-k8s.png", pnt, "001"); +var iconStyleK8s = new Style({ + image: new Icon({ + crossOrigin: "anonymous", + src: "img/icon-k8s.png", + opacity: 1.0, + scale: 0.7, + }), +}); + + +addIconToMap("img/icon-nlb.png", pnt, "001"); +var iconStyleNlb = new Style({ + image: new Icon({ + crossOrigin: "anonymous", + src: "img/icon-nlb.png", + opacity: 1.0, + scale: 0.8, + }), +}); +addIconToMap("img/icon-vpn.png", pnt, "001"); +var iconStyleVPN = new Style({ + image: new Icon({ + crossOrigin: "anonymous", + src: "img/icon-vpn.png", + opacity: 1.0, + scale: 0.8, + }), +}); + +addIconToMap("img/icon-vnet.png", pnt, "001"); +var iconStyleVnet = new Style({ + image: new Icon({ + crossOrigin: "anonymous", + src: "img/icon-vnet.png", + opacity: 1.0, + scale: 0.8, + }), +}); +addIconToMap("img/icon-sg.png", pnt, "001"); +var iconStyleSG = new Style({ + image: new Icon({ + crossOrigin: "anonymous", + src: "img/icon-sg.png", + opacity: 1.0, + scale: 0.8, + }), +}); +addIconToMap("img/icon-key.png", pnt, "001"); +var iconStyleKey = new Style({ + image: new Icon({ + crossOrigin: "anonymous", + src: "img/icon-key.png", + opacity: 1.0, + scale: 0.8, + }), +}); + +// Tombstone marker: a rose halo drawn behind the resource's normal icon, flagging a +// resource whose deletion was requested but is not yet confirmed gone on the CSP. +// Image-based (CircleStyle) so it renders on MultiPoint geometries like the icons do. +var tombstoneHaloStyle = new Style({ + image: new CircleStyle({ + radius: 22, + fill: new Fill({ color: "rgba(251,113,133,0.5)" }), + stroke: new Stroke({ color: "#e11d48", width: 3 }), + }), +}); + +// A resource is a deletion tombstone when its deletion was requested but not yet confirmed +function createIconStyle(imageSrc) { + return new Style({ + image: new Icon({ + crossOrigin: "anonymous", + src: imageSrc, + opacity: 1.0, + scale: 1.0, + }), + }); +} + +// cspIconStyles +const cspIconStyles = {}; +window.cspIconStyles = cspIconStyles; + +// addIconToMap +Object.keys(cspIconImg).forEach((csp) => { + cspIconStyles[csp] = createIconStyle(cspIconImg[csp]); +}); +// Optimized addIconToMap function to prevent layer accumulation +function addIconToMap(imageSrc, point, index) { + var vectorSource = new VectorSource({ projection: "EPSG:4326" }); + var iconFeature = new Feature(point); + iconFeature.set("style", createStyle(imageSrc)); + iconFeature.set("index", index); + vectorSource.addFeature(iconFeature); + var iconLayer = new VectorLayer({ + style: function (feature) { + return feature.get("style"); + }, + source: vectorSource, + }); + + // Set a unique identifier for this layer for potential cleanup + iconLayer.set('layerType', 'iconLayer'); + iconLayer.set('layerIndex', index); + + map.addLayer(iconLayer); + + // Update performance metrics + mapPerformanceMetrics.layerCount++; + mapPerformanceMetrics.featureCount++; + + // Use debounced render to improve performance + if (window.mapRenderTimeout) { + clearTimeout(window.mapRenderTimeout); + } + window.mapRenderTimeout = setTimeout(() => { + map.render(); + mapPerformanceMetrics.renderCount++; + }, 10); +} +Object.keys(cspIconImg).forEach((csp, index) => { + const iconIndex = index.toString().padStart(3, "0"); + addIconToMap(cspIconImg[csp], pnt, iconIndex); +}); + +// Known cloud platform names (used for platform-based fallback resolution) +const knownPlatforms = Object.keys(cspIconImg); + +/** + +// Create individual Node point with offset for status badge +function createNodePointWithOffset(coordinates, offsetX = 0.008, offsetY = 0.008) { + return new Point([coordinates[0] + offsetX, coordinates[1] + offsetY]); +} + +function changeSizeByName(status) { + if (status.includes("-best")) { + return 3.5; + } else if (status.includes("-df")) { + return 0.4; + } else if (status.includes("-ws")) { + return 0.4; + } else if (status.includes("NLB")) { + return 1.5; + } else { + return 2.5; + } +} + +/** + * Compute inter-Infra offset for Nodes at shared locations. + * When multiple Infras have Nodes at the same region, each Infra gets a directional + * offset so their Node icons don't fully overlap. + * @param {number} infraIndex - This Infra's index at the shared location (0-based) + * @param {number} totalInfras - Total Infras sharing this location + * @returns {{ox: number, oy: number}} offset in coordinate units + */ +function getInfraLocationOffset(infraIndex, totalInfras) { + if (totalInfras <= 1 || infraIndex === 0) return { ox: 0, oy: 0 }; + // Place Infras on a ring around the base location + const ringRadius = 1.5; // coordinate-space radius (scaled by zoom later) + const angleStep = 2 * Math.PI / totalInfras; + const startAngle = 3 * Math.PI / 2; // base angle; first offset (index=1) lands near top + const angle = startAngle + angleStep * infraIndex; + return { + ox: ringRadius * Math.cos(angle), + oy: ringRadius * Math.sin(angle) * 0.78 // compress Y for map projection + }; +} + +function returnAdjustmentPoint(index, totalNodes) { + // Initialize coordinates + let ax = 0.0; + let ay = 0.0; + + // First Node (index 0) is placed at center + if (index === 0) { + ax = 0; + ay = 0; + } else { + // Circle radius + const radius = 0.75; + + // Calculate angle step (divide 360° by total Nodes)) + const angleStep = 2 * Math.PI / totalNodes; + + // Start at 12 o'clock position + const startAngle = 3 * Math.PI / 2; + + // Calculate angle for current Node + const angle = startAngle + (angleStep * index); + + // Convert polar coordinates to Cartesian + ax = radius * Math.cos(angle); + ay = radius * Math.sin(angle); + } + + // Add small random offset to prevent exact overlapping + ax = ax + (Math.random() * 0.01); + ay = ay + (Math.random() * 0.01); + + // Compress y-axis for better map projection appearance + ay = ay * 0.78; + + return { ax, ay }; +} + +var n = 400; +var omegaTheta = 600000; // Rotation period in ms +var R = 7; +var r = 2; +var p = 2; + +var coordinates = []; +coordinates.push([-180, -90]); + +var coordinatesFromX = []; +coordinatesFromX.push([0]); +var coordinatesFromY = []; +coordinatesFromY.push([0]); + +var coordinatesToX = []; +coordinatesToX.push([1]); +var coordinatesToY = []; +coordinatesToY.push([1]); + +function makeTria(ip1, ip2, ip3) { + changePoints(ip1, ip2); + changePoints(ip2, ip3); + changePoints(ip3, ip1); + // makeTria is legacy/unused — kept for reference +} + +// Build Node dot geometry data for an Infra entry in infraRenderMap +function makePolyDot(infraEntry, nodePoints, nodeStatuses = [], nodeProviders = [], nodeCommandStatuses = []) { + var resourcePoints = []; + for (i = 0; i < nodePoints.length; i++) { + resourcePoints.push(nodePoints[i]); + } + infraEntry.geometryPoints = { + geometry: new MultiPoint(resourcePoints), + nodePoints: nodePoints, + nodeStatuses: nodeStatuses, + nodeProviders: nodeProviders, + nodeCommandStatuses: nodeCommandStatuses + }; +} + +// Build polygon geometry for an Infra entry in infraRenderMap +function makePolyArray(infraEntry, nodePoints) { + var resourcePoints = []; + for (i = 0; i < nodePoints.length; i++) { + resourcePoints.push(nodePoints[i]); + } + resourcePoints.push(nodePoints[0]); + infraEntry.geometry = new Polygon([resourcePoints]); + infraEntry.geo = new Polygon([resourcePoints]); + // Cache interior point for fast lookup in findNearestInfra (avoids recomputing on every pointermove) + const ip = infraEntry.geometry.getInteriorPoint().getCoordinates(); + infraEntry.anchorCoord = [ip[0], ip[1]]; +} + +// Label anchor for a located Infra: mean x of its Nodes, y of the bottommost +// Node. Computed once from the render points (not from the hull polygon), so it +// does not move with the zoom-dependent geometry simplification the immediate +// renderer applies before it picks a polygon's interior point. +function computeInfraLabelAnchor(nodePoints) { + if (!nodePoints || nodePoints.length === 0) return null; + let sumX = 0, minY = Infinity; + for (const p of nodePoints) { + sumX += p[0]; + if (p[1] < minY) minY = p[1]; + } + return [sumX / nodePoints.length, minY]; +} + +// Centre of the Node cluster (mean x / mean y) for the preferred label spot. +function computeInfraLabelCentroid(nodePoints) { + if (!nodePoints || nodePoints.length === 0) return null; + let sumX = 0, sumY = 0; + for (const p of nodePoints) { sumX += p[0]; sumY += p[1]; } + return [sumX / nodePoints.length, sumY / nodePoints.length]; +} + +// Text width in px for the given font, cached (called every frame). +const textWidthCache = new globalThis.Map(); +let textMeasureCtx = null; +function measureTextWidth(text, font) { + const key = font + '|' + text; + let w = textWidthCache.get(key); + if (w !== undefined) return w; + if (textWidthCache.size > 2000) textWidthCache.clear(); + if (!textMeasureCtx) textMeasureCtx = document.createElement('canvas').getContext('2d'); + textMeasureCtx.font = font; + w = textMeasureCtx.measureText(text).width; + textWidthCache.set(key, w); + return w; +} + +// True when a label block of blockW x blockH px centred on `centroid` would +// cover any Node icon of this Infra (all in screen pixels, current view). +function infraLabelCoversNodeIcon(data, centroid, blockW, blockH) { + const pts = data.geometryPoints && data.geometryPoints.nodePoints; + if (!pts || pts.length === 0) return true; + const c = map.getPixelFromCoordinate(centroid); + if (!c) return true; + const m = INFRA_LABEL_CENTER_MARGIN_PX; + const l = c[0] - blockW / 2 - m, r = c[0] + blockW / 2 + m; + const t = c[1] - blockH / 2 - m, b = c[1] + blockH / 2 + m; + // With NodeGroup labels on, a chip may hang under any Node: treat that + // strip as part of the icon so the centred block never covers a chip. + const down = showInfraNodeGroupLabels + ? NODEGROUP_LABEL_OFFSET_PX + NODEGROUP_LABEL_FONT_PX + NODEGROUP_CHIP_PAD_Y * 2 + 5 + : NODE_ICON_DOWN_PX; + for (const p of pts) { + const px = map.getPixelFromCoordinate(p); + if (!px) continue; + if (px[0] + NODE_ICON_HALF_W_PX > l && px[0] - NODE_ICON_HALF_W_PX < r && + px[1] + down > t && px[1] - NODE_ICON_UP_PX < b) { + return true; + } + } + return false; +} + +// Extra downward clearance for an Infra label when a NodeGroup label (drawn +// under each group's first Node) sits on the same bottom row as the anchor. +function nodeGroupLabelClearance(data, anchor) { + if (!showInfraNodeGroupLabels || !data || !data.id) return 0; + const polys = infraNodeGroupPolygons.get(data.id); + if (!Array.isArray(polys)) return 0; + const sameRow = polys.some((poly) => { + const a = poly && poly.get('labelAnchor'); + return a && Math.abs(a[1] - anchor[1]) < 1e-9; + }); + if (!sameRow) return 0; + // chip: border + padding + font + padding + border, then a small gap + return NODEGROUP_LABEL_FONT_PX + NODEGROUP_CHIP_PAD_Y * 2 + 2 + 3 + INFRA_LABEL_LINE_GAP_PX; +} + +// Shared label geometry for an Infra so drawing (drawObjects) and hit-testing +// (findNearestInfra) agree. Offsets are screen pixels relative to `anchor`. +// - located: block centred on the Node cluster's centroid when that covers +// no Node icon; otherwise stacked downward below the bottommost Node +// (reading order top-down: the Node icons, then name lines, then status) +// - pinned locationless: stacked downward below the placeholder marker +// - docked (no location): block laid out downward to the right of the +// placeholder marker, left-aligned so long names never clip at the edge +function getInfraLabelLayout(data) { + if (!data || !data.name) return null; + let anchor = null; + if (data.isLocationless) { + anchor = data.geometry ? data.geometry.getCoordinates() : null; + } else { + anchor = data.labelAnchor || data.anchorCoord || + (data.geometry && data.geometry.getType() === 'Polygon' + ? data.geometry.getInteriorPoint().getCoordinates() : null); + } + if (!anchor) return null; + + const nameLines = splitInfraNameToLines(data.name); + const nameScale = changeSizeByName(data.name + data.status) + 0.1; + const statusScale = changeSizeStatus(data.name + data.status); + const lineHeight = 12 * nameScale; + const statusHeight = 10 * statusScale; + const n = nameLines.length; + + if (data.isLocationless && data.isDocked) { + const offsetX = LOCATIONLESS_MARKER_RADIUS_PX + 10; + const name = nameLines.map((text, k) => ({ text, offsetX, offsetY: k * lineHeight })); + const statusY = n * lineHeight + INFRA_LABEL_LINE_GAP_PX / 2; + const top = -Math.max(lineHeight / 2, LOCATIONLESS_MARKER_RADIUS_PX); + const bottom = statusY + statusHeight / 2; + return { + anchor, nameScale, statusScale, + textAlign: 'left', textBaseline: 'middle', + name, status: { offsetX, offsetY: statusY }, + center: [offsetX + 40, (top + bottom) / 2], + top, height: bottom - top, + }; + } + + const blockH = n * lineHeight + INFRA_LABEL_LINE_GAP_PX + statusHeight; + + // Preferred: centred on the Node cluster, if that covers no Node icon. + if (!data.isLocationless && data.labelCentroid) { + const nameFont = `bold ${10 * nameScale}px sans-serif`; + const statusFont = `bold ${10 * statusScale}px sans-serif`; + let blockW = measureTextWidth(data.status, statusFont); + for (const line of nameLines) blockW = Math.max(blockW, measureTextWidth(line, nameFont)); + if (!infraLabelCoversNodeIcon(data, data.labelCentroid, blockW, blockH)) { + const top = -blockH / 2; + const name = nameLines.map((text, k) => ({ text, offsetX: 0, offsetY: top + k * lineHeight })); + const statusTop = top + n * lineHeight + INFRA_LABEL_LINE_GAP_PX; + return { + anchor: data.labelCentroid, nameScale, statusScale, + textAlign: 'center', textBaseline: 'top', + name, status: { offsetX: 0, offsetY: statusTop }, + center: [0, 0], + top, height: blockH, + }; + } + } + + // Fallback: hang below the bottommost Node (or the placeholder marker). + const clearance = data.isLocationless + ? LOCATIONLESS_MARKER_RADIUS_PX + INFRA_LABEL_LINE_GAP_PX + : INFRA_LABEL_CLEARANCE_BELOW_PX + nodeGroupLabelClearance(data, anchor); + const name = nameLines.map((text, k) => ({ + text, offsetX: 0, offsetY: clearance + k * lineHeight, + })); + const statusTop = clearance + n * lineHeight + INFRA_LABEL_LINE_GAP_PX; + const bottom = statusTop + statusHeight; + return { + anchor, nameScale, statusScale, + textAlign: 'center', textBaseline: 'top', + name, status: { offsetX: 0, offsetY: statusTop }, + center: [0, (clearance + bottom) / 2], + top: clearance, height: bottom - clearance, + }; +} + +function cross(a, b, o) { + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); +} + +// Build a visible polygon ring for a cluster even when it has only 1-2 nodes. +function buildClusterPolygonRing(clusterPoints, zoomLevel, radius) { + if (!clusterPoints || clusterPoints.length === 0) { + return null; + } + + const safeZoom = Math.max(zoomLevel || 1, 1); + const delta = (2.5 / safeZoom) * radius; + const circleSegments = 32; + + // 1-node cluster: draw a circle centered on the point. + if (clusterPoints.length === 1) { + const [cx, cy] = clusterPoints[0]; + const r = delta; + const ring = []; + for (let i = 0; i < circleSegments; i++) { + const angle = (2 * Math.PI * i) / circleSegments; + ring.push([cx + r * Math.cos(angle), cy + r * Math.sin(angle)]); + } + ring.push(ring[0]); + return ring; + } + + // 2-node cluster: draw a circle enclosing both nodes. + if (clusterPoints.length === 2) { + const [p1, p2] = clusterPoints; + const cx = (p1[0] + p2[0]) / 2; + const cy = (p1[1] + p2[1]) / 2; + const halfDist = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]) / 2; + const r = halfDist + delta * 0.5; + const ring = []; + for (let i = 0; i < circleSegments; i++) { + const angle = (2 * Math.PI * i) / circleSegments; + ring.push([cx + r * Math.cos(angle), cy + r * Math.sin(angle)]); + } + ring.push(ring[0]); + return ring; + } + + // 3+ nodes: standard convex hull. + const hull = convexHull(clusterPoints.map((p) => [p[0], p[1]])); + if (!hull || hull.length < 3) { + return null; + } + + return [...hull, hull[0]]; +} + +/** + * @param points An array of [X, Y] coordinates + */ +function convexHull(points) { + points.sort(function (a, b) { + return a[0] == b[0] ? a[1] - b[1] : a[0] - b[0]; + }); + + var lower = []; + for (var i = 0; i < points.length; i++) { + while ( + lower.length >= 2 && + cross(lower[lower.length - 2], lower[lower.length - 1], points[i]) <= 0 + ) { + lower.pop(); + } + lower.push(points[i]); + } + + var upper = []; + for (var i = points.length - 1; i >= 0; i--) { + while ( + upper.length >= 2 && + cross(upper[upper.length - 2], upper[upper.length - 1], points[i]) <= 0 + ) { + upper.pop(); + } + upper.push(points[i]); + } + + upper.pop(); + lower.pop(); + return lower.concat(upper); +} + +function range_change(obj) { + document.getElementById("myvalue").value = obj.value; +} +window.range_change = range_change; + +(function () { + const parentS = document.querySelectorAll(".range-slider"); + + if (!parentS) { + return; + } + + parentS.forEach((parent) => { + const rangeS = parent.querySelectorAll('input[type="range"]'), + numberS = parent.querySelectorAll('input[type="number"]'); + + rangeS.forEach((el) => { + el.oninput = () => { + let slide1 = parseFloat(rangeS[0].value), + slide2 = parseFloat(rangeS[1].value); + + if (slide1 > slide2) { + [slide1, slide2] = [slide2, slide1]; + } + + numberS[0].value = slide1; + numberS[1].value = slide2; + }; + }); + + numberS.forEach((el) => { + el.oninput = () => { + let number1 = parseFloat(numberS[0].value), + number2 = parseFloat(numberS[1].value); + + if (number1 > number2) { + let tmp = number1; + numberS[0].value = number2; + numberS[1].value = tmp; + } + + rangeS[0].value = number1; + rangeS[1].value = number2; + }; + }); + }); +})(); + +function renderMapFromConfig() { + const specs = window.recommendedSpecList || recommendedSpecList; + cspPointsCircle = specs + .map(s => [parseFloat(s?.regionLongitude), parseFloat(s?.regionLatitude)]) + .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat)); + geoCspPointsCircle = cspPointsCircle.length + ? [new MultiPoint(cspPointsCircle)] + : []; + map.render(); +} +window.renderMapFromConfig = renderMapFromConfig; + +function handleAxiosResponse(response) { + // Extract X-Request-Id from the response headers + console.log("Response Headers:", response.headers); + const requestId = response.headers["x-request-id"]; + console.log("X-Request-Id:", requestId); + if (requestId) { + addRequestIdToSelect(requestId); + } +} + +// Function to add X-Request-Id to the select element +function addRequestIdToSelect(requestId) { + // Add X-Request-Id to the global array if it's not already present + if (!xRequestIds.includes(requestId)) { + xRequestIds.push(requestId); + const select = document.getElementById("xRequestIdSelect"); + const option = document.createElement("option"); + option.value = requestId; + option.text = requestId; + select.appendChild(option); + } +} + +// Function to generate a random X-Request-Id with a prefix and specified total length +function generateRandomRequestId(prefix, totalLength) { + const characters = '0123456789'; + let result = prefix; + const charactersLength = characters.length; + const randomPartLength = totalLength; + for (let i = 0; i < randomPartLength; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +// Function to handle selection of an X-Request-Id +function handleRequestIdSelection() { + const select = document.getElementById("xRequestIdSelect"); + const selectedRequestId = select.value; + console.log("Selected X-Request-Id:", selectedRequestId); + + // actions based on the selected X-Request-Id + + if (selectedRequestId) { + var config = getConfig(); var hostname = config.hostname; + var port = config.port; + var username = config.username; + var password = config.password; + + var url = `${tbApiBase()}/request/${selectedRequestId}`; + + axios({ + method: "get", + url: url, + auth: { + username: `${username}`, + password: `${password}`, + }, + }).then((res) => { + console.log(res); // for debug + displayJsonData(res.data, typeInfo); + }); + } else { + console.log("No X-Request-Id selected"); + } +} +window.handleRequestIdSelection = handleRequestIdSelection; + +function getInfra() { + var hostname = window.configHostname || configHostname; + var port = window.configPort || configPort; + var username = window.configUsername || configUsername; + var password = window.configPassword || configPassword; + var namespace = window.configNamespace || configNamespace || 'default'; + + // Use global refreshInterval variable instead of DOM element + var filteredRefreshInterval = isNormalInteger(refreshInterval.toString()) + ? refreshInterval + : 5; + setTimeout(() => getInfra(), filteredRefreshInterval * 1000); + pollExternalRequests(); + + // Show refresh indicator + showMapRefreshIndicator(true); + + var zoomLevel = map.getView().getZoom() * 2.0; + var radius = 4.0; + + if (namespace && namespace != "") { + // get infra list and put them on the map - full details including connectionConfig + var url = `${tbApiBase()}/ns/${namespace}/infra`; + + axios({ + method: "get", + url: url, + auth: { + username: `${username}`, + password: `${password}`, + }, + timeout: 600000, + }) + .then((res) => { + var obj = res.data; + + // Update central data store for Dashboard + if (obj.infra) { + window.cloudBaristaCentralData.infraData = obj.infra; + + // Extract Node data from Infra data + const allNodes = []; + obj.infra.forEach(infra => { + if (infra.node && Array.isArray(infra.node)) { + infra.node.forEach(nd => { + allNodes.push({ + ...nd, + infraId: infra.id, + infraName: infra.name + }); + }); + } + }); + window.cloudBaristaCentralData.nodeData = allNodes; + + // Load VPN data (feeds the always-visible map icons). + loadVpnDataFromInfras(); + // NLBs come from one namespace-wide call, so they are refreshed every cycle + // for the Net graph, the NLB manager and the Board table alike. + loadNlbData(); + + // Update running cost display + updateRunningCostDisplay(obj.infra); + + // Notify Dashboard subscribers + notifyDataSubscribers(); + + // Update map connection status to connected + updateMapConnectionStatus('connected'); + + // Hide refresh indicator + showMapRefreshIndicator(false); + } + + // Also load K8s cluster data for dashboard + loadK8sClusterData(); + + // Rebuild infraRenderMap from fresh API data + infraRenderMap.clear(); + infraClusterPolygons.clear(); + infraClusterNames.clear(); + infraClusterColors.clear(); + infraNodeGroupPolygons.clear(); + infraNodeGroupNames.clear(); + infraNodeGroupColors.clear(); + + // Track how many Infras share each base location, so overlapping Infras get offset + // Key: "roundedLon,roundedLat" → counter (incremented per Infra at that location) + const locationInfraCounter = new globalThis.Map(); + // For each Infra, store its assigned index per location + // Key: "infraId:locationKey" → infraIndexAtLocation + const infraLocationIndex = new globalThis.Map(); + + if (obj.infra != null && obj.infra.length > 0) { + debugLog.api(`Processing ${obj.infra.length} Infras for map display`); + + // First pass: assign Infra index per shared location + for (let item of obj.infra) { + if (item.node == null || item.node.length === 0) continue; + const seenLocations = new Set(); + for (const nd of item.node) { + if (!nd.location || nd.location.longitude === undefined || nd.location.latitude === undefined) continue; + // Round to ~0.5 degree to group nearby regions + const locKey = Math.round(nd.location.longitude * 2) / 2 + ',' + Math.round(nd.location.latitude * 2) / 2; + if (!seenLocations.has(locKey)) { + seenLocations.add(locKey); + const idx = locationInfraCounter.get(locKey) || 0; + infraLocationIndex.set(item.id + ':' + locKey, idx); + locationInfraCounter.set(locKey, idx + 1); + } + } + } + + for (let item of obj.infra) { + + var hideFlag = false; + for (let hideName of infraHideList) { + if (item.id == hideName) { + hideFlag = true; + break; + } + } + if (hideFlag) { + continue; + } + + // Handle Infra without Nodes (preparing, prepared, empty, failed states) + if (item.node == null || item.node.length === 0) { + if (item.status === "Preparing" || item.status === "Prepared" || item.status === "Failed" || item.status.includes("Empty")) { + handleInfraWithoutNodes(item); + } + continue; + } + + var vmGeo = []; + + // Build intra-Infra location groups: VMs within same ~0.5° grid get spread out + // Key: "roundedLon,roundedLat" → array of Node indices in that cell + const intraLocGroups = new globalThis.Map(); + for (let vi = 0; vi < item.node.length; vi++) { + const v = item.node[vi]; + if (!v.location || v.location.longitude === undefined || v.location.latitude === undefined) continue; + const gKey = Math.round(v.location.longitude * 2) / 2 + ',' + Math.round(v.location.latitude * 2) / 2; + if (!intraLocGroups.has(gKey)) intraLocGroups.set(gKey, []); + intraLocGroups.get(gKey).push(vi); + } + // Build per-Node lookup: nodeIndex → { indexInGroup, groupSize } + const vmGroupInfo = new globalThis.Map(); + for (const [, indices] of intraLocGroups) { + for (let gi = 0; gi < indices.length; gi++) { + vmGroupInfo.set(indices[gi], { indexInGroup: gi, groupSize: indices.length }); + } + } + + // Build per-Node render point lookup to keep cluster geometry aligned with node dots. + const nodeRenderPointById = new globalThis.Map(); + + var validateNum = 0; + for (j = 0; j < item.node.length; j++) { + const nd = item.node[j]; + if (!nd.location || nd.location.longitude === undefined || nd.location.latitude === undefined) { + console.warn(`Node ${nd.id || j}: missing location data, skipping`); + continue; + } + + // Compute inter-Infra offset for this Node's location + const nodeLocKey = Math.round(nd.location.longitude * 2) / 2 + ',' + Math.round(nd.location.latitude * 2) / 2; + const infraIdxAtLoc = infraLocationIndex.get(item.id + ':' + nodeLocKey) || 0; + const totalInfrasAtLoc = locationInfraCounter.get(nodeLocKey) || 1; + const infraOff = getInfraLocationOffset(infraIdxAtLoc, totalInfrasAtLoc); + const infraOffX = (infraOff.ox / zoomLevel) * radius; + const infraOffY = (infraOff.oy / zoomLevel) * radius; + + // Compute intra-Infra offset: spread VMs sharing the same location group + const gInfo = vmGroupInfo.get(j); + let intraOffX = 0, intraOffY = 0; + if (gInfo && gInfo.groupSize > 1 && gInfo.indexInGroup > 0) { + const adj = returnAdjustmentPoint(gInfo.indexInGroup, gInfo.groupSize); + intraOffX = (adj.ax / zoomLevel) * radius; + intraOffY = (adj.ay / zoomLevel) * radius; + } + + vmGeo.push([ + nd.location.longitude * 1 + infraOffX + intraOffX, + nd.location.latitude * 1 + infraOffY + intraOffY, + ]); + validateNum++; + } + if (item.node.length == 1 && item.node[0].location && item.node[0].location.longitude !== undefined && item.node[0].location.latitude !== undefined) { + const singleVm = item.node[0]; + const sLocKey = Math.round(singleVm.location.longitude * 2) / 2 + ',' + Math.round(singleVm.location.latitude * 2) / 2; + const sIdx = infraLocationIndex.get(item.id + ':' + sLocKey) || 0; + const sTotal = locationInfraCounter.get(sLocKey) || 1; + const sOff = getInfraLocationOffset(sIdx, sTotal); + const sOffX = (sOff.ox / zoomLevel) * radius; + const sOffY = (sOff.oy / zoomLevel) * radius; + vmGeo.pop(); + vmGeo.push([ + singleVm.location.longitude * 1 + sOffX, + singleVm.location.latitude * 1 + sOffY, + ]); + vmGeo.push([ + singleVm.location.longitude * 1 + sOffX + Math.random() * 0.001, + singleVm.location.latitude * 1 + sOffY + Math.random() * 0.001, + ]); + vmGeo.push([ + singleVm.location.longitude * 1 + sOffX + Math.random() * 0.001, + singleVm.location.latitude * 1 + sOffY + Math.random() * 0.001, + ]); + } + if (validateNum == item.node.length) { + var nodeStatuses = []; + var nodeProviders = []; + var nodeCommandStatuses = []; + var nodePoints = []; + + for (let nodeIndex = 0; nodeIndex < item.node.length; nodeIndex++) { + const nd = item.node[nodeIndex]; + + if (nodeIndex === 0) { + debugLog.node(`Node ${nd.id || 'unknown'} structure:`, nd); + } + + nodeStatuses.push(nd.status || "Undefined"); + + let commandStatus = "None"; + if (nd.commandStatus) { + const queuedCmd = nd.commandStatus.find(cmd => cmd.status === "Queued"); + const handlingCmd = nd.commandStatus.find(cmd => cmd.status === "Handling"); + + if (handlingCmd) { + commandStatus = "Handling"; + } else if (queuedCmd) { + commandStatus = "Queued"; + } + } + if (commandStatus === "None" && window._cmdStreamSessions) { + Object.values(window._cmdStreamSessions).forEach((s) => { + if (!s || s.doneSummary || s.error || s.commandError) return; + if (!s.infraId || s.infraId === item.id) { + const ns = s.nodeState && s.nodeState[nd.id]; + if (ns) { + if (ns.status === 'Handling') commandStatus = 'Handling'; + else if (ns.status === 'Queued' && commandStatus !== 'Handling') commandStatus = 'Queued'; + } else if (!s.targetNodeId || s.targetNodeId === nd.id) { + commandStatus = 'Handling'; + } + } + }); + } + nodeCommandStatuses.push(commandStatus); + + if (nodeIndex === 0 && nd.commandStatus) { + debugLog.node(`Node ${nd.id || 'unknown'} commandStatus:`, nd.commandStatus); + debugLog.node(`Node ${nd.id} command status:`, commandStatus); + } + + let provider = "unknown"; + + if (nodeIndex === 0) { + debugLog.node(`Node ${nd.id}: connectionName =`, nd.connectionName); + debugLog.node(`Node ${nd.id}: connectionConfig =`, nd.connectionConfig); + } + + if (nd.connectionConfig && nd.connectionConfig.providerName) { + provider = nd.connectionConfig.providerName; + if (nodeIndex === 0) debugLog.node(`Node ${nd.id}: found provider in connectionConfig = ${provider}`); + } else if (nd.connectionName) { + provider = nd.connectionName.split('-')[0]; + if (nodeIndex === 0) debugLog.node(`Node ${nd.id}: extracted provider from connectionName = ${provider}`); + } else { + if (nodeIndex === 0) { + debugLog.node(`Node ${nd.id}: no provider info found, using unknown`); + debugLog.node(`Node ${nd.id}: available properties:`, Object.keys(nd)); + } + } + + nodeProviders.push(provider); + + // Compute inter-Infra offset for this Node's location + const nodeLocKey2 = Math.round(nd.location.longitude * 2) / 2 + ',' + Math.round(nd.location.latitude * 2) / 2; + const infraIdx2 = infraLocationIndex.get(item.id + ':' + nodeLocKey2) || 0; + const totalInfras2 = locationInfraCounter.get(nodeLocKey2) || 1; + const infraOff2 = getInfraLocationOffset(infraIdx2, totalInfras2); + const infraOffX2 = (infraOff2.ox / zoomLevel) * radius; + const infraOffY2 = (infraOff2.oy / zoomLevel) * radius; + + // Compute intra-Infra offset: spread VMs sharing the same location group + const gInfo2 = vmGroupInfo.get(nodeIndex); + let intraOffX2 = 0, intraOffY2 = 0; + if (gInfo2 && gInfo2.groupSize > 1 && gInfo2.indexInGroup > 0) { + const adj2 = returnAdjustmentPoint(gInfo2.indexInGroup, gInfo2.groupSize); + intraOffX2 = (adj2.ax / zoomLevel) * radius; + intraOffY2 = (adj2.ay / zoomLevel) * radius; + } + nodePoints.push([ + nd.location.longitude * 1 + infraOffX2 + intraOffX2, + nd.location.latitude * 1 + infraOffY2 + intraOffY2, + ]); + + if (nd.id) { + nodeRenderPointById.set(nd.id, nodePoints[nodePoints.length - 1]); + } + } + + // Keep the original Infra name/id (no "-nlb" -> "NLB" relabel) so a + // Global NLB host remains identifiable/operable as its own Infra. + var newName = item.name; + + // Create Infra render entry + var infraEntry = { + id: item.id, + name: newName, + status: item.status, + targetAction: (item.targetAction && item.targetAction !== "None" && item.targetAction !== "") + ? item.targetAction : null, + geometry: null, + geometryPoints: null, + geo: null, + isLocationless: false + }; + + // Build Node dots and polygon geometry into the entry + makePolyDot(infraEntry, vmGeo, nodeStatuses, nodeProviders, nodeCommandStatuses); + // convexHull sorts in-place; pass a copy so vmGeo (stored in + // geometryPoints.nodePoints by reference) keeps the original + // item.node order aligned with nodeProviders/nodeStatuses. + const hullGeo = convexHull([...vmGeo]); + makePolyArray(infraEntry, hullGeo); + // Name/status label anchor: below the bottommost Node, centred on the + // mean Node x (see getInfraLabelLayout). + infraEntry.labelAnchor = computeInfraLabelAnchor(vmGeo); + infraEntry.labelCentroid = computeInfraLabelCentroid(vmGeo); + + // Process cluster polygons (if cluster data exists) + if (item.cluster && Array.isArray(item.cluster) && item.cluster.length > 0) { + debugLog.mapOp(`[ClusterPolygon] Processing ${item.cluster.length} clusters for infra: ${item.id}`); + + const clusterPolygons = []; + const clusterNames = []; + const clusterColorMap = new globalThis.Map(); + + // Generate distinct colors for clusters + const baseColors = ['#FF5733', '#33FF57', '#3357FF', '#FF33F7', '#F7FF33', '#33FFF7', '#FF8C33', '#8C33FF']; + + item.cluster.forEach((cluster, idx) => { + const clusterColor = baseColors[idx % baseColors.length]; + clusterColorMap.set(cluster.id, clusterColor); + clusterNames.push(cluster.id); + + // Collect node coordinates for this cluster + const clusterNodeGeo = []; + + if (cluster.nodeIds && Array.isArray(cluster.nodeIds)) { + cluster.nodeIds.forEach(nodeId => { + const renderPoint = nodeRenderPointById.get(nodeId); + if (renderPoint && renderPoint.length === 2) { + clusterNodeGeo.push([renderPoint[0], renderPoint[1]]); + } + }); + } + + // Generate visible polygon for this cluster (supports 1/2/3+ nodes) + if (clusterNodeGeo.length > 0) { + const clusterRing = buildClusterPolygonRing(clusterNodeGeo, zoomLevel, radius); + if (clusterRing && clusterRing.length >= 4) { + const clusterPolygon = new Polygon([clusterRing]); + clusterPolygon.set('fill', true); + clusterPolygon.set('fillColor', clusterColor); + clusterPolygon.set('fillOpacity', 0.18); + clusterPolygon.set('stroke', true); + clusterPolygon.set('strokeColor', clusterColor); + clusterPolygon.set('strokeWidth', 2.5); + clusterPolygon.set('strokeOpacity', 0.95); + clusterPolygon.set('clusterNodeCount', clusterNodeGeo.length); + clusterPolygon.set('name', `Cluster: ${cluster.id}`); + + clusterPolygons.push(clusterPolygon); + debugLog.mapOp(`[ClusterPolygon] Created polygon for cluster ${cluster.id}: ${clusterNodeGeo.length} nodes`); + } + } + }); + + // Store cluster polygons and names for rendering + if (clusterPolygons.length > 0) { + infraClusterPolygons.set(item.id, clusterPolygons); + infraClusterNames.set(item.id, clusterNames); + infraClusterColors.set(item.id, clusterColorMap); + debugLog.mapOp(`[ClusterPolygon] Stored ${clusterPolygons.length} cluster polygons for infra: ${item.id}`); + } + } + + // Process NodeGroup polygons — group nodes by nodeGroupId + { + const ngBaseColors = ['#2196F3','#FF9800','#9C27B0','#4CAF50','#F44336','#00BCD4','#FF5722','#607D8B']; + const ngGroupMap = new globalThis.Map(); // nodeGroupId → [[x,y], ...] + item.node.forEach(nd => { + const gid = nd.nodeGroupId; + if (!gid || !nd.id) return; + const pt = nodeRenderPointById.get(nd.id); + if (!pt) return; + if (!ngGroupMap.has(gid)) ngGroupMap.set(gid, []); + ngGroupMap.get(gid).push(pt); + }); + + if (ngGroupMap.size > 0) { + const ngPolygons = [], ngNames = []; + const ngColorMap = new globalThis.Map(); + let ngIdx = 0; + for (const [gid, pts] of ngGroupMap) { + const color = ngBaseColors[ngIdx % ngBaseColors.length]; + ngColorMap.set(gid, color); + ngNames.push(gid); + const ring = buildClusterPolygonRing(pts, zoomLevel, radius); + if (ring && ring.length >= 4) { + const poly = new Polygon([ring]); + poly.set('clusterNodeCount', pts.length); + poly.set('nodeGroupId', gid); + // Label anchor: the group's first Node (API order). The render + // point is shared by reference with nodePoints, so a locationless + // Infra that moves at draw time keeps its label attached. + poly.set('labelAnchor', pts[0]); + ngPolygons.push(poly); + } + ngIdx++; + } + if (ngPolygons.length > 0) { + infraNodeGroupPolygons.set(item.id, ngPolygons); + infraNodeGroupNames.set(item.id, ngNames); + infraNodeGroupColors.set(item.id, ngColorMap); + } + } + } + + infraRenderMap.set(item.id, infraEntry); + } + } + } else { + // No Infra data — map is already cleared above + console.log("No Infra data found, clearing map objects"); + updateRunningCostDisplay([]); + map.render(); + } + }) + .catch(function (error) { + console.log("Infra API error:", error); + // Don't update geometries on API error to preserve current state + + // Update map connection status to disconnected + updateMapConnectionStatus('disconnected'); + + // Hide refresh indicator + showMapRefreshIndicator(false); + }); + + // get vnet list and put them on the map + var url = `${tbApiBase()}/ns/${namespace}/resources/vNet`; + axios({ + method: "get", + url: url, + auth: { + username: `${username}`, + password: `${password}`, + }, + timeout: 600000, + }).then((res) => { + var obj = res.data; + debugLog.api('vNet API response:', obj); + + // Update central data store + if (obj.vNet) { + window.cloudBaristaCentralData.vNet = obj.vNet; + window.cloudBaristaCentralData.resourceData.vNet = obj.vNet; + debugLog.resource('vNet data stored in central store:', obj.vNet.length, 'items'); + } + + if (obj.vNet != null && obj.vNet.length > 0) { + var normalLoc = [], tombLoc = [], tombItems = []; + for (let item of obj.vNet) { + var pt = [ + item.connectionConfig.regionDetail.location.longitude * 1, + item.connectionConfig.regionDetail.location.latitude * 1 - 0.05, + ]; + if (isTombstoneResource(item)) { tombLoc.push(pt); tombItems.push(item); } + else normalLoc.push(pt); + } + geoResourceLocation.vnet = normalLoc.length ? [new MultiPoint([normalLoc])] : []; + geoResourceLocation.vnetTombstone = tombLoc.length ? [new MultiPoint([tombLoc])] : []; + window.tombstoneRegistry.vNet = tombItems; + } else { + geoResourceLocation.vnet = []; + geoResourceLocation.vnetTombstone = []; + window.tombstoneRegistry.vNet = []; + } + updateTombstoneBanner(); + + // Notify Dashboard of data update + notifyDataSubscribers(); + }) + .catch(function (error) { + console.log("vNet API error:", error); + // Don't update icons on API error to preserve current state + }); + + // get securityGroup list and put them on the map + var url = `${tbApiBase()}/ns/${namespace}/resources/securityGroup`; + axios({ + method: "get", + url: url, + auth: { + username: `${username}`, + password: `${password}`, + }, + timeout: 600000, + }).then((res) => { + var obj = res.data; + debugLog.api('Security Group API response:', obj); + + // Update central data store + if (obj.securityGroup) { + window.cloudBaristaCentralData.securityGroup = obj.securityGroup; + window.cloudBaristaCentralData.resourceData.securityGroup = obj.securityGroup; + debugLog.resource('Security Group data stored in central store:', obj.securityGroup.length, 'items'); + } + + if (obj.securityGroup != null && obj.securityGroup.length > 0) { + var normalLoc = [], tombLoc = [], tombItems = []; + for (let item of obj.securityGroup) { + var pt = [ + item.connectionConfig.regionDetail.location.longitude * 1 - 0.05, + item.connectionConfig.regionDetail.location.latitude * 1, + ]; + if (isTombstoneResource(item)) { tombLoc.push(pt); tombItems.push(item); } + else normalLoc.push(pt); + } + geoResourceLocation.sg = normalLoc.length ? [new MultiPoint([normalLoc])] : []; + geoResourceLocation.sgTombstone = tombLoc.length ? [new MultiPoint([tombLoc])] : []; + window.tombstoneRegistry.securityGroup = tombItems; + } else { + geoResourceLocation.sg = []; + geoResourceLocation.sgTombstone = []; + window.tombstoneRegistry.securityGroup = []; + } + updateTombstoneBanner(); + + // Notify Dashboard of data update + notifyDataSubscribers(); + }) + .catch(function (error) { + console.log("securityGroup API error:", error); + // Don't update icons on API error to preserve current state + }); + + + // get sshKey list and put them on the map + var url = `${tbApiBase()}/ns/${namespace}/resources/sshKey`; + axios({ + method: "get", + url: url, + auth: { + username: `${username}`, + password: `${password}`, + }, + timeout: 600000, + }).then((res) => { + var obj = res.data; + debugLog.api('SSH Key API response:', obj); + + // Update central data store + if (obj.sshKey) { + window.cloudBaristaCentralData.sshKey = obj.sshKey; + window.cloudBaristaCentralData.resourceData.sshKey = obj.sshKey; + debugLog.resource('SSH Key data stored in central store:', obj.sshKey.length, 'items'); + } + + if (obj.sshKey != null && obj.sshKey.length > 0) { + var normalLoc = [], tombLoc = [], tombItems = []; + for (let item of obj.sshKey) { + var pt = [ + item.connectionConfig.regionDetail.location.longitude * 1 + 0.05, + item.connectionConfig.regionDetail.location.latitude * 1, + ]; + if (isTombstoneResource(item)) { tombLoc.push(pt); tombItems.push(item); } + else normalLoc.push(pt); + } + geoResourceLocation.sshKey = normalLoc.length ? [new MultiPoint([normalLoc])] : []; + geoResourceLocation.sshKeyTombstone = tombLoc.length ? [new MultiPoint([tombLoc])] : []; + window.tombstoneRegistry.sshKey = tombItems; + } else { + geoResourceLocation.sshKey = []; + geoResourceLocation.sshKeyTombstone = []; + window.tombstoneRegistry.sshKey = []; + } + updateTombstoneBanner(); + + // Notify Dashboard of data update + notifyDataSubscribers(); + }) + .catch(function (error) { + console.log("sshKey API error:", error); + // Don't update icons on API error to preserve current state + }); + + // Load VPN data from all Infras (reuses Infra data from central store) + loadVpnDataFromInfras(); + + // Get custom images + var customImageUrl = `${tbApiBase()}/ns/${namespace}/resources/customImage`; + axios({ + method: "get", + url: customImageUrl, + auth: { + username: `${username}`, + password: `${password}`, + }, + timeout: 600000, + }).then((res) => { + var obj = res.data; + debugLog.api('Custom Image API response:', obj); + + // Handle different possible response structures + let customImages = []; + if (obj && obj.customImage && Array.isArray(obj.customImage)) { + customImages = obj.customImage; + } else if (obj && Array.isArray(obj)) { + customImages = obj; + } + + window.cloudBaristaCentralData.customImage = customImages; + debugLog.resource('Custom Image data stored:', customImages.length, 'items'); + }).catch(function (error) { + console.log("Custom Image API error:", error); + // Set empty array on error to prevent undefined issues + window.cloudBaristaCentralData.customImage = []; + }); + + // Get data disks + var dataDiskUrl = `${tbApiBase()}/ns/${namespace}/resources/dataDisk`; + axios({ + method: "get", + url: dataDiskUrl, + auth: { + username: `${username}`, + password: `${password}`, + }, + timeout: 600000, + }).then((res) => { + var obj = res.data; + debugLog.api('Data Disk API response:', obj); + + // Handle different possible response structures + let dataDisks = []; + if (obj && obj.dataDisk && Array.isArray(obj.dataDisk)) { + dataDisks = obj.dataDisk; + } else if (obj && obj.dataDiskInfo && Array.isArray(obj.dataDiskInfo)) { + dataDisks = obj.dataDiskInfo; + } else if (obj && Array.isArray(obj)) { + dataDisks = obj; + } + + window.cloudBaristaCentralData.dataDisk = dataDisks; + debugLog.resource('Data Disk data stored:', dataDisks.length, 'items'); + }).catch(function (error) { + console.log("Data Disk API error:", error); + // Set empty array on error to prevent undefined issues + window.cloudBaristaCentralData.dataDisk = []; + }); + + // TODO: Object Storage API not yet available in CB-Tumblebug + // Get object storage - DISABLED until API is implemented + // var objectStorageUrl = `${tbApiBase()}/ns/${namespace}/resources/objectStorage`; + // axios({ + // method: "get", + // url: objectStorageUrl, + // auth: { + // username: `${username}`, + // password: `${password}`, + // }, + // timeout: 10000, + // }).then((res) => { + // var obj = res.data; + // if (obj && obj.objectStorages) { + // window.cloudBaristaCentralData.objectStorage = obj.objectStorages; + // } + // }).catch(function (error) { + // console.log("Object Storage API error:", error); + // }); + + // TODO: SQL Database API not yet available in CB-Tumblebug + // Get SQL databases - DISABLED until API is implemented + // var sqlDbUrl = `${tbApiBase()}/ns/${namespace}/resources/sqlDb`; + // axios({ + // method: "get", + // url: sqlDbUrl, + // auth: { + // username: `${username}`, + // password: `${password}`, + // }, + // timeout: 10000, + // }).then((res) => { + // var obj = res.data; + // if (obj && obj.sqlDbs) { + // window.cloudBaristaCentralData.sqlDb = obj.sqlDbs; + // } + // }).catch(function (error) { + // console.log("SQL DB API error:", error); + // }); + } +} + + +window.onload = function () { + // Get host address and update configuration + var tbServerAp = window.location.host; + var strArray = tbServerAp.split(":"); + console.log("Host address: " + strArray[0]); + configHostname = strArray[0]; + + // Use the new connection check with retry instead of single getConnection call + setTimeout(checkConnectionWithRetry, 1000); + + updateCredentialHolderList(); + updateNsList(); + + getInfra(); + + // Add event listener for Provision tab to show map when clicked + const provisionTab = document.getElementById('provision-tab'); + if (provisionTab) { + provisionTab.addEventListener('click', function(e) { + console.log('Provision tab clicked, switching to map view'); + // Small delay to allow tab to activate first + setTimeout(function() { + if (typeof showMap === 'function') { + showMap(); + } else { + console.log('showMap function not found'); + } + }, 100); + }); + } +}; + +let drawCounter = 0; +const shuffleInterval = 200; // Shuffle every shuffleInterval draws +let shuffledKeys = Object.keys(cspIconStyles); // Initialize with original keys + +function shuffleKeys() { + shuffledKeys = Object.keys(cspIconStyles) + .map((key) => ({ key, sort: Math.random() })) // Map to array of objects with random sort values + .sort((a, b) => a.sort - b.sort) // Sort by random values + .map(({ key }) => key); // Extract the keys +} + +// Section for general tools + +function jsonToTable(jsonText) { + let table = document.createElement("table"); + let arr00 = new Array(); + let arr01 = new Array(); + let arr02 = new Array(); + let arr03 = new Array(); + let arr04 = new Array(); + let arr05 = new Array(); + + let json = JSON.parse(jsonText); + + for (i = 0; i < json.length; i++) { + arr00[i] = json[i].connectionName; + arr01[i] = json[i].cspSpecName; + arr02[i] = json[i].vCPU; + arr03[i] = json[i].memoryGiB; + arr04[i] = json[i].costPerHour; + arr05[i] = json[i].evaluationScore09; + } + + // Header + let tr0 = document.createElement("tr"); + + let th0 = document.createElement("th"); + th0.appendChild(document.createTextNode(" cspRegion")); + let th1 = document.createElement("th"); + th1.appendChild(document.createTextNode(" cspSpecName")); + let th2 = document.createElement("th"); + th2.appendChild(document.createTextNode(" vCPU")); + let th3 = document.createElement("th"); + th3.appendChild(document.createTextNode(" memoryGiB")); + let th4 = document.createElement("th"); + th4.appendChild(document.createTextNode(" costPerHour")); + let th5 = document.createElement("th"); + th5.appendChild(document.createTextNode(" evaluationScore")); + + tr0.appendChild(th0); + tr0.appendChild(th1); + tr0.appendChild(th2); + tr0.appendChild(th3); + tr0.appendChild(th4); + tr0.appendChild(th5); + table.appendChild(tr0); + + for (i = 0; i < arr01.length; i++) { + let tr = document.createElement("tr"); + + let td0 = document.createElement("td"); + td0.appendChild(document.createTextNode(" " + arr00[i] + "")); + + let td1 = document.createElement("td"); + td1.appendChild(document.createTextNode(" " + arr01[i] + "")); + + let td2 = document.createElement("td"); + td2.appendChild(document.createTextNode(" " + arr02[i] + "")); + + let td3 = document.createElement("td"); + td3.appendChild(document.createTextNode(" " + arr03[i] + "")); + + let td4 = document.createElement("td"); + td4.appendChild(document.createTextNode(" " + arr04[i] + "")); + + let td5 = document.createElement("td"); + td5.appendChild(document.createTextNode(" " + arr05[i] + "")); + + tr.appendChild(td0); + tr.appendChild(td1); + tr.appendChild(td2); + tr.appendChild(td3); + tr.appendChild(td4); + tr.appendChild(td5); + + table.appendChild(tr); + } +} + + +// Draw Objects to the Map +function drawObjects(event) { + + // Place docked (locationless) Infras: a pixel-anchored list at the top-left, + // re-derived every frame so it stays put through pan/zoom/resize. Each slot + // is as tall as its own label block, so multi-line names never overlap. + { + let dockY = LOCATIONLESS_DOCK_TOP_PX; + for (const [, data] of infraRenderMap) { + if (!data.isLocationless || !data.isDocked) continue; + const layout = getInfraLabelLayout(data); + if (!layout) continue; + const markerY = dockY - layout.top; // layout.top is negative (block starts above the marker centre) + const coord = map.getCoordinateFromPixel([LOCATIONLESS_DOCK_LEFT_PX, markerY]); + if (coord) data.geometry.setCoordinates(coord); + dockY += layout.height + LOCATIONLESS_DOCK_GAP_PX; + } + } + + var vectorContext = getVectorContext(event); + var frameState = event.frameState; + var theta = (2 * Math.PI * frameState.time) / omegaTheta; + + // Shuffle keys every shuffleInterval draws + drawCounter++; + if (drawCounter % shuffleInterval === 0) { + shuffleKeys(); + } + + // Get the selected providers from checkboxes + var selectedProviders = getSelectedProviders(); + var isAllSelected = selectedProviders.includes("ALL") || selectedProviders.length === 0; + + // Draw CSP location first with the stored random order + shuffledKeys.forEach((key) => { + if (isAllSelected || selectedProviders.includes(key)) { + if (Array.isArray(geoCspPoints[key]) && geoCspPoints[key].length) { + const style = getCspStyle(key); + if (!style) return; + const styles = Array.isArray(style) ? style : [style]; + const validStyles = styles.filter(Boolean); + if (!validStyles.length) return; + validStyles.forEach((s) => { + vectorContext.setStyle(s); + vectorContext.drawGeometry(geoCspPoints[key][0]); + }); + } + } + }); + + // Draw Infra Geometry (polygons and points from infraRenderMap) + { + let colorIdx = 0; + for (const [, data] of infraRenderMap) { + if (data.geometry) { + var polyStyle = new Style({ + stroke: new Stroke({ + width: 1, + color: cororLineList[colorIdx % cororList.length], + }), + fill: new Fill({ + color: cororList[colorIdx % cororList.length], + }), + }); + vectorContext.setStyle(polyStyle); + vectorContext.drawGeometry(data.geometry); + colorIdx++; + } + } + } + + // Draw K8s Cluster Group Geometry (clusters with same clustergroup label) + for (i = k8sClusterGroups.length - 1; i >= 0; --i) { + var k8sGroupPolyStyle = new Style({ + stroke: new Stroke({ + width: 2, + color: [75, 0, 130, 0.8], // Indigo color for K8s groups + lineDash: [8, 4] // Dashed line to distinguish from Infra + }), + fill: new Fill({ + color: [138, 43, 226, 0.15], // BlueViolet with transparency + }), + }); + + vectorContext.setStyle(k8sGroupPolyStyle); + vectorContext.drawGeometry(k8sClusterGroups[i]); + } + + // Draw Infra Cluster Geometry (clusters within infra) + if (infraClusterPolygons.size > 0) { + for (const [infraId, polygons] of infraClusterPolygons) { + if (Array.isArray(polygons)) { + polygons.forEach((polygon, idx) => { + // Get cluster color and info + const clusterNames = infraClusterNames.get(infraId) || []; + const clusterColors = infraClusterColors.get(infraId) || new globalThis.Map(); + const clusterName = clusterNames[idx] || `Cluster ${idx}`; + const clusterColor = clusterColors.get(clusterName) || '#FF5733'; + + // Parse hex color to RGBA + const rgbColor = hexToRgb(clusterColor); + const clusterNodeCount = polygon.get('clusterNodeCount') || 0; + const isSmallCluster = clusterNodeCount <= 2; + const clusterStyle = new Style({ + stroke: new Stroke({ + width: isSmallCluster ? 3.2 : 2.8, + lineDash: isSmallCluster ? [6, 4] : undefined, + color: [...rgbColor, 0.9] + }), + fill: new Fill({ + color: [...rgbColor, isSmallCluster ? 0.2 : 0.12] + }) + }); + + vectorContext.setStyle(clusterStyle); + vectorContext.drawGeometry(polygon); + }); + } + } + } + + if (cspPointsCircle.length) { + //console.log("cspPointsCircle.length:" +cspPointsCircle.length + "cspPointsCircle["+cspPointsCircle+"]") + // Fix: Create MultiPoint with proper coordinate structure + geoCspPointsCircle[0] = new MultiPoint(cspPointsCircle); + vectorContext.setStyle(iconStyleCircle); + vectorContext.drawGeometry(geoCspPointsCircle[0]); + + // Draw convex hull polygon for configuration points (like existing Infra VMs) + if (cspPointsCircle.length >= 3) { + // Create deep copy to avoid modifying original array (convexHull sorts input) + const pointsCopy = cspPointsCircle.map(point => [point[0], point[1]]); + + // Debug: log points before convex hull + console.log("Original points:", cspPointsCircle); + console.log("Points copy:", pointsCopy); + + const hullPoints = convexHull(pointsCopy); + + // Debug: log hull result + console.log("Hull points:", hullPoints); + + if (hullPoints.length >= 3) { + // Ensure the polygon is closed by adding the first point at the end + const closedHull = [...hullPoints, hullPoints[0]]; + const configPolygon = new Polygon([closedHull]); + const configPolyStyle = new Style({ + stroke: new Stroke({ + width: 2, + color: [169, 169, 169, 0.8], // Light gray with transparency + lineDash: [5, 5] // Dashed line for config state + }), + fill: new Fill({ + color: [192, 192, 192, 0.1], // Very light gray fill + }), + }); + vectorContext.setStyle(configPolyStyle); + vectorContext.drawGeometry(configPolygon); + } + } + } + + if (geoResourceLocation.vnet[0]) { + vectorContext.setStyle(iconStyleVnet); + vectorContext.drawGeometry(geoResourceLocation.vnet[0]); + } + if (geoResourceLocation.sg[0]) { + vectorContext.setStyle(iconStyleSG); + vectorContext.drawGeometry(geoResourceLocation.sg[0]); + } + if (geoResourceLocation.sshKey[0]) { + vectorContext.setStyle(iconStyleKey); + vectorContext.drawGeometry(geoResourceLocation.sshKey[0]); + } + // Tombstoned resources: rose halo drawn behind the resource's normal icon. + const drawTombstones = (geom, iconStyle) => { + if (!geom) return; + vectorContext.setStyle(tombstoneHaloStyle); + vectorContext.drawGeometry(geom); + vectorContext.setStyle(iconStyle); + vectorContext.drawGeometry(geom); + }; + drawTombstones(geoResourceLocation.vnetTombstone && geoResourceLocation.vnetTombstone[0], iconStyleVnet); + drawTombstones(geoResourceLocation.sgTombstone && geoResourceLocation.sgTombstone[0], iconStyleSG); + drawTombstones(geoResourceLocation.sshKeyTombstone && geoResourceLocation.sshKeyTombstone[0], iconStyleKey); + if (geoResourceLocation.k8s[0]) { + vectorContext.setStyle(iconStyleK8s); + vectorContext.drawGeometry(geoResourceLocation.k8s[0]); + } + if (geoResourceLocation.vpn[0]) { + vectorContext.setStyle(iconStyleVPN); + vectorContext.drawGeometry(geoResourceLocation.vpn[0]); + } + + // Draw Infra Points and Individual Node Status Badges + for (const [, data] of infraRenderMap) { + const geometryPoint = data.geometryPoints; + + // Skip if no geometry point (e.g., preparing/prepared Infra) + if (!geometryPoint) { + continue; + } + + // Check if geometryPoint has the new structure with Node data + if (geometryPoint && typeof geometryPoint === 'object' && geometryPoint.geometry) { + const { geometry, nodePoints, nodeStatuses, nodeProviders, nodeCommandStatuses } = geometryPoint; + const vmBaseScale = changeSizeStatus(data.name + data.status); + + if (nodePoints && nodeStatuses) { + nodeStatuses.forEach((nodeStatus, nodeIndex) => { + if (nodePoints[nodeIndex]) { + const nodeCoords = nodePoints[nodeIndex]; + const vmProvider = nodeProviders ? nodeProviders[nodeIndex] : null; + const commandStatus = nodeCommandStatuses ? nodeCommandStatuses[nodeIndex] : "None"; + const vmStyles = createNodeStyleWithStatusBadge(nodeStatus, vmProvider, vmBaseScale, nodeCoords, commandStatus); + + const vmPoint = new Point(nodeCoords); + vmStyles.forEach(style => { + vectorContext.setStyle(style); + vectorContext.drawGeometry(vmPoint); + }); + } + }); + } + } else { + // Legacy structure: Draw single Infra icon (fallback) + if (data.name.includes("NLB")) { + vectorContext.setStyle(iconStyleNlb); + } else { + vectorContext.setStyle(iconStyleNode); + } + if (geometryPoint) { + vectorContext.drawGeometry(geometryPoint); + } + } + } + + // Draw K8s cluster text (name and status) + for (i = 0; i < k8sName.length; i++) { + if (k8sCoords[i] && k8sName[i]) { + // Create Point geometry from stored coordinates + const k8sPoint = new Point(k8sCoords[i]); + + // Split K8s cluster name into lines for better display + const nameLines = splitK8sNameToLines(k8sName[i]); + const lineHeight = 28; // Spacing between lines (slightly larger than Infra due to bigger font) + const baseOffsetY = 30; // Position below the icon + + // Draw each line of the K8s cluster name + nameLines.forEach((line, lineIndex) => { + const k8sNameStyle = new Style({ + text: new Text({ + text: line, + font: "bold 24px sans-serif", // Increased from 20px to 24px (20% larger) + scale: 1.0, // Fixed scale for K8s clusters + offsetY: baseOffsetY + (lineIndex * lineHeight), // Offset each line down + stroke: new Stroke({ + color: [255, 255, 255, 1], // white stroke + width: 2, // Adjusted stroke width proportionally + }), + fill: new Fill({ + color: [0, 0, 0, 1], // black text + }), + }), + }); + + vectorContext.setStyle(k8sNameStyle); + vectorContext.drawGeometry(k8sPoint); + }); + + // K8s cluster status text with appropriate color + const statusOffsetY = baseOffsetY + (nameLines.length * lineHeight) + 8; // Position below the name lines with gap + const statusColors = getK8sStatusColor(k8sStatus[i]); + const k8sStatusStyle = new Style({ + text: new Text({ + text: k8sStatus[i], + font: "bold 22px sans-serif", // Increased from 18px to 22px (20% larger) + scale: 0.9, // Slightly smaller for status + offsetY: statusOffsetY, // Use calculated offset based on name lines + stroke: new Stroke({ + color: statusColors.stroke, + width: 2, // Adjusted stroke width proportionally + }), + fill: new Fill({ + color: statusColors.fill, + }), + }), + }); + + // Draw status text + vectorContext.setStyle(k8sStatusStyle); + vectorContext.drawGeometry(k8sPoint); + } + } + + // Draw Infra name + status labels (layout shared with findNearestInfra) + { + let infraDrawIdx = 0; + for (const [, data] of infraRenderMap) { + const layout = getInfraLabelLayout(data); + if (!layout) { infraDrawIdx++; continue; } + const anchorPoint = new Point(layout.anchor); + const statusColors = getNodeStatusColor(data.status); + + // Placeholder marker for an Infra that has no Node positions yet + if (data.isLocationless) { + vectorContext.setStyle(new Style({ + image: new CircleStyle({ + radius: LOCATIONLESS_MARKER_RADIUS_PX, + fill: new Fill({ color: [255, 255, 255, 0.85] }), + stroke: new Stroke({ color: statusColors.stroke, width: 2, lineDash: [4, 3] }), + }), + })); + vectorContext.drawGeometry(anchorPoint); + vectorContext.setStyle(new Style({ + text: new Text({ text: '⏳', font: '11px sans-serif', textBaseline: 'middle' }), + })); + vectorContext.drawGeometry(anchorPoint); + } + + layout.name.forEach((line, lineIndex) => { + let displayText = line.text; + + if (lineIndex === 0 && data.targetAction) { + const spinChars = ['⠿', '⠷', '⠯', '⠟', '⠻', '⠽', '⠾', '⠷','⠿']; + const animIndex = Math.floor(drawCounter / 10 + infraDrawIdx) % spinChars.length; + displayText = spinChars[animIndex] + ' ' + displayText; + } + + const textColor = (lineIndex === 0 && data.targetAction) + ? getTargetActionColor(data.targetAction) + : [0, 0, 0, 1]; + + vectorContext.setStyle(new Style({ + text: new Text({ + text: displayText, + font: "bold 10px sans-serif", + scale: layout.nameScale, + textAlign: layout.textAlign, + textBaseline: layout.textBaseline, + offsetX: line.offsetX, + offsetY: line.offsetY, + stroke: new Stroke({ + color: [255, 255, 255, 1], + width: 1, + }), + fill: new Fill({ + color: textColor, + }), + }), + })); + vectorContext.drawGeometry(anchorPoint); + }); + + vectorContext.setStyle(new Style({ + text: new Text({ + text: data.status, + font: "bold 10px sans-serif", + scale: layout.statusScale, + textAlign: layout.textAlign, + textBaseline: layout.textBaseline, + offsetX: layout.status.offsetX, + offsetY: layout.status.offsetY, + stroke: new Stroke({ + color: statusColors.stroke, + width: 2, + }), + fill: new Fill({ + color: statusColors.fill, + }), + }), + })); + vectorContext.drawGeometry(anchorPoint); + infraDrawIdx++; + } + } + + // Draw Infra NodeGroup labels + if (showInfraNodeGroupLabels && infraNodeGroupPolygons.size > 0) { + for (const [infraId, polygons] of infraNodeGroupPolygons) { + if (Array.isArray(polygons)) { + const ngNames = infraNodeGroupNames.get(infraId) || []; + const ngColors = infraNodeGroupColors.get(infraId) || new globalThis.Map(); + polygons.forEach((polygon, idx) => { + const ngName = polygon && (polygon.get('nodeGroupId') || ngNames[idx]); + if (polygon && ngName) { + // Anchor the label to the group's first Node icon rather than the hull + // top: the hull is inflated in map units, so its top edge drifts + // relative to the icons as the zoom changes and ends up on top of them. + let anchor = polygon.get('labelAnchor'); + if (!anchor) { + const extent = polygon.getExtent(); + anchor = [(extent[0] + extent[2]) / 2, extent[3]]; + } + const labelPoint = new Point(anchor); + const ngColor = ngColors.get(ngName) || '#2196F3'; + const ngRgb = hexToRgb(ngColor); + const nodeCount = polygon.get('clusterNodeCount') || 0; + const labelText = `${ngName} (${nodeCount})`; + // Readability over map tiles: black text on a translucent white chip. + // The group colour (not drawn anywhere else) survives only as the + // chip border so groups stay distinguishable. + // Icon is 52px * (2.4 * 0.3) ≈ 37px tall, centered on the point, and + // the status badge sits ~18px below center. Pixel offsets keep this + // clearance constant at every zoom level. + vectorContext.setStyle(new Style({ image: getNodeGroupLabelChip(labelText, ngRgb) })); + vectorContext.drawGeometry(labelPoint); + vectorContext.setStyle(new Style({ + text: new Text({ + text: labelText, + font: NODEGROUP_LABEL_FONT, + textAlign: 'center', + textBaseline: 'top', + offsetY: NODEGROUP_LABEL_OFFSET_PX + 1.5 + NODEGROUP_CHIP_PAD_Y, + fill: new Fill({ color: [20, 20, 20, 1] }), + }), + })); + vectorContext.drawGeometry(labelPoint); + } + }); + } + } + } + + // Draw Infra Cluster labels (drawn last to appear on top of polygons) + if (showInfraClusterLabels && infraClusterPolygons.size > 0) { + for (const [infraId, polygons] of infraClusterPolygons) { + if (Array.isArray(polygons)) { + const clusterNames = infraClusterNames.get(infraId) || []; + const clusterColors = infraClusterColors.get(infraId) || new globalThis.Map(); + + polygons.forEach((polygon, idx) => { + if (polygon && clusterNames[idx]) { + const extent = polygon.getExtent(); + const centerX = (extent[0] + extent[2]) / 2; + const topY = extent[3]; + const labelPoint = new Point([centerX, topY]); + + const clusterName = clusterNames[idx]; + const clusterColor = clusterColors.get(clusterName) || '#FF5733'; + const rgbColor = hexToRgb(clusterColor); + + const clusterNodeCount = polygon.get('clusterNodeCount') || 0; + const infraClusterNameStyle = new Style({ + text: new Text({ + text: `${clusterName} (${clusterNodeCount})`, + font: "bold 14px sans-serif", + scale: 1.0, + offsetY: 12, + stroke: new Stroke({ + color: [255, 255, 255, 1], + width: 2, + }), + fill: new Fill({ + color: rgbColor, + }), + }), + }); + + vectorContext.setStyle(infraClusterNameStyle); + vectorContext.drawGeometry(labelPoint); + } + }); + } + } + } + + // Draw K8s Cluster Group labels (drawn last to appear on top of polygons) + for (i = k8sClusterGroups.length - 1; i >= 0; --i) { + if (k8sClusterGroupNames[i]) { + const extent = k8sClusterGroups[i].getExtent(); + const centerX = (extent[0] + extent[2]) / 2; + const topY = extent[3]; // Use top of polygon instead of center + const labelPoint = new Point([centerX, topY]); + + const k8sGroupNameStyle = new Style({ + text: new Text({ + text: `⎈ ${k8sClusterGroupNames[i]}`, // Kubernetes helm symbol + font: "bold 28px sans-serif", // Larger than individual K8s cluster labels (24px) + scale: 1.0, + offsetY: 0, // Label at the top edge of the polygon + stroke: new Stroke({ + color: [255, 255, 255, 1], // White stroke + width: 3, + }), + fill: new Fill({ + color: [75, 0, 130, 1], // Indigo text + }), + }), + }); + + vectorContext.setStyle(k8sGroupNameStyle); + vectorContext.drawGeometry(labelPoint); + } + } + + + map.render(); +} + +tileLayer.on("postrender", function (event) { + drawObjects(event); +}); + diff --git a/vite.config.js b/vite.config.js index 2e79cfb..e66b83e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,4 @@ import { defineConfig } from 'vite'; -import { resolve } from 'path'; -import fs from 'fs'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; export default defineConfig({ @@ -13,35 +11,7 @@ export default defineConfig({ process: true, }, }), - { - name: 'copy-static-assets', - closeBundle() { - const copyFile = (src, dest) => { - const s = resolve(__dirname, src); - const d = resolve(__dirname, dest); - if (fs.existsSync(s)) { - fs.copyFileSync(s, d); - } - }; - const copyDir = (src, dest) => { - const s = resolve(__dirname, src); - const d = resolve(__dirname, dest); - if (fs.existsSync(s)) { - fs.mkdirSync(d, { recursive: true }); - fs.cpSync(s, d, { recursive: true }); - } - }; - // Copy static assets to dist/ - copyDir('img', 'dist/img'); - copyFile('dashboard.html', 'dist/dashboard.html'); - copyFile('dashboard.js', 'dist/dashboard.js'); - copyFile('swagger.html', 'dist/swagger.html'); - copyFile('redoc-swagger.html', 'dist/redoc-swagger.html'); - copyFile('scalar.html', 'dist/scalar.html'); - copyFile('runtime-params.json', 'dist/runtime-params.json'); - }, - }, ], server: { host: '0.0.0.0',