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]

Reply via email to