From 6de48a59f5682621d96521e4e48dfbe731855a31 Mon Sep 17 00:00:00 2001 From: eunjuny Date: Mon, 12 Oct 2026 15:23:49 +0900 Subject: [PATCH 1/5] fix: preserve Kubernetes memory quantity units in utilization metrics --- .../service/KubernetesMonitoringService.java | 21 ++-- .../KubernetesMonitoringMemoryUsageTest.java | 113 ++++++++++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringMemoryUsageTest.java diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringService.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringService.java index 71dbb41..40ae022 100644 --- a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringService.java +++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringService.java @@ -915,7 +915,8 @@ private Map getResourceUsagePercentage(KubernetesClient client, } double cpuUsagePercentage = (totalCpuUsage / totalCpuCapacity) * 100; - double memoryUsagePercentage = (totalMemoryUsage / (totalMemoryCapacity * 1024)) * 100; + // Both usage and capacity are in GiB. Do not add a unit conversion here. + double memoryUsagePercentage = (totalMemoryUsage / totalMemoryCapacity) * 100; // 소수점 2자리까지 반올림 Double roundedCpuUsage = Math.round(cpuUsagePercentage * 100.0) / 100.0; @@ -1112,7 +1113,8 @@ private double getTotalMemoryCapacity(KubernetesClient client) { .filter(node -> node.getStatus() != null && node.getStatus().getCapacity() != null) .mapToDouble(node -> { try { - return parseMemoryUsage(node.getStatus().getCapacity().get("memory").getAmount()); + // Quantity.getAmount() drops Ki/Mi/Gi; preserve the unit. + return parseMemoryUsage(node.getStatus().getCapacity().get("memory").toString()); } catch (Exception e) { log.warn("Error parsing memory capacity for node: {}", node.getMetadata().getName(), e); return 0.0; @@ -1154,16 +1156,11 @@ private double parseMemoryUsage(String memoryUsage) { } try { - if (memoryUsage.endsWith("Ki")) { - return Double.parseDouble(memoryUsage.substring(0, memoryUsage.length() - 2)) / (1024.0 * 1024.0); - } else if (memoryUsage.endsWith("Mi")) { - return Double.parseDouble(memoryUsage.substring(0, memoryUsage.length() - 2)) / 1024.0; - } else if (memoryUsage.endsWith("Gi")) { - return Double.parseDouble(memoryUsage.substring(0, memoryUsage.length() - 2)); - } else { - return Double.parseDouble(memoryUsage) / (1024.0 * 1024.0 * 1024.0); - } - } catch (NumberFormatException e) { + // Kubernetes Quantity supports binary, decimal and exponent formats. + // For memory its numerical amount is bytes; normalize once to GiB. + return new Quantity(memoryUsage.trim()).getNumericalAmount().doubleValue() + / (1024.0 * 1024.0 * 1024.0); + } catch (IllegalArgumentException | ArithmeticException e) { log.warn("Error parsing memory usage: {}", memoryUsage, e); return 0.0; } diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringMemoryUsageTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringMemoryUsageTest.java new file mode 100644 index 0000000..2e51619 --- /dev/null +++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringMemoryUsageTest.java @@ -0,0 +1,113 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.test.util.ReflectionTestUtils; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.GenericKubernetesResourceList; +import io.fabric8.kubernetes.api.model.NodeBuilder; +import io.fabric8.kubernetes.api.model.NodeList; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.ServiceList; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.dsl.ServiceResource; +import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext; + +class KubernetesMonitoringMemoryUsageTest { + + private final KubernetesMonitoringService service = new KubernetesMonitoringService( + null, null, null, null, null, null, null, null, null, null, null, null); + + @ParameterizedTest + @CsvSource({ + "33554432Ki,512Mi", "32Gi,512Mi", "32768Mi,512Mi", + "34359738368,512Mi", "34.359738368G,512Mi", "0.03125Ti,512Mi", + "32Gi,524288Ki", "32Gi,0.5Gi", "32Gi,536870912", "32Gi,536.870912M" + }) + void equivalentUnitsGiveTheSameMemoryPercentage(String capacity, String usage) { + Map result = usage(List.of(capacity), List.of(usage)); + assertThat(result.get("memoryPercentage")).isEqualTo(1.56); + assertThat(result.get("memoryBytes")).isEqualTo(536870912L); + assertThat(result.get("cpuPercentage")).isEqualTo(3.13); + } + + @Test + void aggregatesMultipleNodesAndPodsWithDifferentUnits() { + Map result = usage(List.of("16Gi", "16777216Ki"), List.of("256Mi", "262144Ki")); + assertThat(result.get("memoryPercentage")).isEqualTo(1.56); + assertThat(result.get("memoryBytes")).isEqualTo(536870912L); + assertThat(result.get("cpuPercentage")).isEqualTo(3.13); + } + + @ParameterizedTest + @CsvSource({"32Gi,0,0.0", "32Gi,32Gi,100.0", "32Gi,1Gi,3.13", "8Gi,2Gi,25.0"}) + void preservesZeroFullAndFractionalUsage(String capacity, String memory, double expected) { + assertThat(usage(List.of(capacity), List.of(memory)).get("memoryPercentage")).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({"524288Ki,0.5", "512Mi,0.5", "0.5Gi,0.5", "536870912,0.5", "536.870912M,0.5", "0.00048828125Ti,0.5"}) + void convertsMemoryToGiBWithoutDroppingItsUnit(String value, double expected) { + Double actual = ReflectionTestUtils.invokeMethod(service, "parseMemoryUsage", value); + assertThat(actual).isEqualTo(expected); + } + + @Test + void malformedMemoryRemainsNonFatal() { + assertThat((Double) ReflectionTestUtils.invokeMethod(service, "parseMemoryUsage", "not-memory")).isZero(); + assertThat((Double) ReflectionTestUtils.invokeMethod(service, "parseMemoryUsage", "")).isZero(); + } + + @SuppressWarnings("unchecked") + private Map usage(List capacities, List memories) { + KubernetesClient client = mock(KubernetesClient.class, RETURNS_DEEP_STUBS); + NodeList nodes = new NodeList(); + nodes.setItems(capacities.stream().map(memory -> new NodeBuilder() + .withNewMetadata().withName("worker").endMetadata() + .withNewStatus().withCapacity(Map.of("memory", new Quantity(memory), "cpu", new Quantity("8"))) + .endStatus().build()).toList()); + when(client.nodes().list()).thenReturn(nodes); + + List pods = java.util.stream.IntStream.range(0, memories.size()).mapToObj(i -> new PodBuilder() + .withNewMetadata().withName("nginx-" + i).endMetadata() + .withNewSpec().endSpec().withNewStatus().withPhase("Running").endStatus().build()).toList(); + GenericKubernetesResourceList metrics = new GenericKubernetesResourceList(); + metrics.setItems(java.util.stream.IntStream.range(0, memories.size()).mapToObj(i -> { + GenericKubernetesResource metric = new GenericKubernetesResource(); + metric.setMetadata(new ObjectMetaBuilder().withName("nginx-" + i).build()); + metric.setAdditionalProperty("containers", List.of(Map.of("usage", Map.of("cpu", "250m", "memory", memories.get(i))))); + return metric; + }).toList()); + MixedOperation> metricsOperation = mock(MixedOperation.class); + when(client.genericKubernetesResources(any(ResourceDefinitionContext.class))).thenReturn(metricsOperation); + when(metricsOperation.inNamespace("default")).thenReturn(metricsOperation); + when(metricsOperation.list()).thenReturn(metrics); + ServiceList services = new ServiceList(); + services.setItems(List.of()); + MixedOperation> servicesOperation = mock(MixedOperation.class); + when(client.services()).thenReturn(servicesOperation); + when(servicesOperation.inNamespace("default")).thenReturn(servicesOperation); + when(servicesOperation.withLabel(anyString(), anyString())).thenReturn(servicesOperation); + when(servicesOperation.list()).thenReturn(services); + return ReflectionTestUtils.invokeMethod(service, "getResourceUsagePercentage", + client, "default", "nginx", "nginx", pods, (long) pods.size(), 0L, 0L); + } +} From 90a31d54ca1351a38c465b5660774f0b226c3181 Mon Sep 17 00:00:00 2001 From: eunjuny Date: Mon, 12 Oct 2026 15:23:49 +0900 Subject: [PATCH 2/5] fix: preserve Kubernetes memory quantity units in utilization metrics --- .../service/KubernetesMonitoringService.java | 21 ++-- .../KubernetesMonitoringMemoryUsageTest.java | 113 ++++++++++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringMemoryUsageTest.java diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringService.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringService.java index c44972b..c474a42 100644 --- a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringService.java +++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringService.java @@ -919,7 +919,8 @@ private Map getResourceUsagePercentage(KubernetesClient client, } double cpuUsagePercentage = (totalCpuUsage / totalCpuCapacity) * 100; - double memoryUsagePercentage = (totalMemoryUsage / (totalMemoryCapacity * 1024)) * 100; + // Both usage and capacity are in GiB. Do not add a unit conversion here. + double memoryUsagePercentage = (totalMemoryUsage / totalMemoryCapacity) * 100; // 소수점 2자리까지 반올림 Double roundedCpuUsage = Math.round(cpuUsagePercentage * 100.0) / 100.0; @@ -1116,7 +1117,8 @@ private double getTotalMemoryCapacity(KubernetesClient client) { .filter(node -> node.getStatus() != null && node.getStatus().getCapacity() != null) .mapToDouble(node -> { try { - return parseMemoryUsage(node.getStatus().getCapacity().get("memory").getAmount()); + // Quantity.getAmount() drops Ki/Mi/Gi; preserve the unit. + return parseMemoryUsage(node.getStatus().getCapacity().get("memory").toString()); } catch (Exception e) { log.warn("Error parsing memory capacity for node: {}", node.getMetadata().getName(), e); return 0.0; @@ -1158,16 +1160,11 @@ private double parseMemoryUsage(String memoryUsage) { } try { - if (memoryUsage.endsWith("Ki")) { - return Double.parseDouble(memoryUsage.substring(0, memoryUsage.length() - 2)) / (1024.0 * 1024.0); - } else if (memoryUsage.endsWith("Mi")) { - return Double.parseDouble(memoryUsage.substring(0, memoryUsage.length() - 2)) / 1024.0; - } else if (memoryUsage.endsWith("Gi")) { - return Double.parseDouble(memoryUsage.substring(0, memoryUsage.length() - 2)); - } else { - return Double.parseDouble(memoryUsage) / (1024.0 * 1024.0 * 1024.0); - } - } catch (NumberFormatException e) { + // Kubernetes Quantity supports binary, decimal and exponent formats. + // For memory its numerical amount is bytes; normalize once to GiB. + return new Quantity(memoryUsage.trim()).getNumericalAmount().doubleValue() + / (1024.0 * 1024.0 * 1024.0); + } catch (IllegalArgumentException | ArithmeticException e) { log.warn("Error parsing memory usage: {}", memoryUsage, e); return 0.0; } diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringMemoryUsageTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringMemoryUsageTest.java new file mode 100644 index 0000000..2e51619 --- /dev/null +++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesMonitoringMemoryUsageTest.java @@ -0,0 +1,113 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.test.util.ReflectionTestUtils; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.GenericKubernetesResourceList; +import io.fabric8.kubernetes.api.model.NodeBuilder; +import io.fabric8.kubernetes.api.model.NodeList; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.ServiceList; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.dsl.ServiceResource; +import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext; + +class KubernetesMonitoringMemoryUsageTest { + + private final KubernetesMonitoringService service = new KubernetesMonitoringService( + null, null, null, null, null, null, null, null, null, null, null, null); + + @ParameterizedTest + @CsvSource({ + "33554432Ki,512Mi", "32Gi,512Mi", "32768Mi,512Mi", + "34359738368,512Mi", "34.359738368G,512Mi", "0.03125Ti,512Mi", + "32Gi,524288Ki", "32Gi,0.5Gi", "32Gi,536870912", "32Gi,536.870912M" + }) + void equivalentUnitsGiveTheSameMemoryPercentage(String capacity, String usage) { + Map result = usage(List.of(capacity), List.of(usage)); + assertThat(result.get("memoryPercentage")).isEqualTo(1.56); + assertThat(result.get("memoryBytes")).isEqualTo(536870912L); + assertThat(result.get("cpuPercentage")).isEqualTo(3.13); + } + + @Test + void aggregatesMultipleNodesAndPodsWithDifferentUnits() { + Map result = usage(List.of("16Gi", "16777216Ki"), List.of("256Mi", "262144Ki")); + assertThat(result.get("memoryPercentage")).isEqualTo(1.56); + assertThat(result.get("memoryBytes")).isEqualTo(536870912L); + assertThat(result.get("cpuPercentage")).isEqualTo(3.13); + } + + @ParameterizedTest + @CsvSource({"32Gi,0,0.0", "32Gi,32Gi,100.0", "32Gi,1Gi,3.13", "8Gi,2Gi,25.0"}) + void preservesZeroFullAndFractionalUsage(String capacity, String memory, double expected) { + assertThat(usage(List.of(capacity), List.of(memory)).get("memoryPercentage")).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({"524288Ki,0.5", "512Mi,0.5", "0.5Gi,0.5", "536870912,0.5", "536.870912M,0.5", "0.00048828125Ti,0.5"}) + void convertsMemoryToGiBWithoutDroppingItsUnit(String value, double expected) { + Double actual = ReflectionTestUtils.invokeMethod(service, "parseMemoryUsage", value); + assertThat(actual).isEqualTo(expected); + } + + @Test + void malformedMemoryRemainsNonFatal() { + assertThat((Double) ReflectionTestUtils.invokeMethod(service, "parseMemoryUsage", "not-memory")).isZero(); + assertThat((Double) ReflectionTestUtils.invokeMethod(service, "parseMemoryUsage", "")).isZero(); + } + + @SuppressWarnings("unchecked") + private Map usage(List capacities, List memories) { + KubernetesClient client = mock(KubernetesClient.class, RETURNS_DEEP_STUBS); + NodeList nodes = new NodeList(); + nodes.setItems(capacities.stream().map(memory -> new NodeBuilder() + .withNewMetadata().withName("worker").endMetadata() + .withNewStatus().withCapacity(Map.of("memory", new Quantity(memory), "cpu", new Quantity("8"))) + .endStatus().build()).toList()); + when(client.nodes().list()).thenReturn(nodes); + + List pods = java.util.stream.IntStream.range(0, memories.size()).mapToObj(i -> new PodBuilder() + .withNewMetadata().withName("nginx-" + i).endMetadata() + .withNewSpec().endSpec().withNewStatus().withPhase("Running").endStatus().build()).toList(); + GenericKubernetesResourceList metrics = new GenericKubernetesResourceList(); + metrics.setItems(java.util.stream.IntStream.range(0, memories.size()).mapToObj(i -> { + GenericKubernetesResource metric = new GenericKubernetesResource(); + metric.setMetadata(new ObjectMetaBuilder().withName("nginx-" + i).build()); + metric.setAdditionalProperty("containers", List.of(Map.of("usage", Map.of("cpu", "250m", "memory", memories.get(i))))); + return metric; + }).toList()); + MixedOperation> metricsOperation = mock(MixedOperation.class); + when(client.genericKubernetesResources(any(ResourceDefinitionContext.class))).thenReturn(metricsOperation); + when(metricsOperation.inNamespace("default")).thenReturn(metricsOperation); + when(metricsOperation.list()).thenReturn(metrics); + ServiceList services = new ServiceList(); + services.setItems(List.of()); + MixedOperation> servicesOperation = mock(MixedOperation.class); + when(client.services()).thenReturn(servicesOperation); + when(servicesOperation.inNamespace("default")).thenReturn(servicesOperation); + when(servicesOperation.withLabel(anyString(), anyString())).thenReturn(servicesOperation); + when(servicesOperation.list()).thenReturn(services); + return ReflectionTestUtils.invokeMethod(service, "getResourceUsagePercentage", + client, "default", "nginx", "nginx", pods, (long) pods.size(), 0L, 0L); + } +} From b42d255fe887429b7026c455b08204263ff82b51 Mon Sep 17 00:00:00 2001 From: eunjuny Date: Wed, 14 Oct 2026 13:29:55 +0900 Subject: [PATCH 3/5] fix: expose deployment failure logs and stabilize detail modal lifecycle --- applicationFE/package.json | 1 + .../scripts/test-application-detail-modal.mjs | 136 +++++++++++++++++ .../scripts/test-deployment-submission.mjs | 6 +- .../scripts/test-ingress-preparation.mjs | 22 ++- applicationFE/src/common/request.ts | 9 +- .../src/utils/deploymentSubmission.ts | 6 +- .../components/applicationDetailModal.vue | 142 +++++++++++------- .../applicationInstallationForm.vue | 46 +++++- .../components/applicationStatusList.vue | 32 +--- .../components/softwareCatalogList.vue | 22 +-- .../exception/GlobalExceptionHandler.java | 8 + .../service/DeploymentFailureException.java | 36 +++++ .../service/DeploymentSubmissionService.java | 3 +- .../ApplicationOrchestrationServiceImpl.java | 9 ++ .../kubernetes/service/KubernetesService.java | 8 +- .../DeploymentFailureExceptionTest.java | 26 ++++ .../DeploymentSubmissionServiceTest.java | 10 ++ ...nOrchestrationServiceProjectScopeTest.java | 15 ++ .../service/KubernetesServiceFailureTest.java | 62 ++++++++ 19 files changed, 480 insertions(+), 119 deletions(-) create mode 100644 applicationFE/scripts/test-application-detail-modal.mjs create mode 100644 src/main/java/kr/co/mcmp/softwarecatalog/application/service/DeploymentFailureException.java create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/application/service/DeploymentFailureExceptionTest.java create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesServiceFailureTest.java diff --git a/applicationFE/package.json b/applicationFE/package.json index 42dea9a..be26ae3 100644 --- a/applicationFE/package.json +++ b/applicationFE/package.json @@ -15,6 +15,7 @@ "test:ibm-ingress-tls": "node scripts/test-ibm-ingress-tls.mjs", "test:ingress-preparation": "node scripts/test-ingress-preparation.mjs", "test:nhn-cinder-addon": "node scripts/test-nhn-cinder-addon.mjs", + "test:detail-modal": "node scripts/test-application-detail-modal.mjs", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "format": "prettier --write src/" }, diff --git a/applicationFE/scripts/test-application-detail-modal.mjs b/applicationFE/scripts/test-application-detail-modal.mjs new file mode 100644 index 0000000..6c2b401 --- /dev/null +++ b/applicationFE/scripts/test-application-detail-modal.mjs @@ -0,0 +1,136 @@ +// Run against the real Vite app: AM_UI_TEST_URL=http://127.0.0.1:4179 +// PLAYWRIGHT_MODULE_PATH may point to an existing Playwright installation. +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +const { chromium } = createRequire(import.meta.url)(process.env.PLAYWRIGHT_MODULE_PATH || 'playwright') +const origin = process.env.AM_UI_TEST_URL || 'http://127.0.0.1:4179' +const browser = await chromium.launch({ headless: true, channel: 'chrome' }) +const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }) +const errors = [] +page.on('pageerror', error => { errors.push(error.message); console.error('Browser error:', error.message) }) +let state = 'FAILED', delay = false, release, deny = false, scenarios = 0 +await page.route('**/*', async route => { + const path = new URL(route.request().url()).pathname + if (!['/api/applications/', '/applications/', '/catalog/', '/cbtumblebug/'].some(p => path.startsWith(p))) return route.continue() + let data = path.startsWith('/catalog/') || path.startsWith('/cbtumblebug/') ? [] : null + if (path.includes('/status/groups')) data = [{ + id: 1, deploymentHistoryId: 42, applicationName: 'Nginx', status: state, + deploymentType: 'K8S', namespace: 'default', clusterName: 'k5', checkedAt: '2026-10-13T08:00:00' + }] + if (path.includes('/integrated/deployment/')) { + if (delay) await new Promise(resolve => { release = resolve }) + if (deny) return route.fulfill({ status: 401, json: { code: 401, message: 'Unauthorized' } }) + data = { integratedInfo: { + deploymentId: 42, catalogName: 'Nginx', status: state, applicationStatus: state === 'SUCCESS' ? 'RUNNING' : state, + deploymentType: 'K8S', namespace: 'default', clusterName: 'k5', operationHistories: [], errorLogs: [], + deploymentLogs: [{ id: 1, logType: state === 'SUCCESS' ? 'INFO' : 'ERROR', logMessage: 'Deployment log: ' + state }] + } } + } + return route.fulfill({ json: { code: 200, data } }) +}) +const modal = page.locator('#application-status-detail-modal') +const openStatus = async () => { + await page.locator('.tabulator-row').first().getByText('Nginx', { exact: true }).click() + await modal.locator('.modal-body').waitFor({ state: 'visible' }) +} +const assertClosed = async () => { + await modal.waitFor({ state: 'hidden' }) + await page.waitForTimeout(200) + assert.equal(await page.locator('.modal-backdrop').count(), 0) + assert.equal(await page.evaluate(() => document.body.classList.contains('modal-open')), false) + assert.equal(await page.evaluate(() => document.body.style.overflow), '') + scenarios++ +} +try { + await page.goto(origin + '/web/applications/status') + await page.locator('.tabulator-row').first().waitFor() + // A second Bootstrap bundle/global must not own this component's lifecycle. + await page.evaluate(() => { window.bootstrap = { Modal: class { constructor() { throw Error('Foreign Bootstrap must not be used') } } } }) + for (state of ['SUCCESS', 'FAILED', 'PARTIAL_SUCCESS']) { + for (const action of ['footer', 'header', 'escape', 'backdrop']) { + await openStatus() + await modal.getByText('Deployment log: ' + state, { exact: true }).last().waitFor() + await page.waitForTimeout(400) + if (action === 'footer') await modal.locator('button.btn-secondary').click() + if (action === 'header') await modal.locator('button.btn-close').click() + if (action === 'escape') await page.keyboard.press('Escape') + if (action === 'backdrop') await page.mouse.click(5, 5) + await assertClosed() + } + } + // Close immediately during animation/loading; a late response must not resurrect the dialog. + delay = true + await openStatus() + await modal.locator('button.btn-close').click({ force: true }) + await assertClosed() + while (!release) await page.waitForTimeout(20) + delay = false; release() + await page.waitForTimeout(300) + assert(!(await modal.isVisible())) + assert.equal(await modal.locator('.alert-danger').count(), 0) + // A denied API is not a reason to trap the user in an empty dialog. + deny = true + await openStatus() + await modal.getByText('No data available', { exact: true }).waitFor() + await page.waitForTimeout(400) + await modal.locator('button.btn-secondary').click() + await assertClosed(); deny = false + await openStatus() + await page.waitForTimeout(400) + await page.evaluate(async () => { + await document.querySelector('#app').__vue_app__.config.globalProperties.$router.push('/web/applications/swcatalog') + }) + await page.getByRole('button', { name: 'Deploy', exact: true }).first().waitFor() + await page.waitForTimeout(600) + assert.equal(await page.locator('.modal-backdrop').count(), 0) + assert.equal(await page.evaluate(() => document.body.classList.contains('modal-open')), false) + scenarios++ + // Catalog and deployment-failure entry points share the exact same detail component. + await page.evaluate(() => { + const seen = new Set() + const find = vnode => { + if (!vnode || seen.has(vnode)) return + seen.add(vnode) + if (vnode.component?.type?.__file?.endsWith('/softwareCatalogList.vue')) return vnode.component + const nodes = [vnode.component?.subTree, ...(Array.isArray(vnode.children) ? vnode.children : [])] + for (const node of nodes) { const found = find(node); if (found) return found } + } + const catalog = find(document.querySelector('#app').__vue_app__._instance.subTree) + if (!catalog) throw Error('Catalog component not found') + catalog.setupState.openApplicationDetail(42) + }) + const catalogModal = page.locator('#application-detail-modal') + await catalogModal.locator('.modal-body').waitFor({ state: 'visible' }) + await page.waitForTimeout(400) + await catalogModal.locator('button.btn-secondary').click() + await catalogModal.waitFor({ state: 'hidden' }); scenarios++ + await page.getByRole('button', { name: 'Deploy', exact: true }).first().click() + await page.waitForTimeout(500) + await page.evaluate(() => { + const s = document.querySelector('#install-form').__vueParentComponent.setupState + window.installState = s + s.deploymentFailure = { message: 'Test failure', deploymentId: 42, unknown: false } + s.selectMci = 'preserved-input' + }) + for (const action of ['footer', 'escape']) { + await page.getByRole('button', { name: 'View deployment logs' }).click() + const detail = page.locator('#install-form-failure-detail') + await detail.locator('.alert-danger').waitFor() + await page.waitForTimeout(400) + if (action === 'footer') await detail.locator('button.btn-secondary').click() + else await page.keyboard.press('Escape') + await page.locator('#install-form.show').waitFor() + await page.waitForTimeout(400) + assert.equal(await page.evaluate(() => window.installState.selectMci), 'preserved-input') + assert.equal(await page.locator('.modal-backdrop').count(), 1) + scenarios++ + } + await page.locator('#install-form button.btn-close').click() + await page.locator('#install-form').waitFor({ state: 'hidden' }) + await page.waitForTimeout(300) + assert.equal(await page.locator('.modal-backdrop').count(), 0) + assert.deepEqual(errors, []) + console.log('PASS: ' + scenarios + ' real-browser modal scenarios; close/reopen, transitions, late responses, auth failure, route cleanup, catalog, failure logs and retained inputs') +} finally { + await browser.close() +} diff --git a/applicationFE/scripts/test-deployment-submission.mjs b/applicationFE/scripts/test-deployment-submission.mjs index f0d710b..503f7df 100644 --- a/applicationFE/scripts/test-deployment-submission.mjs +++ b/applicationFE/scripts/test-deployment-submission.mjs @@ -3,13 +3,17 @@ import { readFile } from 'node:fs/promises' import ts from 'typescript' const source=await readFile(new URL('../src/utils/deploymentSubmission.ts',import.meta.url),'utf8') const js=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText -const {submitAndTrackDeployment,DeploymentStatusUnknown,deploymentOperationId}=await import('data:text/javascript;base64,'+Buffer.from(js).toString('base64')) +const {submitAndTrackDeployment,DeploymentStatusUnknown,DeploymentFailed,deploymentOperationId}=await import('data:text/javascript;base64,'+Buffer.from(js).toString('base64')) const state=(s)=>({id:'op1',namespace:'ns1',state:s,message:s,deploymentId:22}) const sleep=async()=>{} let submits=0,polls=0 const result=await submitAndTrackDeployment('op1','ns1',async()=>{submits++;throw {response:{status:504}}},async()=>{polls++;return state(polls===1?'RUNNING':'SUCCEEDED')},()=>true,sleep) assert.equal(result.data.id,22);assert.equal(submits,1);assert.equal(polls,2) for(const s of ['FAILED','PARTIAL_SUCCESS']) await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state(s),async()=>assert.fail(),()=>true,sleep),new RegExp(s)) +for (const deploymentId of [22, undefined]) { + await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>({...state('FAILED'), deploymentId}),async()=>assert.fail(),()=>true,sleep), + error => error instanceof DeploymentFailed && error.deploymentId === deploymentId && error.message === 'FAILED') +} await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state('INTERRUPTED'),async()=>assert.fail(),()=>true,sleep),DeploymentStatusUnknown) await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state('QUEUED'),async()=>{throw Error('network')},()=>true,sleep),DeploymentStatusUnknown) await assert.rejects(submitAndTrackDeployment('op1','ns1',async()=>state('QUEUED'),async()=>assert.fail(),()=>false,sleep),DeploymentStatusUnknown) diff --git a/applicationFE/scripts/test-ingress-preparation.mjs b/applicationFE/scripts/test-ingress-preparation.mjs index d0cc8c4..f67405a 100644 --- a/applicationFE/scripts/test-ingress-preparation.mjs +++ b/applicationFE/scripts/test-ingress-preparation.mjs @@ -58,7 +58,7 @@ return { runInstall, cancel: () => { preparationEpoch++ }, invalidate: () => { s function harness(options = {}) { const calls = [] const values = Object.fromEntries(Object.entries({ - jupyterInstallationUnsupported: false, deploying: false, deploymentCompleted: false, modalTitle: 'Application Installation', selectInfra: options.infra || 'K8S', specCheckFlag: false, + interruptedDeployment: null, deploymentFailure: null, jupyterInstallationUnsupported: false, deploying: false, deploymentCompleted: false, modalTitle: 'Application Installation', selectInfra: options.infra || 'K8S', specCheckFlag: false, specChecking: false, projectScopeError: '', servicePortCidr: '203.0.113.4/32', ingressData: { ingressEnabled: options.ingress !== false, ingressHost: 'app.company.com', ingressPath: '/', ingressTlsEnabled: Boolean(options.tls) }, isJupyterObjectStorageCatalog: Boolean(options.jupyter), projectContextKey: 'workspace/project-a', @@ -70,7 +70,8 @@ function harness(options = {}) { }).map(([k,v]) => [k,ref(v)])) const args = { ...values, - toast: { success: m => calls.push(['success',m]), error: m => calls.push(['error',m]) }, + toast: { success: m => calls.push(['success',m]), error: m => calls.push(['error',m]), warning: m => calls.push(['warning',m]) }, + DeploymentFailed: class extends Error { constructor(message,id) { super(message); this.deploymentId=id } }, validateStorageClassSelection: () => true, emitDeploymentEvent: (...e) => calls.push(['event',...e]), getDeploymentId: () => 123, buildK8sAdditionalConfig: () => ({}), @@ -97,6 +98,23 @@ await test('actual IBM Deploy uses prepare -> poll READY -> application API with for(const key of ['namespace','clusterName','catalogId','ingressHost','servicePortCidr']) assert.equal(prepared[key],deployed[key]) assert.equal(deployed.openServicePort,false); assert.equal(h.deploying.value,false) }) +await test('actual form retains failure reason and history ID without reporting success', async () => { + const h=harness({ibm:false,deploy:async()=>{throw Object.assign(new Error('Enable Ingress to configure external CIDR access.'),{deploymentId:42})}}); + await h.runInstall(); + assert.deepEqual(h.deploymentFailure.value,{message:'Enable Ingress to configure external CIDR access.',deploymentId:42,unknown:false}); + assert.equal(h.calls.some(c=>c[0]==='success'),false); + assert.equal(h.calls.find(c=>c[0]==='event' && c[1]==='DEPLOY_FAILED')[2].deploymentId,42); +}) +await test('legacy FAILED result is not treated as success and transport uncertainty stays separate', async () => { + const failed=harness({ibm:false,deploy:async()=>({data:{id:123,status:'FAILED'}})}); + await failed.runInstall(); + assert.equal(failed.deploymentFailure.value.deploymentId,123); + assert.equal(failed.deploymentCompleted.value,false); + const unknown=harness({ibm:false,deploy:async()=>{throw Object.assign(new Error('May still be running'),{deploymentStatusUnknown:true})}}); + await unknown.runInstall(); + assert.equal(unknown.deploymentFailure.value.unknown,true); + assert.equal(unknown.calls.some(c=>c[0]==='event' && c[1]==='DEPLOY_FAILED'),false); +}) for(const event of ['cancel','invalidate','unmount','project']) { await test(`actual form ${event} during preparation does not call application deployment`, async () => { let h diff --git a/applicationFE/src/common/request.ts b/applicationFE/src/common/request.ts index e1309cd..74a23b9 100644 --- a/applicationFE/src/common/request.ts +++ b/applicationFE/src/common/request.ts @@ -85,7 +85,10 @@ service.interceptors.response.use( return res; } else { if (!response.config?.url?.startsWith('/applications/deployment-submissions/')) toast.error(res.detail) - return Promise.reject(new Error(res.message || "Error")); + const error = Object.assign(new Error(res.detail || res.message || "Error"), { + deploymentId: res.data?.deploymentId + }) + return Promise.reject(error); } }, error => { @@ -101,6 +104,10 @@ service.interceptors.response.use( if (axios.isCancel(error)) { return Promise.reject(error); } + if (res?.data?.data?.deploymentId) { + error.deploymentId = res.data.data.deploymentId + error.message = res.data.detail || res.data.message || error.message + } // toast.error(error.message) return Promise.reject(error); } diff --git a/applicationFE/src/utils/deploymentSubmission.ts b/applicationFE/src/utils/deploymentSubmission.ts index a21a364..45039f6 100644 --- a/applicationFE/src/utils/deploymentSubmission.ts +++ b/applicationFE/src/utils/deploymentSubmission.ts @@ -20,6 +20,10 @@ export class DeploymentStatusUnknown extends Error { constructor(message: string, readonly interruptedOperation?: { id: string, namespace: string }) { super(message) } } +export class DeploymentFailed extends Error { + constructor(message: string, readonly deploymentId?: number, readonly state = 'FAILED') { super(message) } +} + /** A transport failure is not an installation failure. Never automatically submit a second operation. */ export async function submitAndTrackDeployment( id: string, @@ -54,7 +58,7 @@ export async function submitAndTrackDeployment( if (status) { if (status.id !== id || status.namespace !== namespace) throw new DeploymentStatusUnknown('Deployment response did not match this operation. Check Apps Status before retrying.') if (status.state === 'SUCCEEDED') { terminal(); return { data: { id: status.deploymentId, status: 'SUCCESS' } } } - if (status.state === 'FAILED' || status.state === 'PARTIAL_SUCCESS' || status.state === 'ABANDONED') { terminal(); throw new Error(status.message || 'Installation failed. Open Apps Status for details.') } + if (status.state === 'FAILED' || status.state === 'PARTIAL_SUCCESS' || status.state === 'ABANDONED') { terminal(); throw new DeploymentFailed(status.message || 'Installation failed. Open Apps Status for details.', status.deploymentId, status.state) } if (status.state === 'INTERRUPTED') throw new DeploymentStatusUnknown(status.message, { id, namespace }) if (!['QUEUED', 'RUNNING'].includes(status.state)) throw new DeploymentStatusUnknown('Unknown deployment state. Check Apps Status before retrying.') } diff --git a/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue b/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue index f29bae3..1ed6716 100644 --- a/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue +++ b/applicationFE/src/views/softwareCatalog/components/applicationDetailModal.vue @@ -4,7 +4,7 @@ @@ -511,9 +516,10 @@