This is an automated email from the ASF dual-hosted git repository.
abhishekpal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/master by this push:
new 7a791adf827 HDDS-13183. Create Cluster Capacity page UI. (#9584)
7a791adf827 is described below
commit 7a791adf827cfd1261b86c3abeed4656554c9b8f
Author: Abhishek Pal <[email protected]>
AuthorDate: Fri Jan 23 17:56:31 2026 +0530
HDDS-13183. Create Cluster Capacity page UI. (#9584)
---
hadoop-ozone/dist/src/main/license/bin/LICENSE.txt | 1 +
hadoop-ozone/dist/src/main/license/jar-report.txt | 1 +
hadoop-ozone/recon/pom.xml | 4 +
.../ozone/recon/api/DataNodeMetricsService.java | 23 +-
.../ozone/recon/api/PendingDeletionEndpoint.java | 72 +++-
.../webapps/recon/ozone-recon-web/api/db.json | 141 +++++++
.../webapps/recon/ozone-recon-web/api/routes.json | 7 +-
.../src/__tests__/capacity/Capacity.test.tsx | 95 +++++
.../mocks/capacityMocks/capacityResponseMocks.ts | 87 ++++
.../mocks/capacityMocks/capacityServer.ts | 59 +++
.../recon/ozone-recon-web/src/utils/themeIcons.tsx | 27 ++
.../overviewCardWrapper.tsx | 0
.../{overviewCard => cards}/overviewSimpleCard.tsx | 0
.../v2/components/cards/overviewStorageCard.tsx | 312 +++++++++++++++
.../overviewSummaryCard.tsx | 1 -
.../src/v2/components/navBar/navBar.tsx | 6 +
.../src/v2/constants/capacity.constants.tsx | 61 +++
.../src/v2/hooks/useAutoReload.hook.tsx | 7 +-
.../src/v2/pages/capacity/capacity.less | 177 +++++++++
.../src/v2/pages/capacity/capacity.tsx | 437 +++++++++++++++++++++
.../capacity/components/CapacityBreakdown.tsx | 74 ++++
.../pages/capacity/components/CapacityDetail.tsx | 191 +++++++++
.../pages/capacity/components/StackedProgress.tsx | 59 +++
.../pages/capacity/components/WrappedInfoIcon.tsx | 38 ++
.../capacity/constants/descriptions.constants.tsx | 27 ++
.../pages/capacity/constants/styles.constants.tsx | 43 ++
.../src/v2/pages/overview/overview.tsx | 6 +-
.../recon/ozone-recon-web/src/v2/routes-v2.tsx | 5 +
.../ozone-recon-web/src/v2/types/capacity.types.ts | 84 ++++
.../recon/api/TestPendingDeletionEndpoint.java | 269 +++++++++++++
pom.xml | 6 +
31 files changed, 2304 insertions(+), 16 deletions(-)
diff --git a/hadoop-ozone/dist/src/main/license/bin/LICENSE.txt
b/hadoop-ozone/dist/src/main/license/bin/LICENSE.txt
index 4679855b743..fda1e61820a 100644
--- a/hadoop-ozone/dist/src/main/license/bin/LICENSE.txt
+++ b/hadoop-ozone/dist/src/main/license/bin/LICENSE.txt
@@ -369,6 +369,7 @@ Apache License 2.0
org.apache.commons:commons-compress
org.apache.commons:commons-configuration2
org.apache.commons:commons-collections4
+ org.apache.commons:commons-csv
org.apache.commons:commons-lang3
org.apache.commons:commons-pool2
org.apache.commons:commons-text
diff --git a/hadoop-ozone/dist/src/main/license/jar-report.txt
b/hadoop-ozone/dist/src/main/license/jar-report.txt
index 057dda703fb..9aadc148b50 100644
--- a/hadoop-ozone/dist/src/main/license/jar-report.txt
+++ b/hadoop-ozone/dist/src/main/license/jar-report.txt
@@ -26,6 +26,7 @@ share/ozone/lib/commons-collections.jar
share/ozone/lib/commons-collections4.jar
share/ozone/lib/commons-compress.jar
share/ozone/lib/commons-configuration2.jar
+share/ozone/lib/commons-csv.jar
share/ozone/lib/commons-daemon.jar
share/ozone/lib/commons-digester.jar
share/ozone/lib/commons-io.jar
diff --git a/hadoop-ozone/recon/pom.xml b/hadoop-ozone/recon/pom.xml
index 90eb48d0921..51f01adb65f 100644
--- a/hadoop-ozone/recon/pom.xml
+++ b/hadoop-ozone/recon/pom.xml
@@ -102,6 +102,10 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-csv</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/DataNodeMetricsService.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/DataNodeMetricsService.java
index c37e8e65ace..6b3adf302da 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/DataNodeMetricsService.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/DataNodeMetricsService.java
@@ -24,6 +24,7 @@
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
@@ -271,6 +272,11 @@ private void handleCompletedFuture(
private void updateFinalState(CollectionContext context) {
// Update shared state atomically
synchronized (this) {
+ // Sort by pendingBlockSize in descending order so highest values appear
first
+ context.results.sort(
+
Comparator.comparingLong(DatanodePendingDeletionMetrics::getPendingBlockSize)
+ .reversed()
+ );
pendingDeletionList = context.results;
totalPendingDeletion = context.totalPending;
totalNodesQueried = context.totalQueried;
@@ -294,16 +300,23 @@ private void resetState() {
totalNodesFailed = 0;
}
- public DataNodeMetricsServiceResponse getCollectedMetrics() {
+ public DataNodeMetricsServiceResponse getCollectedMetrics(Integer limit) {
startTask();
if (currentStatus == MetricCollectionStatus.FINISHED) {
- return DataNodeMetricsServiceResponse.newBuilder()
+ DataNodeMetricsServiceResponse.Builder dnMetricsBuilder =
DataNodeMetricsServiceResponse.newBuilder();
+ dnMetricsBuilder
.setStatus(currentStatus)
- .setPendingDeletion(pendingDeletionList)
.setTotalPendingDeletionSize(totalPendingDeletion)
.setTotalNodesQueried(totalNodesQueried)
- .setTotalNodeQueryFailures(totalNodesFailed)
- .build();
+ .setTotalNodeQueryFailures(totalNodesFailed);
+
+ if (null == limit) {
+ return
dnMetricsBuilder.setPendingDeletion(pendingDeletionList).build();
+ } else {
+ return dnMetricsBuilder.setPendingDeletion(
+ pendingDeletionList.subList(0, Math.min(limit,
pendingDeletionList.size())
+ )).build();
+ }
}
return DataNodeMetricsServiceResponse.newBuilder()
.setStatus(currentStatus)
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/PendingDeletionEndpoint.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/PendingDeletionEndpoint.java
index 2fbb9c6bb8d..534c036dcf6 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/PendingDeletionEndpoint.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/api/PendingDeletionEndpoint.java
@@ -17,16 +17,24 @@
package org.apache.hadoop.ozone.recon.api;
+import java.io.BufferedWriter;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
import java.util.Map;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
+import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
+import javax.ws.rs.core.StreamingOutput;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVPrinter;
import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
import org.apache.hadoop.hdds.scm.protocol.StorageContainerLocationProtocol;
import org.apache.hadoop.ozone.recon.api.types.DataNodeMetricsServiceResponse;
+import org.apache.hadoop.ozone.recon.api.types.DatanodePendingDeletionMetrics;
import org.apache.hadoop.ozone.recon.api.types.ScmPendingDeletion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -56,15 +64,21 @@ public PendingDeletionEndpoint(
}
@GET
- public Response getPendingDeletionByComponent(@QueryParam("component")
String component) {
+ public Response getPendingDeletionByComponent(
+ @QueryParam("component")
+ String component,
+ @QueryParam("limit")
+ Integer limit
+ ) {
if (component == null || component.isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("component query parameter is required").build();
}
+
final String normalizedComponent = component.trim().toLowerCase();
switch (normalizedComponent) {
case "dn":
- return handleDataNodeMetrics();
+ return handleDataNodeMetrics(limit);
case "scm":
return handleScmPendingDeletion();
case "om":
@@ -75,8 +89,58 @@ public Response
getPendingDeletionByComponent(@QueryParam("component") String co
}
}
- private Response handleDataNodeMetrics() {
- DataNodeMetricsServiceResponse response =
dataNodeMetricsService.getCollectedMetrics();
+ @GET
+ @Path("/download")
+ public Response downloadPendingDeleteData() {
+ DataNodeMetricsServiceResponse dnMetricsResponse =
dataNodeMetricsService.getCollectedMetrics(null);
+
+ if (dnMetricsResponse.getStatus() !=
DataNodeMetricsService.MetricCollectionStatus.FINISHED) {
+ return Response.status(Response.Status.ACCEPTED)
+ .entity(dnMetricsResponse)
+ .type("application/json")
+ .build();
+ }
+
+ if (null == dnMetricsResponse.getPendingDeletionPerDataNode()) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity("Metrics data is missing despite FINISHED status.")
+ .type("text/plain")
+ .build();
+ }
+
+ StreamingOutput stream = output -> {
+ CSVFormat format = CSVFormat.DEFAULT.builder()
+ .setHeader("HostName", "Datanode UUID", "Pending Block Size
(bytes)").build();
+ try (CSVPrinter csvPrinter = new CSVPrinter(
+ new BufferedWriter(new OutputStreamWriter(output,
StandardCharsets.UTF_8)), format)) {
+ for (DatanodePendingDeletionMetrics metric :
dnMetricsResponse.getPendingDeletionPerDataNode()) {
+ csvPrinter.printRecord(
+ metric.getHostName(),
+ metric.getDatanodeUuid(),
+ metric.getPendingBlockSize()
+ );
+ }
+ csvPrinter.flush();
+ } catch (Exception e) {
+ LOG.error("Failed to stream CSV", e);
+ throw new WebApplicationException("Failed to generate CSV", e);
+ }
+ };
+
+ return Response.status(Response.Status.ACCEPTED)
+ .entity(stream)
+ .type("text/csv")
+ .header("Content-Disposition", "attachment;
filename=\"pending_deletion_all_datanode_stats.csv\"")
+ .build();
+ }
+
+ private Response handleDataNodeMetrics(Integer limit) {
+ if (null != limit && limit < 1) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity("Limit query parameter must be at-least 1").build();
+ }
+
+ DataNodeMetricsServiceResponse response =
dataNodeMetricsService.getCollectedMetrics(limit);
if (response.getStatus() ==
DataNodeMetricsService.MetricCollectionStatus.FINISHED) {
return Response.ok(response).build();
} else {
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json
index 30fed20baeb..37663e3704e 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json
@@ -6866,5 +6866,146 @@
"selectedRowKeys": [
"b5907812-a5f2-11ea-bb37-0242ac130011"
]
+ },
+ "utilization": {
+ "globalStorage": {
+ "totalUsedSpace": 30679040,
+ "totalFreeSpace": 539776978944,
+ "totalCapacity": 879519597390
+ },
+ "globalNamespace": {
+ "totalUsedSpace": 12349932,
+ "totalKeys": 1576
+ },
+ "usedSpaceBreakdown": {
+ "openKeyBytes": 19255266,
+ "committedKeyBytes": 1249923,
+ "preAllocatedContainerBytes": 1022024
+ },
+ "dataNodeUsage": [
+ {
+ "datanodeUuid": "1bf314dc-3eba-4774-9dbc-6d957cc7670b",
+ "hostName": "ozone-datanode-7.ozone_default",
+ "capacity": 125645656770,
+ "used": 4382720,
+ "remaining": 77110996992,
+ "committed": 0,
+ "minimumFreeSpace": 104857600,
+ "reserved": 12565822
+ },
+ {
+ "datanodeUuid": "8adead67-85a6-4ba9-942f-cd313f1472f9",
+ "hostName": "ozone-datanode-6.ozone_default",
+ "capacity": 125645656770,
+ "used": 4382720,
+ "remaining": 77110996992,
+ "committed": 0,
+ "minimumFreeSpace": 104857600,
+ "reserved": 12565822
+ },
+ {
+ "datanodeUuid": "953b91f4-c03a-45d8-9fbe-142887531cb1",
+ "hostName": "ozone-datanode-5.ozone_default",
+ "capacity": 125645656770,
+ "used": 4382720,
+ "remaining": 77110996992,
+ "committed": 0,
+ "minimumFreeSpace": 104857600,
+ "reserved": 12565822
+ },
+ {
+ "datanodeUuid": "44617070-a300-48af-a0e3-117167b008b6",
+ "hostName": "ozone-datanode-2.ozone_default",
+ "capacity": 125645656770,
+ "used": 4382720,
+ "remaining": 77110996992,
+ "committed": 0,
+ "minimumFreeSpace": 104857600,
+ "reserved": 12565822
+ },
+ {
+ "datanodeUuid": "d3ade292-6ec1-47b2-bd9e-045c1acc2c29",
+ "hostName": "ozone-datanode-1.ozone_default",
+ "capacity": 125645656770,
+ "used": 4382720,
+ "remaining": 77110996992,
+ "committed": 0,
+ "minimumFreeSpace": 104857600,
+ "reserved": 12565822
+ },
+ {
+ "datanodeUuid": "149a1640-e600-4e98-b62d-397804059f0e",
+ "hostName": "ozone-datanode-3.ozone_default",
+ "capacity": 125645656770,
+ "used": 4382720,
+ "remaining": 77110996992,
+ "committed": 0,
+ "minimumFreeSpace": 104857600,
+ "reserved": 12565822
+ },
+ {
+ "datanodeUuid": "17e2562a-dae8-416f-a749-7d4e8a0a781e",
+ "hostName": "ozone-datanode-4.ozone_default",
+ "capacity": 125645656770,
+ "used": 4382720,
+ "remaining": 77110996992,
+ "committed": 0,
+ "minimumFreeSpace": 104857600,
+ "reserved": 12565822
+ }
+ ]
+ },
+ "pendingDeletionDN": {
+ "status": "FINISHED",
+ "totalPendingDeletionSize": 12203,
+ "pendingDeletionPerDataNode": [
+ {
+ "hostName": "ozone-datanode-5.ozone_default",
+ "datanodeUuid": "953b91f4-c03a-45d8-9fbe-142887531cb1",
+ "pendingBlockSize": 1200
+ },
+ {
+ "hostName": "ozone-datanode-3.ozone_default",
+ "datanodeUuid": "149a1640-e600-4e98-b62d-397804059f0e",
+ "pendingBlockSize": 803
+ },
+ {
+ "hostName": "ozone-datanode-4.ozone_default",
+ "datanodeUuid": "17e2562a-dae8-416f-a749-7d4e8a0a781e",
+ "pendingBlockSize": -1
+ },
+ {
+ "hostName": "ozone-datanode-7.ozone_default",
+ "datanodeUuid": "1bf314dc-3eba-4774-9dbc-6d957cc7670b",
+ "pendingBlockSize": 2200
+ },
+ {
+ "hostName": "ozone-datanode-1.ozone_default",
+ "datanodeUuid": "d3ade292-6ec1-47b2-bd9e-045c1acc2c29",
+ "pendingBlockSize": -1
+ },
+ {
+ "hostName": "ozone-datanode-2.ozone_default",
+ "datanodeUuid": "44617070-a300-48af-a0e3-117167b008b6",
+ "pendingBlockSize": 300
+ },
+ {
+ "hostName": "ozone-datanode-6.ozone_default",
+ "datanodeUuid": "8adead67-85a6-4ba9-942f-cd313f1472f9",
+ "pendingBlockSize": 2730
+ }
+ ],
+ "totalNodesQueried": 7,
+ "totalNodeQueriesFailed": 2
+ },
+ "pendingDeletionOM": {
+ "totalSize": 240430,
+ "pendingDirectorySize": 120040,
+ "pendingKeySize": 120390
+ },
+ "pendingDeletionSCM": {
+ "totalBlocksize": 24030,
+ "totalReplicatedBlockSize": 120040,
+ "totalBlocksCount": 120390
}
}
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/routes.json
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/routes.json
index af586efb3fa..46622492e1c 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/routes.json
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/routes.json
@@ -53,5 +53,10 @@
"/keys/deletePending/dirs?limit=*": "/dirdeletePending",
"/datanodes/decommission/info": "/decommissioninfo",
"/datanodes/decommission/info/datanode?uuid=*": "/DatanodesDecommissionInfo",
- "/datanodes/remove": "/datanodesRemove"
+ "/datanodes/remove": "/datanodesRemove",
+
+ "/storageDistribution": "/utilization",
+ "/pendingDeletion?component=dn&limit=*": "/pendingDeletionDN",
+ "/pendingDeletion?component=om": "/pendingDeletionOM",
+ "/pendingDeletion?component=scm": "/pendingDeletionSCM"
}
\ No newline at end of file
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/capacity/Capacity.test.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/capacity/Capacity.test.tsx
new file mode 100644
index 00000000000..94109adb274
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/capacity/Capacity.test.tsx
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+
+import Capacity from '@/v2/pages/capacity/capacity';
+import { capacityServer } from '@tests/mocks/capacityMocks/capacityServer';
+
+vi.mock('@/components/autoReloadPanel/autoReloadPanel', () => ({
+ default: () => <div data-testid="auto-reload-panel" />,
+}));
+vi.mock('@/components/eChart/eChart', () => ({
+ EChart: () => <div data-testid="echart" />,
+}));
+
+describe('Capacity Page', () => {
+ beforeAll(() => capacityServer.listen());
+ afterEach(() => capacityServer.resetHandlers());
+ afterAll(() => capacityServer.close());
+
+ test('renders cluster and service breakdown with data', async () => {
+ render(<Capacity />);
+
+ expect(screen.getByText('Cluster Capacity')).toBeInTheDocument();
+ expect(screen.getByTestId('auto-reload-panel')).toBeInTheDocument();
+
+ const ozoneCapacityTitle = await screen.findByText('Ozone Capacity');
+ const ozoneCapacityCard = ozoneCapacityTitle.closest('.ant-card');
+ expect(ozoneCapacityCard).not.toBeNull();
+ if (!ozoneCapacityCard) {
+ return;
+ }
+ await waitFor(() =>
+ expect(ozoneCapacityCard).toHaveTextContent(/TOTAL\s*10\s*KB/i)
+ );
+ expect(ozoneCapacityCard).toHaveTextContent(/OZONE USED SPACE\s*4\s*KB/i);
+ expect(ozoneCapacityCard).toHaveTextContent(/OTHER USED SPACE\s*2\s*KB/i);
+ expect(ozoneCapacityCard).toHaveTextContent(/CONTAINER
PRE-ALLOCATED\s*1\s*KB/i);
+ expect(ozoneCapacityCard).toHaveTextContent(/REMAINING SPACE\s*4\s*KB/i);
+
+ const ozoneUsedSpaceTitle = screen.getByText('Ozone Used Space');
+ const ozoneUsedSpaceCard = ozoneUsedSpaceTitle.closest('.ant-card');
+ expect(ozoneUsedSpaceCard).not.toBeNull();
+ if (!ozoneUsedSpaceCard) {
+ return;
+ }
+ await waitFor(() =>
+ expect(ozoneUsedSpaceCard).toHaveTextContent(/PENDING
DELETION\s*6\s*KB/i)
+ );
+ });
+
+ test('shows pending deletion and datanode detail values', async () => {
+ render(<Capacity />);
+
+ const pendingDeletionTitle = await screen.findByText('Pending Deletion');
+ const pendingDeletionCard = pendingDeletionTitle.closest('.ant-card');
+ expect(pendingDeletionCard).not.toBeNull();
+ if (!pendingDeletionCard) {
+ return;
+ }
+ await waitFor(() =>
+ expect(pendingDeletionCard).toHaveTextContent(/OZONE MANAGER\s*2\s*KB/i)
+ );
+ expect(pendingDeletionCard)
+ .toHaveTextContent(/STORAGE CONTAINER MANAGER\s*1\s*KB/i);
+ expect(pendingDeletionCard).toHaveTextContent(/DATANODES\s*3\s*KB/i);
+
+ const downloadLink = await screen.findByText('Download Insights');
+ const datanodeCard = downloadLink.closest('.ant-card');
+ expect(datanodeCard).not.toBeNull();
+ if (!datanodeCard) {
+ return;
+ }
+ await waitFor(() =>
+ expect(datanodeCard).toHaveTextContent(/USED SPACE\s*5\s*KB/i)
+ );
+ expect(datanodeCard).toHaveTextContent(/FREE SPACE\s*3\s*KB/i);
+ });
+});
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/capacityMocks/capacityResponseMocks.ts
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/capacityMocks/capacityResponseMocks.ts
new file mode 100644
index 00000000000..970521e962d
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/capacityMocks/capacityResponseMocks.ts
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+export const StorageDistribution = {
+ globalStorage: {
+ totalUsedSpace: 4096,
+ totalFreeSpace: 4096,
+ totalCapacity: 10240
+ },
+ globalNamespace: {
+ totalUsedSpace: 4096,
+ totalKeys: 12
+ },
+ usedSpaceBreakdown: {
+ openKeyBytes: 1024,
+ committedKeyBytes: 2048,
+ preAllocatedContainerBytes: 1024
+ },
+ dataNodeUsage: [
+ {
+ datanodeUuid: 'uuid-1',
+ hostName: 'dn-1',
+ capacity: 8192,
+ used: 4096,
+ remaining: 2048,
+ committed: 1024,
+ minimumFreeSpace: 512,
+ reserved: 128
+ },
+ {
+ datanodeUuid: 'uuid-2',
+ hostName: 'dn-2',
+ capacity: 8192,
+ used: 2048,
+ remaining: 2048,
+ committed: 1024,
+ minimumFreeSpace: 256,
+ reserved: 128
+ }
+ ]
+};
+
+export const ScmPendingDeletion = {
+ totalBlocksize: 1024,
+ totalReplicatedBlockSize: 2048,
+ totalBlocksCount: 2
+};
+
+export const OmPendingDeletion = {
+ totalSize: 2048,
+ pendingDirectorySize: 1024,
+ pendingKeySize: 1024
+};
+
+export const DnPendingDeletion = {
+ status: "FINISHED",
+ totalPendingDeletionSize: 3072,
+ pendingDeletionPerDataNode: [
+ {
+ hostName: 'dn-1',
+ datanodeUuid: 'uuid-1',
+ pendingBlockSize: 1024
+ },
+ {
+ hostName: 'dn-2',
+ datanodeUuid: 'uuid-2',
+ pendingBlockSize: 2048
+ }
+ ],
+ totalNodesQueried: 2,
+ totalNodeQueriesFailed: 0
+};
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/capacityMocks/capacityServer.ts
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/capacityMocks/capacityServer.ts
new file mode 100644
index 00000000000..6c98b29a508
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/capacityMocks/capacityServer.ts
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import { setupServer } from 'msw/node';
+import { rest } from 'msw';
+
+import * as mockResponses from './capacityResponseMocks';
+
+const handlers = [
+ rest.get('api/v1/storageDistribution', (req, res, ctx) => {
+ return res(
+ ctx.status(200),
+ ctx.json(mockResponses.StorageDistribution)
+ );
+ }),
+ rest.get('api/v1/pendingDeletion', (req, res, ctx) => {
+ const component = req.url.searchParams.get('component');
+ switch (component) {
+ case 'scm':
+ return res(
+ ctx.status(200),
+ ctx.json(mockResponses.ScmPendingDeletion)
+ );
+ case 'om':
+ return res(
+ ctx.status(200),
+ ctx.json(mockResponses.OmPendingDeletion)
+ );
+ case 'dn':
+ return res(
+ ctx.status(200),
+ ctx.json(mockResponses.DnPendingDeletion)
+ );
+ default:
+ return res(
+ ctx.status(400),
+ ctx.json({ message: 'Unsupported pending deletion component.' })
+ );
+ }
+ })
+];
+
+//This will configure a request mocking server using MSW
+export const capacityServer = setupServer(...handlers);
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/themeIcons.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/themeIcons.tsx
index 906a528cd28..d5cc414994a 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/themeIcons.tsx
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/themeIcons.tsx
@@ -98,3 +98,30 @@ export class ReplicationIcon extends
React.PureComponent<IReplicationIconProps>
return icon;
}
}
+
+interface IGraphLegendIconProps {
+ color: string;
+ height?: number;
+};
+export class GraphLegendIcon extends
React.PureComponent<IGraphLegendIconProps> {
+ render() {
+ const { color, height = 14 } = this.props;
+
+ return (
+ <svg
+ width="18"
+ height={height}
+ viewBox={`0 0 18 ${height}`}
+ xmlns="http://www.w3.org/2000/svg"
+ style={{ display: 'inline-block', verticalAlign: 'middle' }} //
Optional: helps with alignment
+ >
+ <circle
+ cx="6"
+ cy="6"
+ r="6"
+ fill={color} // Use the color prop for the fill
+ />
+ </svg>
+ )
+ }
+};
\ No newline at end of file
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewCardWrapper.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewCardWrapper.tsx
similarity index 100%
rename from
hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewCardWrapper.tsx
rename to
hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewCardWrapper.tsx
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSimpleCard.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSimpleCard.tsx
similarity index 100%
rename from
hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSimpleCard.tsx
rename to
hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSimpleCard.tsx
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewStorageCard.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewStorageCard.tsx
new file mode 100644
index 00000000000..ace92d4f9d9
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewStorageCard.tsx
@@ -0,0 +1,312 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import React, { HTMLAttributes, useMemo, useState } from 'react';
+import filesize from 'filesize';
+import { Card, Row, Col, Table, Tag, Modal } from 'antd';
+
+import EChart from '@/v2/components/eChart/eChart';
+
+import { StorageReport } from '@/v2/types/overview.types';
+import { InfoCircleFilled } from '@ant-design/icons';
+import { Link } from 'react-router-dom';
+import ErrorCard from '@/v2/components/errors/errorCard';
+
+// ------------- Types -------------- //
+type OverviewStorageCardProps = {
+ loading?: boolean;
+ storageReport: StorageReport;
+ error?: string | null;
+}
+
+const size = filesize.partial({ round: 1 });
+
+function getUsagePercentages(
+ { used, remaining, capacity, committed }: StorageReport): ({
+ ozoneUsedPercentage: number,
+ nonOzoneUsedPercentage: number,
+ committedPercentage: number,
+ usagePercentage: number
+ }) {
+ return {
+ ozoneUsedPercentage: Math.floor(used / capacity * 100),
+ nonOzoneUsedPercentage: Math.floor((capacity - remaining - used) /
capacity * 100),
+ committedPercentage: Math.floor(committed / capacity * 100),
+ usagePercentage: Math.round((capacity - remaining) / capacity * 100)
+ }
+}
+
+// ------------- Styles -------------- //
+const cardHeadStyle: React.CSSProperties = { fontSize: '14px' };
+const cardBodyStyle: React.CSSProperties = { padding: '16px' };
+const cardStyle: React.CSSProperties = {
+ boxSizing: 'border-box',
+ height: '100%'
+}
+const cardErrorStyle: React.CSSProperties = {
+ borderColor: '#FF4D4E',
+ borderWidth: '1.4px'
+}
+const eChartStyle: React.CSSProperties = {
+ width: '280px',
+ height: '200px'
+}
+
+
+// ------------- Component -------------- //
+const OverviewStorageCard: React.FC<OverviewStorageCardProps> = ({
+ loading = false,
+ storageReport = {
+ capacity: 0,
+ used: 0,
+ remaining: 0,
+ committed: 0
+ },
+ error
+}) => {
+
+ if (error) {
+ return <ErrorCard title='Cluster Capacity' />
+ }
+
+ const [isInfoOpen, setInfoOpen] = useState<boolean>(false);
+
+ const {
+ ozoneUsedPercentage,
+ nonOzoneUsedPercentage,
+ committedPercentage,
+ usagePercentage
+ } = useMemo(() =>
+ getUsagePercentages(storageReport),
+ [
+ storageReport.capacity,
+ storageReport.committed,
+ storageReport.remaining,
+ storageReport.used,
+ ]
+ )
+
+ let capacityData = [{
+ value: ozoneUsedPercentage,
+ itemStyle: {
+ color: '#52C41A'
+ }
+ }, {
+ value: nonOzoneUsedPercentage,
+ itemStyle: {
+ color: '#1890FF'
+ }
+ }, {
+ value: committedPercentage,
+ itemStyle: {
+ color: '#FF595E'
+ }
+ }]
+ // Remove all zero values
+ // because guage chart shows a dot if value is zero
+ capacityData = capacityData.filter((val) => val.value > 0)
+
+ const eChartOptions = {
+ title: {
+ left: 'center',
+ bottom: 'bottom',
+ text: `${size(storageReport.capacity - storageReport.remaining)} /
${size(storageReport.capacity)}`,
+ textStyle: {
+ fontWeight: 'normal',
+ fontFamily: 'Roboto'
+ }
+ },
+ series: [
+ {
+ type: 'gauge',
+ startAngle: 90,
+ endAngle: -270,
+ radius: '70%',
+ center: ['50%', '45%'],
+ bottom: '50%',
+ pointer: {
+ show: false
+ },
+ progress: {
+ show: true,
+ overlap: true,
+ roundCap: true,
+ clip: true
+ },
+ splitLine: {
+ show: false
+ },
+ axisTick: {
+ show: false
+ },
+ axisLabel: {
+ show: false,
+ distance: 50
+ },
+ detail: {
+ rich: {
+ value: {
+ fontSize: 24,
+ fontWeight: 400,
+ fontFamily: 'Roboto',
+ color: '#1B232A'
+ },
+ percent: {
+ fontSize: 20,
+ fontWeight: 400,
+ color: '#1B232A'
+ }
+ },
+ formatter: `{value|${usagePercentage}}{percent|%}`,
+ offsetCenter: [0, 0]
+ },
+ data: capacityData
+ }
+ ]
+ }
+
+ const showInfo = () => {
+ setInfoOpen(true);
+ }
+
+ const closeInfo = () => {
+ setInfoOpen(false);
+ }
+
+ const titleElement = (
+ <div className='card-title-div'>
+ <div>
+ <InfoCircleFilled
+ onClick={showInfo}
+ style={{ paddingRight: '12px', color: '#1da57a' }} />
+ Cluster Capacity
+ </div>
+ <Link
+ to={{ pathname: '/NamespaceUsage' }}
+ style={{
+ fontWeight: 400
+ }}> View Usage </Link>
+ </div>
+ )
+
+ const tableData = [
+ {
+ key: 'ozone-used',
+ usage: <Tag key='ozone-used' color='green'>Ozone Used</Tag>,
+ size: size(storageReport.used),
+ desc: 'Size of Data used by Ozone for storing actual files in the
Datanodes'
+ },
+ {
+ key: 'non-ozone-used',
+ usage: <Tag key='non-ozone-used' color='blue'>Non Ozone Used</Tag>,
+ size: size(storageReport.capacity - storageReport.remaining -
storageReport.used),
+ desc: 'Size of data used by Ozone for other files like logs, DB data
etc.'
+ },
+ {
+ key: 'remaining',
+ usage: <Tag key='remaining' color='#E6EBF8'>
+ <span style={{ color: '#4c7cf5' }}>Remaining</span>
+ </Tag>,
+ size: size(storageReport.remaining),
+ desc: 'Space which is free after considering replication and Non-Ozone
used space'
+ },
+ {
+ key: 'pre-allocated',
+ usage: <Tag key='pre-allocated' color='red'>Container
Pre-allocated</Tag>,
+ size: size(storageReport.committed),
+ desc: 'Space which is pre-allocated for containers'
+ }
+ ];
+
+ return (
+ <>
+ <Modal
+ title='Cluster Capacity Info'
+ visible={isInfoOpen}
+ onOk={closeInfo}
+ onCancel={closeInfo}
+ footer={null}
+ centered={true}
+ width={700}
+ data-testid='capacity-info-modal'>
+ <p>Cluster capacity fetches the data from Datanode reports that Recon
receives.</p>
+ <p>
+ The displayed sizes <strong>include</strong> the replicated data
size.<br />
+ Ex: A <strong>1KB key will display 3KB</strong> in <strong>RATIS
(THREE)</strong> replication
+ </p>
+ <Table
+ size='small'
+ pagination={false}
+ columns={[{
+ title: 'Label',
+ dataIndex: 'usage',
+ key: 'label'
+ }, {
+ title: 'Description',
+ dataIndex: 'desc',
+ key: 'desc',
+ align: 'left'
+ }]}
+ dataSource={tableData} />
+ </Modal>
+ <Card
+ size='small'
+ className={'overview-card'}
+ loading={loading}
+ hoverable={false}
+ title={titleElement}
+ headStyle={cardHeadStyle}
+ bodyStyle={cardBodyStyle}
+ style={(usagePercentage > 79) ? { ...cardStyle, ...cardErrorStyle } :
cardStyle} >
+ <Row justify='space-between'>
+ <Col
+ className='echart-col'
+ xs={24} sm={24} md={12} lg={12} xl={12}>
+ <EChart
+ option={eChartOptions}
+ style={eChartStyle} />
+ </Col>
+ <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+ <Table
+ size='small'
+ pagination={false}
+ columns={[
+ {
+ title: 'Usage',
+ dataIndex: 'usage',
+ key: 'usage'
+ },
+ {
+ title: 'Size',
+ dataIndex: 'size',
+ key: 'size',
+ align: 'right'
+ },
+ ]}
+ dataSource={tableData}
+ onRow={(record) => ({
+ 'data-testid': `capacity-${record.key}`
+ }) as HTMLAttributes<HTMLElement>} />
+ </Col>
+ </Row>
+ </Card>
+ </>
+ )
+}
+
+export default OverviewStorageCard;
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSummaryCard.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSummaryCard.tsx
similarity index 96%
rename from
hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSummaryCard.tsx
rename to
hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSummaryCard.tsx
index 9214c456b6c..bf6ed392263 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSummaryCard.tsx
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSummaryCard.tsx
@@ -21,7 +21,6 @@ import { Card, Row, Table } from 'antd';
import { ColumnType } from 'antd/es/table';
import { Link } from 'react-router-dom';
-import ErrorMessage from '@/v2/components/errors/errorCard';
import ErrorCard from '@/v2/components/errors/errorCard';
// ------------- Types -------------- //
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/navBar/navBar.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/navBar/navBar.tsx
index 3cc6b2aca91..f0ec7bc8195 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/navBar/navBar.tsx
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/navBar/navBar.tsx
@@ -120,6 +120,12 @@ const NavBar: React.FC<NavBarProps> = ({
<Link to='/NamespaceUsage' />
</Menu.Item>
), (
+ <Menu.Item key='/Capacity'
+ icon={<PieChartOutlined />}>
+ <span>Cluster Capacity</span>
+ <Link to='/Capacity' />
+ </Menu.Item>
+ ),(
isHeatmapEnabled &&
<Menu.Item key='/Heatmap'
icon={<LayoutOutlined />}>
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/capacity.constants.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/capacity.constants.tsx
new file mode 100644
index 00000000000..1699d7ffeb4
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/capacity.constants.tsx
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import { UtilizationResponse, SCMPendingDeletion, OMPendingDeletion,
DNPendingDeletion } from "@/v2/types/capacity.types";
+
+export const DEFAULT_CAPACITY_UTILIZATION: UtilizationResponse = {
+ globalStorage: {
+ totalUsedSpace: 0,
+ totalFreeSpace: 0,
+ totalCapacity: 0
+ },
+ globalNamespace: {
+ totalUsedSpace: 0,
+ totalKeys: 0
+ },
+ usedSpaceBreakdown: {
+ openKeyBytes: 0,
+ committedKeyBytes: 0,
+ preAllocatedContainerBytes: 0
+ },
+ dataNodeUsage: []
+};
+
+export const DEFAULT_SCM_PENDING_DELETION: SCMPendingDeletion = {
+ totalBlocksize: 0,
+ totalReplicatedBlockSize: 0,
+ totalBlocksCount: 0
+};
+
+export const DEFAULT_OM_PENDING_DELETION: OMPendingDeletion = {
+ totalSize: 0,
+ pendingDirectorySize: 0,
+ pendingKeySize: 0
+};
+
+export const DEFAULT_DN_PENDING_DELETION: DNPendingDeletion = {
+ status: "NOT_STARTED",
+ totalPendingDeletionSize: 0,
+ pendingDeletionPerDataNode: [{
+ hostName: 'unknown-host',
+ datanodeUuid: 'unknown-uuid',
+ pendingBlockSize: 0
+ }],
+ totalNodesQueried: 0,
+ totalNodeQueriesFailed: 0
+};
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useAutoReload.hook.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useAutoReload.hook.tsx
index baa8190bfc9..c18b478798c 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useAutoReload.hook.tsx
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useAutoReload.hook.tsx
@@ -27,6 +27,7 @@ export function useAutoReload(
const [isPolling, setIsPolling] = useState<boolean>(false);
const refreshFunctionRef = useRef(refreshFunction);
const lastPollCallRef = useRef<number>(0); // This is used to store the last
time poll was called
+ const [intervalMs, setIntervalMs] = useState<number>(interval);
// Update the ref when the function changes
refreshFunctionRef.current = refreshFunction;
@@ -39,8 +40,10 @@ export function useAutoReload(
}
};
- const startPolling = () => {
+ const startPolling = (customInterval?: number) => {
stopPolling();
+ const effectiveInterval = customInterval ?? intervalMs;
+ setIntervalMs(effectiveInterval);
const poll = () => {
/**
* Prevent any extra polling calls within 100ms of the last call,
@@ -53,7 +56,7 @@ export function useAutoReload(
refreshFunctionRef.current();
lastPollCallRef.current = Date.now();
}
- intervalRef.current = window.setTimeout(poll, interval);
+ intervalRef.current = window.setTimeout(poll, effectiveInterval);
};
poll();
setIsPolling(true);
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.less
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.less
new file mode 100644
index 00000000000..e8fa61cf363
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.less
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+.data-container {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ height: 100%;
+
+ .section-title {
+ flex-grow: 0;
+ font-family: Roboto;
+ font-size: 20px;
+ font-weight: 500;
+ font-stretch: normal;
+ font-style: normal;
+ line-height: 1.4;
+ letter-spacing: normal;
+ text-align: left;
+ color: #1b2329;
+ }
+
+ .node-select-container {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin: 16px 0px auto 0px;
+ height: 11em;
+ }
+
+ .cluster-card-data-container {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+
+ &.vertical-layout {
+ flex-direction: column;
+ gap: 0px;
+ }
+
+ .cluster-card-statistic {
+ flex: 2 1 10em;
+ }
+
+ .data-detail-item {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: stretch;
+ border-radius: 3px;
+
+ .data-detail-breakdown-container {
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ .data-detail-breakdown-item {
+ margin-left: 20px;
+ .data-detail-breakdown-label {
+ font-size: 12px;
+ color: #5a656d;
+ margin-right: 4px;
+ }
+
+ .data-detail-breakdown-value {
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ }
+ }
+ }
+
+ .data-detail-breakdown-container {
+ .ant-statistic-title {
+ margin-bottom: 0;
+ }
+
+ .ant-statistic-content {
+ .ant-statistic-content-prefix {
+ margin-right: 1px;
+ }
+ .ant-statistic-content-value {
+ font-size: 16px;
+ }
+ }
+ }
+ }
+
+ .stacked-progress {
+ display: flex;
+ width: 100%;
+ height: 8px;
+ border-radius: 100px;
+ overflow: hidden;
+ margin: 24px auto 8px auto;
+ }
+ .stacked-progress-empty {
+ width: 100%;
+ height: 8px;
+ border-radius: 100px;
+ background-color: #f4f5f6;
+ margin: 24px auto 8px auto;
+ }
+}
+
+.data-breakdown-section {
+ display: flex;
+ gap: 16px;
+ width: 100%;
+ justify-content: space-between;
+ align-items: stretch;
+
+ > .ant-card {
+ flex: 1 1 0;
+ min-width: 0;
+ }
+}
+
+.unused-space-breakdown {
+ display: grid;
+ grid-template-columns: 150px auto;
+ grid-column-gap: 20px;
+ grid-row-gap: 4px;
+
+ .ant-tag {
+ text-align: center;
+ }
+}
+
+.ant-statistic-title {
+ font-size: 12px;
+}
+
+// This is for the suffix part of the value ex: TB, GB etc
+.ant-statistic-content-suffix {
+ font-family: Roboto;
+ font-size: 14px;
+ font-weight: normal;
+ font-stretch: normal;
+ font-style: normal;
+ line-height: 1.43;
+ letter-spacing: normal;
+ text-align: left;
+ vertical-align: text-top;
+ color: rgba(0, 0, 0, 0.85);
+ margin: 3px 0 0 1px;
+}
+
+.ant-divider-horizontal {
+ margin: 16px 0;
+}
+
+.ant-card-body {
+ // This is to enforce 16px padding for card body which is 12px by default
+ padding: 16px !important;
+}
+
+.dn-select-option-uuid {
+ font-size: 14px;
+ color: #5a656d;
+ margin-left: 15px;
+}
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.tsx
new file mode 100644
index 00000000000..ec9ad436e59
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.tsx
@@ -0,0 +1,437 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import { Popover, Tag, Typography } from 'antd';
+import React from 'react';
+import AutoReloadPanel from '@/components/autoReloadPanel/autoReloadPanel';
+
+import './capacity.less';
+import { showDataFetchError } from '@/utils/common'
+import moment from 'moment';
+import CapacityBreakdown from
'@/v2/pages/capacity/components/CapacityBreakdown';
+import CapacityDetail from '@/v2/pages/capacity/components/CapacityDetail';
+import {
+ datanodesPendingDeletionDesc,
+ nodeSelectorMessage,
+ otherUsedSpaceDesc,
+ ozoneUsedSpaceDesc,
+ totalCapacityDesc
+} from '@/v2/pages/capacity/constants/descriptions.constants';
+import WrappedInfoIcon from '@/v2/pages/capacity/components/WrappedInfoIcon';
+import filesize from 'filesize';
+import { InfoCircleOutlined, WarningFilled, CheckCircleFilled } from
'@ant-design/icons';
+import { useApiData } from '@/v2/hooks/useAPIData.hook';
+import * as CONSTANTS from '@/v2/constants/capacity.constants';
+import { UtilizationResponse, SCMPendingDeletion, OMPendingDeletion,
DNPendingDeletion, DataNodeUsage } from '@/v2/types/capacity.types';
+import { useAutoReload } from '@/v2/hooks/useAutoReload.hook';
+
+type CapacityState = {
+ isDNPending: boolean;
+ lastUpdated: number;
+};
+
+const Capacity: React.FC<object> = () => {
+ const PENDING_POLL_INTERVAL = 5 * 1000;
+ const DN_CSV_DOWNLOAD_URL = '/api/v1/pendingDeletion/download';
+ const DN_STATUS_URL = '/api/v1/pendingDeletion?component=dn';
+ const DOWNLOAD_POLL_TIMEOUT_MS = 10 * 60 * 1000;
+
+ const [state, setState] = React.useState<CapacityState>({
+ isDNPending: true,
+ lastUpdated: 0
+ });
+
+ const storageDistribution = useApiData<UtilizationResponse>(
+ '/api/v1/storageDistribution',
+ CONSTANTS.DEFAULT_CAPACITY_UTILIZATION,
+ {
+ retryAttempts: 2,
+ onError: (error) => showDataFetchError(error)
+ }
+ );
+
+ const scmPendingDeletes = useApiData<SCMPendingDeletion>(
+ '/api/v1/pendingDeletion?component=scm',
+ CONSTANTS.DEFAULT_SCM_PENDING_DELETION,
+ {
+ retryAttempts: 2,
+ onError: (error) => showDataFetchError(error)
+ }
+ );
+
+ const omPendingDeletes = useApiData<OMPendingDeletion>(
+ '/api/v1/pendingDeletion?component=om',
+ CONSTANTS.DEFAULT_OM_PENDING_DELETION,
+ {
+ retryAttempts: 2,
+ onError: (error) => showDataFetchError(error)
+ }
+ );
+
+ const dnPendingDeletes = useApiData<DNPendingDeletion>(
+ '/api/v1/pendingDeletion?component=dn&limit=15',
+ CONSTANTS.DEFAULT_DN_PENDING_DELETION,
+ {
+ retryAttempts: 2,
+ initialFetch: false,
+ onError: (error) => showDataFetchError(error)
+ }
+ );
+
+ const [selectedDatanode, setSelectedDatanode] =
React.useState<string>(storageDistribution.data.dataNodeUsage[0]?.hostName ??
"");
+
+ // Seed selected datanode once data loads so dependent calculations work
+ React.useEffect(() => {
+ const firstHost = storageDistribution.data.dataNodeUsage[0]?.hostName;
+ if (!selectedDatanode && firstHost) {
+ setSelectedDatanode(firstHost);
+ }
+ }, [selectedDatanode, storageDistribution.data.dataNodeUsage]);
+
+ const loadDNData = () => {
+ dnPendingDeletes.refetch();
+ setState({
+ isDNPending: dnPendingDeletes.data.status !== "FINISHED",
+ lastUpdated: Number(moment())
+ })
+ }
+
+ const autoReload = useAutoReload(loadDNData, PENDING_POLL_INTERVAL);
+
+ const selectedDNDetails: DataNodeUsage & { pendingBlockSize: number } =
React.useMemo(() => {
+ const selected = storageDistribution.data.dataNodeUsage.find(datanode =>
datanode.hostName === selectedDatanode)
+ ?? storageDistribution.data.dataNodeUsage[0];
+ return {
+ ...(selected ?? {
+ datanodeUuid: "unknown-uuid",
+ hostName: "unknown-host",
+ capacity: 0,
+ used: 0,
+ remaining: 0,
+ committed: 0,
+ minimumFreeSpace: 0,
+ reserved: 0
+ }),
+ ...dnPendingDeletes.data.pendingDeletionPerDataNode?.find(dn =>
dn.hostName === (selected?.hostName ?? selectedDatanode)) ?? {
+ hostName: "unknown-host",
+ datanodeUuid: "unknown-uuid",
+ pendingBlockSize: 0
+ }
+ }
+ }, [selectedDatanode, storageDistribution.data.dataNodeUsage,
dnPendingDeletes.data.pendingDeletionPerDataNode]);
+
+ const waitForDnFinished = async () => {
+ const startTime = Date.now();
+ while (Date.now() - startTime < DOWNLOAD_POLL_TIMEOUT_MS) {
+ const response = await fetch(DN_STATUS_URL);
+ if (!response.ok) {
+ throw new Error(`Status check failed: ${response.statusText}`);
+ }
+ const data = await response.json() as DNPendingDeletion;
+ if (data.status === "FINISHED") {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve,
PENDING_POLL_INTERVAL));
+ }
+ throw new Error('CSV download not ready. Please try again later.');
+ };
+
+ const downloadCsv = async (url: string) => {
+ try {
+ await waitForDnFinished();
+ const response = await fetch(url);
+ if (!response.ok) {
+ showDataFetchError(`CSV download failed: ${response.statusText}`);
+ return;
+ }
+ const contentType = response.headers.get('content-type') ?? '';
+ if (!contentType.includes('text/csv')) {
+ showDataFetchError('CSV download not ready. Please try again later.');
+ return;
+ }
+ const contentDisposition = response.headers.get('content-disposition');
+ const filenameMatch = contentDisposition?.match(/filename="([^"]+)"/);
+ if (!filenameMatch) {
+ showDataFetchError('CSV download not ready. Please try again later.');
+ return;
+ }
+ const blob = await response.blob();
+ const filename = filenameMatch?.[1] ?? 'pending_deletion_stats.csv';
+ const link = document.createElement('a');
+ link.href = window.URL.createObjectURL(blob);
+ link.download = filename;
+ link.click();
+ window.URL.revokeObjectURL(link.href);
+ } catch (error) {
+ showDataFetchError((error as Error).message);
+ }
+ };
+
+ // Poll every 5s until status is FINISHED, then stop
+ React.useEffect(() => {
+ if (dnPendingDeletes.data.status !== "FINISHED") {
+ if (!autoReload.isPolling) {
+ autoReload.startPolling(PENDING_POLL_INTERVAL);
+ }
+ return;
+ }
+
+ if (autoReload.isPolling) {
+ autoReload.stopPolling();
+ }
+ }, [
+ dnPendingDeletes.data.status,
+ autoReload.isPolling,
+ autoReload.startPolling,
+ autoReload.stopPolling
+ ]);
+
+ const dnReportStatus = (
+ (dnPendingDeletes.data.totalNodeQueriesFailed ?? 0) > 0
+ ? <Popover content={<>
+ { (dnPendingDeletes.data.totalNodesQueried ?? 0)
+ - (dnPendingDeletes.data.totalNodeQueriesFailed ?? 0)
+ } / { (dnPendingDeletes.data.totalNodesQueried ?? 0) } DNs
+ </>
+ }>
+ <WarningFilled style={{ color: '#f6a62eff', marginRight: 8, fontSize: 14
}} />
+ Datanodes
+ </Popover>
+ : <Popover content={
+ <>
+ {dnPendingDeletes.data.totalNodesQueried ?? 0} /
{dnPendingDeletes.data.totalNodesQueried ?? 0} DNs
+ </>
+ }>
+ <CheckCircleFilled style={{ color: '#1ea57a', marginRight: 8, fontSize:
14 }} />
+ Datanodes
+ </Popover>
+ );
+
+ const unusedSpaceBreakdown = (
+ <span>
+ UNUSED
+ <Popover
+ title="Unused Space Breakdown"
+ placement='topLeft'
+ content={
+ <div className='unused-space-breakdown'>
+ Minimum Free Space
+ <Tag color='red'>{filesize(selectedDNDetails.minimumFreeSpace,
{round: 1})}</Tag>
+ Remaining
+ <Tag color='green'>{filesize(selectedDNDetails.remaining, { round:
1})}</Tag>
+ </div>
+ }
+ >
+ <InfoCircleOutlined style={{ color: '#2f84d8', fontSize: 12,
marginLeft: 4 }} />
+ </Popover>
+ </span>
+ );
+
+ const dnSelectorTitle = (
+ <span>
+ Node Selector <WrappedInfoIcon title={nodeSelectorMessage} />
+ </span>
+ );
+
+ return (
+ <>
+ <div className='page-header-v2'>
+ Cluster Capacity
+ <AutoReloadPanel
+ isLoading={dnPendingDeletes.loading}
+ lastRefreshed={state.lastUpdated}
+ togglePolling={autoReload.handleAutoReloadToggle}
+ onReload={loadDNData} />
+ </div>
+ <div className='data-container'>
+ <Typography.Title level={4}
className='section-title'>Cluster</Typography.Title>
+ <CapacityBreakdown
+ title='Ozone Capacity'
+ loading={storageDistribution.loading}
+ items={[{
+ title: (
+ <span>
+ TOTAL
+ <WrappedInfoIcon title={totalCapacityDesc} />
+ </span>
+ ),
+ value: storageDistribution.data.globalStorage.totalCapacity,
+ }, {
+ title: 'OZONE USED SPACE',
+ value: storageDistribution.data.globalStorage.totalUsedSpace,
+ color: '#f4a233'
+ }, {
+ title: (
+ <span>
+ OTHER USED SPACE
+ <WrappedInfoIcon title={otherUsedSpaceDesc} />
+ </span>
+ ),
+ value: (
+ storageDistribution.data.globalStorage.totalCapacity
+ - storageDistribution.data.globalStorage.totalFreeSpace
+ - storageDistribution.data.globalStorage.totalUsedSpace
+ ),
+ color: '#11073a'
+ }, {
+ title: 'CONTAINER PRE-ALLOCATED',
+ value:
storageDistribution.data.usedSpaceBreakdown.preAllocatedContainerBytes,
+ color: '#f47b2d'
+ }, {
+ title: 'REMAINING SPACE',
+ value: storageDistribution.data.globalStorage.totalFreeSpace,
+ color: '#4553ee'
+ }]}
+ />
+ <Typography.Title level={4}
className='section-title'>Service</Typography.Title>
+ <CapacityBreakdown
+ title={(
+ <span>
+ Ozone Used Space
+ <WrappedInfoIcon title={ozoneUsedSpaceDesc} />
+ </span>
+ )}
+ loading={storageDistribution.loading}
+ items={[{
+ title: 'TOTAL',
+ value: storageDistribution.data.globalStorage.totalUsedSpace
+ }, {
+ title: 'OPEN KEYS',
+ value: storageDistribution.data.usedSpaceBreakdown.openKeyBytes,
+ color: '#f47c2d'
+ }, {
+ title: 'COMMITTED KEYS',
+ value:
storageDistribution.data.usedSpaceBreakdown.committedKeyBytes,
+ color: '#f4a233'
+ }, {
+ title: (
+ dnPendingDeletes.data.status !== "FINISHED" ||
dnPendingDeletes.loading
+ ? (
+ <span>
+ PENDING DELETION
+ <WrappedInfoIcon title="DN pending deletion data is not yet
available. It will be fetched once the pending deletion scan is finished on all
datanodes." />
+ </span>
+ )
+ : 'PENDING DELETION'
+ ),
+ value: (
+ omPendingDeletes.data.totalSize
+ + scmPendingDeletes.data.totalBlocksize
+ + (dnPendingDeletes.data.totalPendingDeletionSize ?? 0)
+ ),
+ color: "#10073b"
+ }]}
+ />
+ <div className='data-breakdown-section'>
+ <CapacityDetail
+ title='Pending Deletion'
+ loading={omPendingDeletes.loading || scmPendingDeletes.loading}
+ showDropdown={false}
+ dataDetails={[{
+ title: 'OZONE MANAGER',
+ size: omPendingDeletes.data.totalSize ?? 0,
+ breakdown: [{
+ label: 'KEYS',
+ value: omPendingDeletes.data.pendingKeySize,
+ color: '#f4a233'
+ }, {
+ label: 'DIRECTORIES',
+ value: omPendingDeletes.data.pendingDirectorySize,
+ color: '#10073b'
+ }]
+ }, {
+ title: 'STORAGE CONTAINER MANAGER',
+ size: scmPendingDeletes.data.totalBlocksize,
+ breakdown: [{
+ label: 'BLOCKS',
+ value: scmPendingDeletes.data.totalBlocksize,
+ color: '#f4a233'
+ }]
+ }, {
+ title: (
+ <span>
+ DATANODES
+ <WrappedInfoIcon title={datanodesPendingDeletionDesc} />
+ </span>
+ ),
+ loading: dnPendingDeletes.loading ||
dnPendingDeletes.data.status !== "FINISHED",
+ size: dnPendingDeletes.data.totalPendingDeletionSize ?? 0,
+ breakdown: [{
+ label: 'BLOCKS',
+ value: dnPendingDeletes.data.totalPendingDeletionSize ?? 0,
+ color: '#f4a233'
+ }]
+ }]} />
+ <CapacityDetail
+ title={dnReportStatus}
+ loading={dnPendingDeletes.loading || dnPendingDeletes.data.status
!== "FINISHED"}
+ showDropdown={true}
+ selectorTitle={dnSelectorTitle}
+ downloadUrl={DN_CSV_DOWNLOAD_URL}
+ onDownloadClick={() => downloadCsv(DN_CSV_DOWNLOAD_URL)}
+ handleSelect={setSelectedDatanode}
+ dropdownItems={storageDistribution.data.dataNodeUsage.map(datanode
=> ({
+ label: (
+ <>
+ <span>{datanode.hostName}</span>
+ <span
className="dn-select-option-uuid">{datanode.datanodeUuid}</span>
+ </>
+ ),
+ value: datanode.hostName,
+ key: datanode.datanodeUuid
+ }))}
+ disabledOpts={
+ (dnPendingDeletes.data.pendingDeletionPerDataNode ?? [])
+ .filter(dn => dn.pendingBlockSize === -1)
+ .map(dn => dn.hostName)
+ }
+ optsClass={'dn-select-option'}
+ dataDetails={[{
+ title: 'USED SPACE',
+ size: (selectedDNDetails.used ?? 0) +
(selectedDNDetails.pendingBlockSize ?? 0),
+ breakdown: [{
+ label: 'PENDING DELETION',
+ value: selectedDNDetails.pendingBlockSize ?? 0,
+ color: '#f4a233'
+ }, {
+ label: 'OZONE USED',
+ value: selectedDNDetails.used ?? 0,
+ color: '#10073b'
+ }]
+ }, {
+ title: 'FREE SPACE',
+ size: (selectedDNDetails.remaining ?? 0) +
(selectedDNDetails.committed ?? 0),
+ breakdown: [{
+ label: unusedSpaceBreakdown,
+ value: selectedDNDetails.remaining ?? 0,
+ color: '#f4a233'
+ }, {
+ label: 'OZONE PRE-ALLOCATED',
+ value: selectedDNDetails.committed ?? 0,
+ color: '#10073b'
+ }]
+ }]} />
+ </div>
+ </div>
+ </>
+
+ )
+
+};
+
+export default Capacity;
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityBreakdown.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityBreakdown.tsx
new file mode 100644
index 00000000000..e120040ebed
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityBreakdown.tsx
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import { GraphLegendIcon } from '@/utils/themeIcons';
+import StackedProgress from '@/v2/pages/capacity/components/StackedProgress';
+import { cardHeadStyle, statisticValueStyle } from
'@/v2/pages/capacity/constants/styles.constants';
+import { Segment } from '@/v2/types/capacity.types';
+import { Card, Statistic } from 'antd';
+import filesize from 'filesize';
+import React from 'react';
+
+type GridItem = {
+ title: string | React.ReactNode;
+ value: number;
+ color?: string;
+ format?: 'bytes' | 'number' | 'percentage';
+};
+
+type ClusterCardProps = {
+ title: string | React.ReactNode;
+ items: GridItem[];
+ loading: boolean;
+};
+
+const getProgressSegments = (items: GridItem[]) => {
+ return items.filter(item => item.color).map((item) => ({
+ value: item.value,
+ color: item.color!,
+ label: item.title
+ } as Segment));
+}
+
+const CapacityBreakdown: React.FC<ClusterCardProps> = ({ title, items, loading
}) => {
+
+ return (
+ <Card title={title} size='small' headStyle={cardHeadStyle}
loading={loading}>
+ <div className='cluster-card-data-container'>
+ {items.map((item, idx) => {
+ // Split the size into the value and the unit
+ const size = filesize((item.value > 0 ? item.value : 0), { round: 1
}).split(' ');
+ return (
+ <Statistic
+ key={`cluster-statistic-${item.title}-${idx}`}
+ title={item.title}
+ prefix={item.color ? <GraphLegendIcon color={item.color} /> :
undefined}
+ value={size[0]}
+ suffix={size[1]}
+ valueStyle={statisticValueStyle}
+ className='cluster-card-statistic'
+ />
+ )
+ })}
+ </div>
+ <StackedProgress segments={getProgressSegments(items)} />
+ </Card>
+ );
+};
+
+export default CapacityBreakdown;
\ No newline at end of file
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityDetail.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityDetail.tsx
new file mode 100644
index 00000000000..612f9f986fe
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityDetail.tsx
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import { EChart } from '@/components/eChart/eChart';
+import { GraphLegendIcon } from '@/utils/themeIcons';
+import { cardHeadStyle, statisticValueStyle } from
'@/v2/pages/capacity/constants/styles.constants';
+import { Segment } from '@/v2/types/capacity.types';
+import { DownloadOutlined } from '@ant-design/icons';
+import { Card, Divider, Row, Select, Spin, Statistic } from 'antd';
+import filesize from 'filesize';
+import React from 'react';
+
+type DataDetailItem = {
+ title: string | React.ReactNode;
+ size: number;
+ breakdown: Segment[];
+ loading?: boolean;
+}
+
+type CapacityDetailProps = {
+ title: string | React.ReactNode;
+ showDropdown: boolean;
+ selectorTitle?: string | React.ReactNode;
+ dataDetails: DataDetailItem[];
+ downloadUrl?: string;
+ dropdownItems?: {
+ label: React.ReactNode | string;
+ value: string;
+ }[];
+ onDownloadClick?: () => void;
+ disabledOpts?: string[];
+ optsClass?: string;
+ handleSelect?: React.Dispatch<React.SetStateAction<string>>
+ loading: boolean;
+ extra?: React.ReactNode;
+};
+
+const getEchartOptions = (title: string | React.ReactNode, data:
DataDetailItem) => {
+ const option = {
+ grid: {
+ left: 2,
+ right: 4,
+ top: 16,
+ bottom: 0
+ },
+ xAxis: {
+ // Use linear scale to support zero values safely
+ type: 'value',
+ axisLine: { show: false },
+ axisTick: { show: false },
+ axisLabel: { show: false }
+ },
+ yAxis: {
+ type: 'category',
+ axisLine: { show: false },
+ axisTick: { show: false },
+ axisLabel: { show: false },
+ },
+ };
+
+ const breakdownLen = data.breakdown.length;
+ const series = data.breakdown.map((breakdown, idx) => ({
+ type: 'bar',
+ ...(breakdownLen > 1 && { stack: title }),
+ itemStyle: {
+ ...(idx === breakdownLen - 1 && { borderRadius: [0, 50, 50, 0] }),
+ ...(idx === 0 && { borderRadius: [50, 0, 0, 50] }),
+ ...(breakdownLen === 1 && { borderRadius: [50, 50, 50, 50] }),
+ color: breakdown.color,
+ },
+ data: [breakdown.value],
+ barWidth: '10px',
+ barGap: '2px'
+ }));
+
+ return {
+ ...option,
+ series
+ } as any
+}
+
+
+const CapacityDetail: React.FC<CapacityDetailProps> = (
+ {
+ title,
+ showDropdown,
+ selectorTitle,
+ downloadUrl,
+ dropdownItems,
+ onDownloadClick,
+ disabledOpts,
+ optsClass,
+ dataDetails,
+ handleSelect,
+ loading,
+ extra
+ }
+) => {
+
+ const options = dropdownItems?.map((item) => ({
+ label: item.label,
+ value: item.value,
+ ...(disabledOpts?.includes(item.value) && { disabled: true }),
+ ...(optsClass && { className: optsClass }),
+ })) ?? [];
+
+ const cardExtra = extra ?? (downloadUrl
+ ? (
+ <a
+ href={downloadUrl}
+ onClick={(event) => {
+ if (onDownloadClick) {
+ event.preventDefault();
+ onDownloadClick();
+ }
+ }}
+ rel='noopener noreferrer'
+ >
+ Download Insights <DownloadOutlined />
+ </a>
+ )
+ : undefined);
+
+ return (
+ <Card title={title} size='small' headStyle={cardHeadStyle}
extra={cardExtra}>
+ <Spin spinning={loading}>
+ <>
+ { showDropdown && options.length > 0 &&
+ <div className='node-select-container'>
+ {selectorTitle}
+ <Select
+ showSearch
+ defaultValue={options?.[0]?.value}
+ options={options}
+ onChange={handleSelect}
+ style={{ marginBottom: '16px' }}
+ />
+ </div>
+ }
+ <div className='cluster-card-data-container vertical-layout'>
+ {dataDetails.map((data, idx) => {
+ const size = filesize(data.size, { round: 1 }).split(' ');
+ return (
+ <div key={`data-detail-${data.title}-${idx}`}
className='data-detail-item'>
+ <Statistic
+ title={data.title}
+ value={size[0]}
+ suffix={size[1]}
+ valueStyle={statisticValueStyle}
+ className='data-detail-statistic'
+ loading={data.loading}
+ />
+ {!data.loading && <Row
className='data-detail-breakdown-container'>
+ {data.breakdown.map((item, idx) => (
+ <div
key={`data-defailt-breakdown-${item.label}-${idx}`}
className='data-detail-breakdown-item'>
+ <GraphLegendIcon color={item.color} height={12} />
+ <span
className="data-detail-breakdown-label">{item.label}</span>
+ <span
className="data-detail-breakdown-value">{filesize(item.value, {round:
1})}</span>
+ </div>
+ ))}
+ <EChart
+ option={getEchartOptions(data.title, data)}
+ style={{ height: '40px', width: '100%', margin: '10px
0px' }} />
+ {idx < dataDetails.length - 1 && <Divider />}
+ </Row>}
+ </div>
+ )
+ })}
+ </div>
+ </>
+ </Spin>
+ </Card>
+ );
+}
+
+export default CapacityDetail;
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/StackedProgress.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/StackedProgress.tsx
new file mode 100644
index 00000000000..67b6f9dcad9
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/StackedProgress.tsx
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import React, { useMemo } from 'react';
+
+type StackedProgressProps = {
+ segments: Segment[];
+};
+
+const StackedProgress: React.FC<StackedProgressProps> = ({
+ segments,
+}) => {
+ const total = useMemo(() => {
+ return segments.reduce((sum, item) => sum + item.value, 0);
+ }, [segments]);
+
+ // Handle the case where there is no data to show
+ if (!total || total === 0) {
+ return (
+ <div className='stacked-progress-empty' />
+ );
+ }
+
+ return (
+ <div className='stacked-progress'>
+ {segments.map((segment, idx) => {
+ const segmentWidth = (segment.value / total) * 100;
+ return (
+ <div
+ key={segment.label || idx}
+ style={{
+ width: `${segmentWidth}%`,
+ backgroundColor: segment.color,
+ }}
+ />
+ );
+ })}
+ </div>
+ );
+};
+
+export default StackedProgress;
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/WrappedInfoIcon.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/WrappedInfoIcon.tsx
new file mode 100644
index 00000000000..f98a5f5e2f1
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/WrappedInfoIcon.tsx
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+import React from "react";
+import { InfoCircleOutlined } from "@ant-design/icons";
+import { Tooltip } from "antd";
+
+type WrappedInfoIconProps = {
+ title: string;
+ placement?: "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | "top" |
"bottom" | "left" | "right";
+}
+
+const WrappedInfoIcon: React.FC<WrappedInfoIconProps> = ({ title, placement =
"right" }) => {
+ return (
+ <Tooltip title={title} placement={placement}>
+ <InfoCircleOutlined style={{ color: '#2f84d8', fontSize: 12, marginLeft:
4 }} />
+ </Tooltip>
+ )
+};
+
+export default WrappedInfoIcon;
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/constants/descriptions.constants.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/constants/descriptions.constants.tsx
new file mode 100644
index 00000000000..739dc8ee016
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/constants/descriptions.constants.tsx
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+export const totalCapacityDesc = 'The space configured for Ozone to use in the
cluster. The actual disk space may be larger than what is allocated to Ozone.';
+
+export const otherUsedSpaceDesc = 'This is the space occupied by other Ozone
related files but not actual data stored by Ozone. This may include things like
logs, configuration files, Rocks DB files etc.';
+
+export const ozoneUsedSpaceDesc = 'These could also include potential missing
space or extra occupied space due to situations like under-replication,
over-replication, mismatched replicas, etc.';
+
+export const datanodesPendingDeletionDesc = 'This is the unreplicated size and
a cumulative value of all the blocks across all the datanodes in the cluster.';
+
+export const nodeSelectorMessage = "This contains the list of the top 15 DNs
by pending deletion size. The information on all the DNs is available as a CSV
download."
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/constants/styles.constants.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/constants/styles.constants.tsx
new file mode 100644
index 00000000000..0038f4c9ccb
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/constants/styles.constants.tsx
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+export const cardHeadStyle: React.CSSProperties = {
+ width: '100%',
+ flexGrow: 0,
+ fontFamily: 'Roboto',
+ fontSize: '16px',
+ fontWeight: 500,
+ fontStretch: 'normal',
+ fontStyle: 'normal',
+ lineHeight: 1.5,
+ letterSpacing: 'normal',
+ textAlign: 'left',
+ color: '#5a656d'
+};
+
+export const statisticValueStyle: React.CSSProperties = {
+ fontFamily: 'Roboto',
+ fontSize: '24px',
+ fontWeight: 'normal',
+ fontStretch: 'normal',
+ fontStyle: 'normal',
+ lineHeight: 1.33,
+ letterSpacing: 'normal',
+ textAlign: 'left',
+ color: '#1b2329'
+};
\ No newline at end of file
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/overview/overview.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/overview/overview.tsx
index 32a1f6de0f3..c13edcc1407 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/overview/overview.tsx
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/overview/overview.tsx
@@ -24,9 +24,9 @@ import moment from 'moment';
import filesize from 'filesize';
import AutoReloadPanel from '@/components/autoReloadPanel/autoReloadPanel';
-import OverviewSimpleCard from
'@/v2/components/overviewCard/overviewSimpleCard';
-import OverviewSummaryCard from
'@/v2/components/overviewCard/overviewSummaryCard';
-import OverviewStorageCard from
'@/v2/components/overviewCard/overviewStorageCard';
+import OverviewSimpleCard from '@/v2/components/cards/overviewSimpleCard';
+import OverviewSummaryCard from '@/v2/components/cards/overviewSummaryCard';
+import OverviewStorageCard from '@/v2/components/cards/overviewStorageCard';
import { AxiosGetHelper } from '@/utils/axiosRequestHelper';
import { showDataFetchError } from '@/utils/common';
import { cancelRequests } from '@/utils/axiosRequestHelper';
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx
index f465c64756a..cbf1f93e5cf 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx
@@ -26,6 +26,7 @@ const NamespaceUsage = lazy(() =>
import('@/v2/pages/namespaceUsage/namespaceUsa
const Containers = lazy(() => import('@/v2/pages/containers/containers'));
const Insights = lazy(() => import('@/v2/pages/insights/insights'));
const OMDBInsights = lazy(() => import('@/v2/pages/insights/omInsights'));
+const Capacity = lazy(() => import('@/v2/pages/capacity/capacity'));
const Heatmap = lazy(() => import('@/v2/pages/heatmap/heatmap'));
@@ -66,6 +67,10 @@ export const routesV2 = [
path: '/Om',
component: OMDBInsights
},
+ {
+ path: '/Capacity',
+ component: Capacity
+ },
{
path: '/Heatmap',
component: Heatmap
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/capacity.types.ts
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/capacity.types.ts
new file mode 100644
index 00000000000..5257c17b819
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/capacity.types.ts
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+
+type GlobalStorage = {
+ totalUsedSpace: number;
+ totalFreeSpace: number;
+ totalCapacity: number;
+};
+
+type GlobalNamespace = {
+ totalUsedSpace: number;
+ totalKeys: number;
+};
+
+type UsedSpaceBreakdown = {
+ openKeyBytes: number;
+ committedKeyBytes: number;
+ preAllocatedContainerBytes: number;
+};
+
+type DNPendingDeleteStat = {
+ hostName: string;
+ datanodeUuid: string;
+ pendingBlockSize: number;
+}
+
+export type DataNodeUsage = {
+ datanodeUuid: string;
+ hostName: string;
+ capacity: number;
+ used: number;
+ remaining: number;
+ committed: number;
+ minimumFreeSpace: number;
+ reserved: number;
+};
+
+export type UtilizationResponse = {
+ globalStorage: GlobalStorage;
+ globalNamespace: GlobalNamespace;
+ usedSpaceBreakdown: UsedSpaceBreakdown;
+ dataNodeUsage: DataNodeUsage[];
+};
+
+export type DNPendingDeletion = {
+ status: "NOT_STARTED" | "IN_PROGRESS" | "FINISHED" | "FAILED";
+ totalPendingDeletionSize: number | null;
+ pendingDeletionPerDataNode: DNPendingDeleteStat[] | null;
+ totalNodesQueried: number | null;
+ totalNodeQueriesFailed: number | null;
+}
+
+export type OMPendingDeletion = {
+ totalSize: number;
+ pendingDirectorySize: number;
+ pendingKeySize: number;
+}
+
+export type SCMPendingDeletion = {
+ totalBlocksize: number;
+ totalReplicatedBlockSize: number;
+ totalBlocksCount: number;
+}
+
+export type Segment = {
+ value: number;
+ color: string;
+ label: string | React.ReactNode;
+};
diff --git
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/api/TestPendingDeletionEndpoint.java
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/api/TestPendingDeletionEndpoint.java
new file mode 100644
index 00000000000..3c9af15d4dc
--- /dev/null
+++
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/api/TestPendingDeletionEndpoint.java
@@ -0,0 +1,269 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+package org.apache.hadoop.ozone.recon.api;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.StreamingOutput;
+import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
+import org.apache.hadoop.hdds.scm.protocol.StorageContainerLocationProtocol;
+import org.apache.hadoop.ozone.recon.api.types.DataNodeMetricsServiceResponse;
+import org.apache.hadoop.ozone.recon.api.types.DatanodePendingDeletionMetrics;
+import org.apache.hadoop.ozone.recon.api.types.ScmPendingDeletion;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Test class for PendingDeletionEndpoint.
+ *
+ * This class tests pending deletion endpoint behaviors, including:
+ *
+ * 1. Component validation and error handling for missing/invalid components.
+ * 2. DataNode metrics responses for finished, in-progress, and null-limit
requests.
+ * 3. SCM pending deletion summaries for success, no-content, and exception
fallback.
+ * 4. OM pending deletion response pass-through values.
+ * 5. CSV download responses for pending, missing metrics, and successful
exports.
+ */
+public class TestPendingDeletionEndpoint {
+ private PendingDeletionEndpoint pendingDeletionEndpoint;
+ private ReconGlobalMetricsService reconGlobalMetricsService;
+ private DataNodeMetricsService dataNodeMetricsService;
+ private StorageContainerLocationProtocol scmClient;
+
+ @BeforeEach
+ public void setup() {
+ reconGlobalMetricsService = mock(ReconGlobalMetricsService.class);
+ dataNodeMetricsService = mock(DataNodeMetricsService.class);
+ scmClient = mock(StorageContainerLocationProtocol.class);
+ pendingDeletionEndpoint = new PendingDeletionEndpoint(
+ reconGlobalMetricsService, dataNodeMetricsService, scmClient);
+ }
+
+ @Test
+ public void testMissingComponentReturnsBadRequest() {
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent(null, 10);
+
+ assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ assertEquals("component query parameter is required",
response.getEntity());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"unknown", "invalid"})
+ public void testInvalidComponentReturnsBadRequest(String component) {
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent(component, 10);
+
+ assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ assertEquals("component query parameter must be one of dn, scm, om",
response.getEntity());
+ }
+
+ @Test
+ public void testEmptyComponentReturnsBadRequest() {
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("", 10);
+
+ assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ assertEquals("component query parameter is required",
response.getEntity());
+ }
+
+ @Test
+ public void testWhitespaceComponentReturnsBadRequest() {
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent(" ", 10);
+
+ assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ assertEquals("component query parameter must be one of dn, scm, om",
response.getEntity());
+ }
+
+ @Test
+ public void testDnComponentWithInvalidLimit() {
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("dn", 0);
+
+ assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ assertEquals("Limit query parameter must be at-least 1",
response.getEntity());
+ }
+
+ @Test
+ public void testDnComponentReturnsOkWhenFinished() {
+ DataNodeMetricsServiceResponse metricsResponse =
DataNodeMetricsServiceResponse.newBuilder()
+ .setStatus(DataNodeMetricsService.MetricCollectionStatus.FINISHED)
+ .setTotalPendingDeletionSize(100L)
+ .setTotalNodesQueried(1)
+ .setTotalNodeQueryFailures(0)
+ .setPendingDeletion(Arrays.asList(
+ new DatanodePendingDeletionMetrics("dn1", "uuid-1", 100L)))
+ .build();
+
when(dataNodeMetricsService.getCollectedMetrics(5)).thenReturn(metricsResponse);
+
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("DN", 5);
+
+ assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+ assertEquals(metricsResponse, response.getEntity());
+ }
+
+ @Test
+ public void testDnComponentAllowsNullLimit() {
+ DataNodeMetricsServiceResponse metricsResponse =
DataNodeMetricsServiceResponse.newBuilder()
+ .setStatus(DataNodeMetricsService.MetricCollectionStatus.FINISHED)
+ .setTotalPendingDeletionSize(100L)
+ .setTotalNodesQueried(1)
+ .setTotalNodeQueryFailures(0)
+ .build();
+
when(dataNodeMetricsService.getCollectedMetrics(null)).thenReturn(metricsResponse);
+
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("dn", null);
+
+ assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+ assertEquals(metricsResponse, response.getEntity());
+ }
+
+ @Test
+ public void testDnComponentReturnsAcceptedWhenInProgress() {
+ DataNodeMetricsServiceResponse metricsResponse =
DataNodeMetricsServiceResponse.newBuilder()
+ .setStatus(DataNodeMetricsService.MetricCollectionStatus.IN_PROGRESS)
+ .build();
+
when(dataNodeMetricsService.getCollectedMetrics(2)).thenReturn(metricsResponse);
+
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("dn", 2);
+
+ assertEquals(Response.Status.ACCEPTED.getStatusCode(),
response.getStatus());
+ assertEquals(metricsResponse, response.getEntity());
+ }
+
+ @Test
+ public void testScmComponentReturnsSummary() throws Exception {
+ HddsProtos.DeletedBlocksTransactionSummary summary =
+ HddsProtos.DeletedBlocksTransactionSummary.newBuilder()
+ .setTotalBlockSize(100L)
+ .setTotalBlockReplicatedSize(300L)
+ .setTotalBlockCount(5L)
+ .build();
+ when(scmClient.getDeletedBlockSummary()).thenReturn(summary);
+
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("scm", 1);
+
+ assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+ ScmPendingDeletion pendingDeletion = (ScmPendingDeletion)
response.getEntity();
+ assertNotNull(pendingDeletion);
+ assertEquals(100L, pendingDeletion.getTotalBlocksize());
+ assertEquals(300L, pendingDeletion.getTotalReplicatedBlockSize());
+ assertEquals(5L, pendingDeletion.getTotalBlocksCount());
+ }
+
+ @Test
+ public void testScmComponentReturnsNoContentWhenSummaryMissing() throws
Exception {
+ when(scmClient.getDeletedBlockSummary()).thenReturn(null);
+
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("scm", 1);
+
+ assertEquals(Response.Status.NO_CONTENT.getStatusCode(),
response.getStatus());
+ }
+
+ @Test
+ public void testScmComponentReturnsFallbackOnException() throws Exception {
+ when(scmClient.getDeletedBlockSummary()).thenThrow(new
RuntimeException("failure"));
+
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("scm", 1);
+
+ assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+ ScmPendingDeletion pendingDeletion = (ScmPendingDeletion)
response.getEntity();
+ assertNotNull(pendingDeletion);
+ assertEquals(-1L, pendingDeletion.getTotalBlocksize());
+ assertEquals(-1L, pendingDeletion.getTotalReplicatedBlockSize());
+ assertEquals(-1L, pendingDeletion.getTotalBlocksCount());
+ }
+
+ @Test
+ public void testOmComponentReturnsPendingDeletionSizes() {
+ Map<String, Long> pendingSizes = new HashMap<>();
+ pendingSizes.put("pendingDirectorySize", 200L);
+ pendingSizes.put("pendingKeySize", 400L);
+ pendingSizes.put("totalSize", 600L);
+
when(reconGlobalMetricsService.calculatePendingSizes()).thenReturn(pendingSizes);
+
+ Response response =
pendingDeletionEndpoint.getPendingDeletionByComponent("om", 1);
+
+ assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
+ assertEquals(pendingSizes, response.getEntity());
+ }
+
+ @Test
+ public void testDownloadReturnsAcceptedWhenCollectionInProgress() {
+ DataNodeMetricsServiceResponse metricsResponse =
DataNodeMetricsServiceResponse.newBuilder()
+ .setStatus(DataNodeMetricsService.MetricCollectionStatus.IN_PROGRESS)
+ .build();
+
when(dataNodeMetricsService.getCollectedMetrics(null)).thenReturn(metricsResponse);
+
+ Response response = pendingDeletionEndpoint.downloadPendingDeleteData();
+
+ assertEquals(Response.Status.ACCEPTED.getStatusCode(),
response.getStatus());
+ assertEquals("application/json", response.getMediaType().toString());
+ assertEquals(metricsResponse, response.getEntity());
+ }
+
+ @Test
+ public void testDownloadReturnsServerErrorWhenMetricsMissing() {
+ DataNodeMetricsServiceResponse metricsResponse =
DataNodeMetricsServiceResponse.newBuilder()
+ .setStatus(DataNodeMetricsService.MetricCollectionStatus.FINISHED)
+ .build();
+
when(dataNodeMetricsService.getCollectedMetrics(null)).thenReturn(metricsResponse);
+
+ Response response = pendingDeletionEndpoint.downloadPendingDeleteData();
+
+ assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
response.getStatus());
+ assertEquals("Metrics data is missing despite FINISHED status.",
response.getEntity());
+ assertEquals("text/plain", response.getMediaType().toString());
+ }
+
+ @Test
+ public void testDownloadReturnsCsvWithMetrics() throws Exception {
+ List<DatanodePendingDeletionMetrics> pendingDeletionMetrics =
Arrays.asList(
+ new DatanodePendingDeletionMetrics("dn1", "uuid-1", 10L),
+ new DatanodePendingDeletionMetrics("dn2", "uuid-2", 20L));
+ DataNodeMetricsServiceResponse metricsResponse =
DataNodeMetricsServiceResponse.newBuilder()
+ .setStatus(DataNodeMetricsService.MetricCollectionStatus.FINISHED)
+ .setPendingDeletion(pendingDeletionMetrics)
+ .build();
+
when(dataNodeMetricsService.getCollectedMetrics(null)).thenReturn(metricsResponse);
+
+ Response response = pendingDeletionEndpoint.downloadPendingDeleteData();
+
+ assertEquals(Response.Status.ACCEPTED.getStatusCode(),
response.getStatus());
+ assertEquals("text/csv", response.getMediaType().toString());
+ assertEquals("attachment;
filename=\"pending_deletion_all_datanode_stats.csv\"",
+ response.getHeaderString("Content-Disposition"));
+ StreamingOutput streamingOutput = assertInstanceOf(StreamingOutput.class,
response.getEntity());
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ streamingOutput.write(outputStream);
+ String csv = new String(outputStream.toByteArray(),
StandardCharsets.UTF_8);
+ assertTrue(csv.contains("HostName,Datanode UUID,Pending Block Size
(bytes)"));
+ assertTrue(csv.contains("dn1,uuid-1,10"));
+ assertTrue(csv.contains("dn2,uuid-2,20"));
+ }
+}
diff --git a/pom.xml b/pom.xml
index 79946385d56..216c80dd964 100644
--- a/pom.xml
+++ b/pom.xml
@@ -56,6 +56,7 @@
<commons-collections.version>4.4</commons-collections.version>
<commons-compress.version>1.27.1</commons-compress.version>
<commons-configuration2.version>2.12.0</commons-configuration2.version>
+ <commons-csv.version>1.14.1</commons-csv.version>
<commons-daemon.version>1.4.0</commons-daemon.version>
<commons-fileupload.version>1.6.0</commons-fileupload.version>
<commons-io.version>2.18.0</commons-io.version>
@@ -705,6 +706,11 @@
</exclusion>
</exclusions>
</dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-csv</artifactId>
+ <version>${commons-csv.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]