No favorites saved yet.
';
- return;
- }
- dropdown.innerHTML = f.map((cmd, i) =>
- `" +
- "" +
- "
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: '📄 Save as Template ',
- 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 += `${nodeGroupName} `;
- }
- });
-
- // 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 + "
" +
- "
" +
- "
" +
- "Available NodeGroups: " +
- "" +
- "-- Select NodeGroup -- " +
- nodeGroupOptions +
- " " +
- "
" +
- "
",
- 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:
-
-
-
-
- ⏸️ Suspend
-
-
- ▶️ Resume
-
-
- 🔄 Reboot
-
-
-
-
-
-
- 🧹 Refine
-
-
- ⏹️ Terminate
-
-
-
-
-
-
- 🔒 Hold Control
-
-
- 🛠️ Recovery Control
-
-
-
- `,
- 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.
-
-
-
- ⏭️ Continue
-
-
- ⬅️ Withdraw
-
-
-
- `
- });
-}
-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.
-
-
-
- 🩹 Reconcile
-
-
- 🛑 Abort
-
-
-
- `
- });
-}
-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 += `${infraId} `;
- }
- });
-
- 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)
" : "") +
- "
" +
- "
" +
- "Available Infras: " +
- "" +
- "-- Select Infra -- " +
- infraOptions +
- " " +
- "
" +
- "
",
- 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: `
-
-
- `,
- 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 = '-- Loading Nodes -- ';
-
- if (!infraId) {
- nodeSelect.innerHTML = '-- Select Infra First -- ';
- 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 = '-- Select Node or All -- ' +
- '🌐 All Nodes (Infra Snapshot - one per nodegroup) ' +
- nodes.map(nd => `${nd.id} (${nd.status}) `).join('');
- } catch (error) {
- console.error('Error loading Node list:', error);
- nodeSelect.innerHTML = '-- Error loading Nodes -- ';
- }
- };
-
- 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
-
-
-
-
-
- Status
- NodeGroup
- Node ID
- Image ID
- Result
- Error
-
-
-
- ${resultsHtml}
-
-
-
- ${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 = 'Provider (Region) ID (Status) OS (Arch) Description Source Node UID Created Action ';
-
- 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 += `
-
- ${providerRegion}
- ${idWithStatus}
- ${osInfo}
- ${descShort}
- ${img.sourceNodeUid ? img.sourceNodeUid.substring(0, 12) + '...' : 'N/A'}
- ${img.creationDate ? new Date(img.creationDate).toLocaleDateString() : 'N/A'}
-
- 👁️
- 🗑️
-
-
- `;
- });
-
- html += '
';
- 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 = `
-
-
-
- Command
- Infra / Node
- Status
- Started At
- Duration
- Action
-
-
-
- `;
-
- 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 += `
-
-
- ${cmdTruncatedEscaped}
-
- ${targetTextEscaped}
-
- ${statusIcon}
- ${statusEscaped}
-
-
- ${startedAtText}
-
-
- ${durationText}
-
-
- ${canCancel ?
- `
- Cancel
- ` :
- '- '
- }
-
-
- `;
- });
-
- tasksHtml += '
';
- }
-
- // 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 =>
- `${m} `
- ).join('');
-
- Swal.fire({
- title: `📋 Command Execution History `,
- html: `
-
-
-
- Infra:
-
- ${infraOptionsHtml}
-
-
-
- 🔄 Refresh Now
- ⏸️ Pause Auto-refresh
-
-
-
- 🟢 Auto-refreshing every 3 seconds |
- Last refresh: -
-
-
-
- `,
- 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 `${safeNsId} `;
- }).join('');
-
- // Build type tab buttons
- const typeTabsHtml = [
- `All `,
- ...TEMPLATE_TYPES.map(t =>
- `${t.icon} ${t.label} `
- )
- ].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: `
-
-
-
- Namespace:
-
- ${nsOptionsHtml}
-
- ➕ New Template
- 🔄 Refresh
-
-
- ${typeTabsHtml}
-
-
-
-
-
-
-
- `,
- 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 `
-
-
-
-
${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 =>
- `
- ${t.icon}
- ${t.label}
- `
- ).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)}
-
-
-
- ▶️ Apply This Template
- ${type === 'infra' ? `📋 Load to Config ` : ''}
- ${type === 'k8sCluster' ? `📋 Load to Config ` : ''}
- 📋 Copy JSON
- ⬅️ Back to List
-
-
- `,
- 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 `${safeNsId} `;
- }).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}
-
- Target Namespace *
-
- ${nsOptionsHtml}
-
-
-
- ${nameLabel} *
-
-
-
- Description (optional)
-
-
-
- `,
- 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: `
-
-
- Template Name *
-
-
-
- Description
-
-
-
- ${typeMeta.label} Request Body (JSON) *
-
-
-
- `,
- 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.
-
- Template Name *
-
-
-
- Description
-
-
-
-
Configuration Preview
-
-
-
- `,
- 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 = 'Loading... ';
- 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 `${safeName} (${z.recordCount} records) `;
- }).join('');
- } else {
- hostedZoneOptions = '(No hosted zones) ';
- 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 = '(Unavailable) ';
- 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}
-
-
- Hosted Zone (Domain)
-
- ${hostedZoneOptions}
-
-
-
-
-
-
🔍 Query DNS Records
-
-
- Record Name (optional)
-
-
-
- Query
-
-
-
-
-
-
-
-
-
✏️ Create / Update DNS Record (UPSERT)
-
-
-
- Record Name
-
-
-
- Record Type
-
- A
- AAAA
- CNAME
- TXT
-
-
-
- TTL (seconds)
-
-
-
- Routing Policy
-
- Simple (all IPs returned)
- Weighted 1:1:1 (Route53 picks one per query)
- Geoproximity (nearest server)
-
-
-
-
-
- ℹ️ Weighted : One A-record per IP, each with weight 1. Route53 picks one record per DNS query in proportion to its weight. TTL 30s recommended.
-
-
-
- ℹ️ Geoproximity : Each Node gets its own record with lat/lng coordinates. Route53 routes users to the nearest server. Infra source required (Node location data needed).
-
-
-
-
IP Source (choose one):
-
-
-
-
-
-
- Infra — Collect Public IPs from Nodes in Infra
-
-
-
-
- Namespace
-
-
-
- Infra
-
- Loading...
-
-
-
-
-
-
🏷️ Label Filter (optional — narrow down Nodes by labels)
-
- No filter — all Nodes in Infra will be used
-
-
-
-
- Clear All
-
-
-
Available Labels (click to add/remove)
-
- Select an Infra to see available labels...
-
-
-
- Matching Nodes: 0 / 0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Update DNS Record
-
-
-
-
-
- `;
-
- 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 element
- async function loadInfraList(nsId, selectEl, preselect) {
- selectEl.innerHTML = 'Loading... ';
- if (!nsId) { selectEl.innerHTML = '(No namespace) '; return; }
- try {
- var infraUrl = tbApiBase() + '/ns/' + nsId + '/infra?option=id';
- const infraResp = await axios.get(infraUrl, {
- auth: { username: config.username, password: config.password }, timeout: 15000
- });
- const infraList = infraResp.data?.output || [];
- if (infraList.length === 0) {
- selectEl.innerHTML = '(No Infras found) ';
- return;
- }
- selectEl.innerHTML = '';
- infraList.forEach(m => {
- const opt = document.createElement('option');
- opt.value = m;
- opt.textContent = m;
- if (m === preselect) opt.selected = true;
- selectEl.appendChild(opt);
- });
- } catch (e) {
- selectEl.innerHTML = '(Failed to load) ';
- }
- }
-
- // Initialize Infra dropdown and auto-load labels
- const infraSelect = document.getElementById('dns-infra-id');
- loadInfraList(namespace, infraSelect, infraIdValue).then(function() {
- if (infraSelect.value) {
- infraSelect.dispatchEvent(new Event('change'));
- }
- });
-
- // Reload Infra list when namespace changes
- const nsInput = document.getElementById('dns-infra-nsid');
- if (nsInput) {
- nsInput.addEventListener('change', function() {
- loadInfraList(this.value, infraSelect, '').then(function() {
- if (infraSelect.value) infraSelect.dispatchEvent(new Event('change'));
- });
- });
- }
-
- // Query button handler
- document.getElementById('dns-query-btn').addEventListener('click', async function() {
- const domain = document.getElementById('dns-hosted-zone').value;
- if (!domain) {
- document.getElementById('dns-query-result').style.display = 'block';
- document.getElementById('dns-query-result').innerHTML = 'Please select a hosted zone first.
';
- return;
- }
- const recordName = document.getElementById('dns-query-record').value.trim();
- const resultDiv = document.getElementById('dns-query-result');
- resultDiv.style.display = 'block';
- resultDiv.innerHTML = ' Querying...
';
-
- try {
- let url = `${tbApiBase()}/resources/globalDns/record?domainName=${encodeURIComponent(domain)}`;
- if (recordName) url += `&recordName=${encodeURIComponent(recordName)}`;
- const resp = await axios.get(url, { auth: { username: config.username, password: config.password }, timeout: 30000 });
- const records = resp.data?.record || [];
- if (records.length === 0) {
- resultDiv.innerHTML = 'No records found.
';
- } else {
- // Count deletable records (not NS/SOA)
- const deletableRecords = records.filter(r => r.type !== 'NS' && r.type !== 'SOA');
- let bulkBar = '';
- if (deletableRecords.length > 1) {
- bulkBar = `
-
- Select All
-
- 0 selected
-
- 🗑️ Delete Selected
-
-
`;
- }
-
- let tableHtml = '';
- 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 += 'Name Type TTL Values Policy ';
- 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 += `
- ${esc(r.name)}${setIdInfo}
- ${esc(r.type)}
- ${r.ttl}
- ${(r.values || []).map(v => esc(v)).join(', ')}
- ${policyBadge}
- `;
- });
- html += '
';
- 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 =>
- `${window.escapeHtml(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 *','','')}
-
-
-
- Fetch this provider's specs and images after registering
- (required before any VM can be created on it)
- `,
- 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.
-
- ${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 `
-
-
-
-
-
-
-
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('');
- 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 += '' +
- '' +
- '
' +
- '' +
- 'Valid Spec ' +
- 'Location Zone ' +
- 'GPU VRAM ' +
- 'Risk $/hr ' +
- 'Note ';
-
- (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 += '' +
- '' + validIcon + ' ' +
- '' + (c.specId || c.cspSpecName || '') + ' ' +
- '' + (c.providerName || '') + '/' + (c.regionName || '') + ' ' +
- '' + (c.suggestedZone || '—') + ' ' +
- '' + gpuCell + ' ' +
- '' + vramCell + ' ' +
- '' + (c.riskLevel || '—') + ' ' +
- '$' + ((c.costPerHour || 0).toFixed(3)) + ' ' +
- '' + note + ' ' +
- ' ';
- });
-
- var costLine = ns.validCandidates > 0
- ? 'Cost: $' + (ns.costPerHourMin || 0).toFixed(2) +
- ' – $' + (ns.costPerHourMax || 0).toFixed(2) + ' /hr per node'
- : 'No valid candidates ';
-
- html += '
' +
- '
' + 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 + '
' +
- '
' +
- '' +
- 'Spec ' +
- 'Connection Zone ' +
- 'Nodes $/hr ' +
- (hasGpuAttempt ? 'GPU ' : '') +
- ' ' +
- '' + attemptRows + '
' +
- (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 `
+
+ ${interval}s
+ `;
+ }).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 `${safeNs} `;
+ }).join('');
+ const nsSelectHtml = nsOptions || `${window.escapeHtml ? window.escapeHtml(curNs) : curNs} `;
+
+ // 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 `${holderId} (${connCount} conn${connCount !== 1 ? 's' : ''}) `;
+ }).join('');
+ const holderSelectHtml = holderOptions || `${window.escapeHtml(configCredentialHolder)} `;
+
+ Swal.fire({
+ title: '',
+ html: `
+
+
+
+
+
Namespace
+
${nsSelectHtml}
+
Active namespace for Provision and Control panels
+
+
+
+
+
+
Credential Holder
+
${holderSelectHtml}
+
Filter connections and map icons by holder
+
+
+
+
+
+
Refresh Interval
+
${intervalPills}
+
+
+
+
+
+
CSP Icon Style
+
+ Use CSP logos (branded icons)
+ Use generic colored icons (hide CSP logos)
+ Use 3D datacenter icons (colored rack)
+
+
+ ${Object.keys(window.cspGenericColors || {}).map(csp => {
+ const color = (window.cspGenericColors || {})[csp];
+ return '' +
+ ' ' +
+ csp.toUpperCase() + ' ';
+ }).join('')}
+
+
+
+
+
+
+
Cluster Labels
+
+
+ Show infra cluster labels on map
+
+
Display cluster IDs above cluster boundaries
+
+
+
+
NodeGroup Labels
+
+
+ Show nodegroup IDs on map
+
+
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: `
+
+ 🌐 Global DNS Management
+ 🔀 Nginx Gateway
+ ☁️ Register a CSP (runtime)
+
+ `,
+ 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
+
+
+
+ 🕹️ Control
+ 📊 Status
+ 🔑 Access Info
+ 📦 SSH Keys
+
+ 💻 Remote Cmd
+ 📋 Cmd Status
+ 📁 File Transfer
+ 🌐 Global DNS
+ 🔀 Gateway
+ ☁️ Register CSP
+ 🔗 Set Bastion
+
+ ⚖️ NLB
+ 🌐 Global NLB
+ 🔒 VPN
+ 🔥 Firewall
+ 📸 Snapshots
+ ⬆️ Scale Out
+
+ 📋 Copy Config
+ 📄 Save Template
+ 🗑️ Delete Infra
+
+
+ `,
+ 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',