This is an automated email from the ASF dual-hosted git repository.

arafat2198 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 ce46297e85 HDDS-11162. Improve Disk Usage page UI (#7214)
ce46297e85 is described below

commit ce46297e85b490a29f933b171fcada2dbebba3b0
Author: Abhishek Pal <[email protected]>
AuthorDate: Tue Oct 1 11:26:04 2024 +0530

    HDDS-11162. Improve Disk Usage page UI (#7214)
---
 .../webapps/recon/ozone-recon-web/api/db.json      |   2 +-
 .../components/duBreadcrumbNav/duBreadcrumbNav.tsx | 173 +++++++++
 .../src/v2/components/duMetadata/duMetadata.tsx    | 389 +++++++++++++++++++++
 .../src/v2/components/duPieChart/duPieChart.tsx    | 211 +++++++++++
 .../src/v2/components/eChart/eChart.tsx            |  15 +-
 .../src/v2/pages/diskUsage/diskUsage.less          |  59 ++++
 .../src/v2/pages/diskUsage/diskUsage.tsx           | 144 ++++++++
 .../recon/ozone-recon-web/src/v2/routes-v2.tsx     |   5 +
 .../v2/{routes-v2.tsx => types/diskUsage.types.ts} |  50 ++-
 9 files changed, 1018 insertions(+), 30 deletions(-)

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 8cfb23ad68..f1d5dc3670 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
@@ -1480,7 +1480,7 @@
     "path": "/dummyVolume/dummyBucket",
     "size": 200000,
     "sizeWithReplica": -1,
-    "subPathCount": 5,
+    "subPathCount": 8,
     "subPaths": [
       {
         "path": "/dummyVolume/dummyBucket/dir1",
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duBreadcrumbNav/duBreadcrumbNav.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duBreadcrumbNav/duBreadcrumbNav.tsx
new file mode 100644
index 0000000000..d28212cca2
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duBreadcrumbNav/duBreadcrumbNav.tsx
@@ -0,0 +1,173 @@
+/*
+ * 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, { useState } from 'react';
+
+import { DUSubpath } from '@/v2/types/diskUsage.types';
+import { Breadcrumb, Menu, Input } from 'antd';
+import { MenuProps } from 'antd/es/menu';
+import { CaretDownOutlined, CaretRightOutlined, HomeFilled } from 
'@ant-design/icons';
+
+
+type File = {
+  path: string;
+  subPaths: DUSubpath[];
+  updateHandler: (arg0: string) => void;
+};
+
+
+const DUBreadcrumbNav: React.FC<File> = ({
+  path = '/',
+  subPaths = [],
+  updateHandler
+}) => {
+  const [currPath, setCurrPath] = useState<string[]>([]);
+
+  function generateCurrentPathState() {
+    // We are not at root path
+    if (path !== '/') {
+      /**
+       * Remove leading / and split to avoid empty string 
+       * Without the substring this will produce ['', 'pathLoc'] for /pathLoc
+       */
+      const splitPath = path.substring(1).split('/');
+      setCurrPath(
+        ['/', ...splitPath]
+      );
+    }
+    else {
+      setCurrPath(['/']);
+    }
+  }
+
+  const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
+    //If click is not on search panel
+    if (!(key as string).includes('-search')){
+      updateHandler(key as string);
+    }
+  }
+
+  function handleSearch(value: string) {
+    /**
+     * The following will generate  path like //vol1/buck1/dir1/key...
+     * since the first element of the currPos is ['/']
+     * we are joining that with / as well causing //
+     * Hence we substring from 1st index to remove one / from path
+     */
+    updateHandler([...currPath, value].join('/').substring(1));
+  }
+
+  function handleBreadcrumbClick(idx: number, lastPath: string) {
+    /**
+     * The following will generate  path like //vol1/buck1/dir1/key...
+     * since the first element of the currPos is ['/']
+     * we are joining that with / as well causing //
+     */
+    const constructedPath = [...currPath.slice(0, idx), lastPath].join('/');
+    if (idx === 0) {
+      //Root path clicked
+      updateHandler('/');
+    }
+    else {
+      // Pass the string without the leading /
+      updateHandler(constructedPath.substring(1));
+    }
+  }
+
+  function generateSubMenu(lastPath: string) {
+    const menuItems = subPaths.map((subpath) => {
+      // Do not add any menu item for keys i.e keys cannot be drilled down
+      // further
+      if (!subpath.isKey) {
+        const splitSubpath = subpath.path.split('/');
+        return (
+          <Menu.Item key={subpath.path}>
+            {splitSubpath[splitSubpath.length - 1]}
+          </Menu.Item>
+        );
+      }
+      
+    });
+    //Push a new input to allow passing a path
+    menuItems.push(
+      <Menu.Item
+        key={`${lastPath}-search`}>
+        <Input.Search
+          placeholder='Enter Path'
+          onSearch={handleSearch}
+          onClick={(e) => {
+            //Prevent menu close on click
+            e.stopPropagation();
+          }}
+          style={{ width: 160}}/>
+      </Menu.Item>
+    )
+    return (
+      <Breadcrumb.Item key={lastPath}
+        overlay={
+          <Menu
+            onClick={handleMenuClick}
+            mode='inline'
+            expandIcon={<CaretDownOutlined/>}>
+            {menuItems}
+          </Menu>
+        }
+        dropdownProps={{
+          trigger: ['click'] 
+        }}>
+        {(lastPath === '/') ? <HomeFilled style={{fontSize: '16px'}}/> : 
lastPath}
+      </Breadcrumb.Item>
+    )
+  }
+
+  React.useEffect(() => {
+    generateCurrentPathState()
+  }, [path]); //Anytime the path changes we need to generate the state
+
+  function generateBreadCrumbs(){
+    let breadCrumbs = [];
+    currPath.forEach((location, idx) => {
+      breadCrumbs.push(
+        <Breadcrumb.Item
+          key={location}>
+          {(location === '/')
+            ? <HomeFilled
+                onClick={() => {handleBreadcrumbClick(idx, location)}}
+                style={{color: '#1aa57a'}} />
+            : (<button
+                className='breadcrumb-nav-item'
+                onClick={() => {handleBreadcrumbClick(idx, location)}}>
+                  {location}
+              </button>)}
+        </Breadcrumb.Item>
+      );
+    });
+    breadCrumbs[breadCrumbs.length - 1] = 
generateSubMenu(currPath[currPath.length - 1]);
+    return breadCrumbs;
+  }
+
+  return (
+    <Breadcrumb
+      separator={<CaretRightOutlined style={{ fontSize: '12px'}}/>}
+      className='breadcrumb-nav'>
+      {generateBreadCrumbs()}
+    </Breadcrumb>
+  )
+}
+
+export default DUBreadcrumbNav;
\ No newline at end of file
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duMetadata/duMetadata.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duMetadata/duMetadata.tsx
new file mode 100644
index 0000000000..f2c740f7db
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duMetadata/duMetadata.tsx
@@ -0,0 +1,389 @@
+/*
+ * 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, { useRef, useState } from 'react';
+import moment from 'moment';
+import { AxiosError } from 'axios';
+import { Table } from 'antd';
+
+import { AxiosGetHelper, cancelRequests } from '@/utils/axiosRequestHelper';
+import { byteToSize, showDataFetchError } from '@/utils/common';
+
+import { Acl } from '@/v2/types/acl.types';
+
+
+// ------------- Types -------------- //
+type CountStats = {
+  numBucket: number;
+  numDir: number;
+  numKey: number;
+  numVolume: number;
+};
+
+type LocationInfo = {
+  blockID: {
+    containerBlockID: {
+      containerID: number;
+      localID: number;
+    };
+    blockCommitSequenceId: number;
+    containerID: number;
+    localID: number;
+  };
+  length: number;
+  offset: number;
+  token: null;
+  createVersion: number;
+  pipeline: null;
+  partNumber: number;
+  containerID: number;
+  localID: number;
+  blockCommitSequenceId: number;
+};
+
+type ObjectInfo = {
+  bucketName: string;
+  bucketLayout: string;
+  encInfo: null;
+  fileName: string;
+  keyName: string;
+  name: string;
+  owner: string;
+  volume: string;
+  volumeName: string;
+  sourceVolume: string | null;
+  sourceBucket: string | null;
+  usedBytes: number | null;
+  usedNamespace: number;
+  storageType: string;
+  creationTime: number;
+  dataSize: number;
+  modificationTime: number;
+  quotaInBytes: number;
+  quotaInNamespace: number;
+}
+
+type ReplicationConfig = {
+  replicationFactor: string;
+  requiredNodes: number;
+  replicationType: string;
+}
+
+type ObjectInfoResponse = ObjectInfo & {
+  acls: Acl[];
+  versioningEnabled: boolean;
+  metadata: Record<string, any>;
+  file: boolean;
+  keyLocationVersions: {
+    version: number;
+    locationList: LocationInfo[];
+    multipartKey: boolean;
+    blocksLatestVersionOnly: LocationInfo[];
+    locationLists: LocationInfo[][];
+    locationListCount: number;
+  }[];
+  versioning: boolean;
+  encryptionInfo: null;
+  replicationConfig: ReplicationConfig;
+};
+
+type SummaryResponse = {
+  countStats: CountStats;
+  objectInfo: ObjectInfoResponse;
+  path: string;
+  status: string;
+  type: string;
+}
+
+type MetadataProps = {
+  path: string;
+};
+
+type MetadataState = {
+  keys: string[];
+  values: (string | number | boolean | null)[];
+};
+
+
+// ------------- Component -------------- //
+const DUMetadata: React.FC<MetadataProps> = ({
+  path = '/'
+}) => {
+  const [loading, setLoading] = useState<boolean>(false);
+  const [state, setState] = useState<MetadataState>({
+    keys: [],
+    values: []
+  });
+  const cancelSummarySignal = useRef<AbortController>();
+  const keyMetadataSummarySignal = useRef<AbortController>();
+  const cancelQuotaSignal = useRef<AbortController>();
+
+  const getObjectInfoMapping = React.useCallback((summaryResponse) => {
+
+    const keys: string[] = [];
+    const values: (string | number | boolean | null)[] = [];
+    /**
+     * We are creating a specific set of keys under Object Info response
+     * which do not require us to modify anything
+     */
+    const selectedInfoKeys = [
+      'bucketName', 'bucketLayout', 'encInfo', 'fileName', 'keyName',
+      'name', 'owner', 'sourceBucket', 'sourceVolume', 'storageType',
+      'usedNamespace', 'volumeName', 'volume'
+    ] as const;
+    const objectInfo: ObjectInfo = summaryResponse.objectInfo ?? {};
+
+    selectedInfoKeys.forEach((key) => {
+      if (objectInfo[key as keyof ObjectInfo] !== undefined && objectInfo[key 
as keyof ObjectInfo] !== -1) {
+        // We will use regex to convert the Object key from camel case to 
space separated title
+        // The following regex will match abcDef and produce Abc Def
+        let keyName = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
+        keyName = keyName.charAt(0).toUpperCase() + keyName.slice(1);
+        keys.push(keyName);
+        values.push(objectInfo[key as keyof ObjectInfo]);
+      }
+    });
+
+    if (objectInfo?.creationTime !== undefined && objectInfo?.creationTime !== 
-1) {
+      keys.push('Creation Time');
+      values.push(moment(objectInfo.creationTime).format('ll LTS'));
+    }
+
+    if (objectInfo?.usedBytes !== undefined && objectInfo?.usedBytes !== -1 && 
objectInfo!.usedBytes !== null) {
+      keys.push('Used Bytes');
+      values.push(byteToSize(objectInfo.usedBytes, 3));
+    }
+
+    if (objectInfo?.dataSize !== undefined && objectInfo?.dataSize !== -1) {
+      keys.push('Data Size');
+      values.push(byteToSize(objectInfo.dataSize, 3));
+    }
+
+    if (objectInfo?.modificationTime !== undefined && 
objectInfo?.modificationTime !== -1) {
+      keys.push('Modification Time');
+      values.push(moment(objectInfo.modificationTime).format('ll LTS'));
+    }
+
+    if (objectInfo?.quotaInBytes !== undefined && objectInfo?.quotaInBytes !== 
-1) {
+      keys.push('Quota In Bytes');
+      values.push(byteToSize(objectInfo.quotaInBytes, 3));
+    }
+
+    if (objectInfo?.quotaInNamespace !== undefined && 
objectInfo?.quotaInNamespace !== -1) {
+      keys.push('Quota In Namespace');
+      values.push(byteToSize(objectInfo.quotaInNamespace, 3));
+    }
+
+    if (summaryResponse.objectInfo?.replicationConfig?.replicationFactor !== 
undefined) {
+      keys.push('Replication Factor');
+      
values.push(summaryResponse.objectInfo.replicationConfig.replicationFactor);
+    }
+
+    if (summaryResponse.objectInfo?.replicationConfig?.replicationType !== 
undefined) {
+      keys.push('Replication Type');
+      
values.push(summaryResponse.objectInfo.replicationConfig.replicationType);
+    }
+
+    if (summaryResponse.objectInfo?.replicationConfig?.requiredNodes !== 
undefined
+      && summaryResponse.objectInfo?.replicationConfig?.requiredNodes !== -1) {
+      keys.push('Replication Required Nodes');
+      values.push(summaryResponse.objectInfo.replicationConfig.requiredNodes);
+    }
+
+    return { keys, values }
+  }, [path]);
+
+  function loadMetadataSummary(path: string) {
+    cancelRequests([
+      cancelSummarySignal.current!,
+      keyMetadataSummarySignal.current!
+    ]);
+    const keys: string[] = [];
+    const values: (string | number | boolean | null)[] = [];
+
+    const { request, controller } = AxiosGetHelper(
+      `/api/v1/namespace/summary?path=${path}`,
+      cancelSummarySignal.current
+    );
+    cancelSummarySignal.current = controller;
+
+    request.then(response => {
+      const summaryResponse: SummaryResponse = response.data;
+      keys.push('Entity Type');
+      values.push(summaryResponse.type);
+
+      if (summaryResponse.status === 'INITIALIZING') {
+        showDataFetchError(`The metadata is currently initializing. Please 
wait a moment and try again later`);
+        return;
+      }
+
+      if (summaryResponse.status === 'PATH_NOT_FOUND') {
+        showDataFetchError(`Invalid Path: ${path}`);
+        return;
+      }
+
+      // If the entity is a Key then fetch the Key metadata only
+      if (summaryResponse.type === 'KEY') {
+        const { request: metadataRequest, controller: metadataNewController } 
= AxiosGetHelper(
+          `/api/v1/namespace/du?path=${path}&replica=true`,
+          keyMetadataSummarySignal.current
+        );
+        keyMetadataSummarySignal.current = metadataNewController;
+        metadataRequest.then(response => {
+          keys.push('File Size');
+          values.push(byteToSize(response.data.size, 3));
+          keys.push('File Size With Replication');
+          values.push(byteToSize(response.data.sizeWithReplica, 3));
+          keys.push("Creation Time");
+          
values.push(moment(summaryResponse.objectInfo.creationTime).format('ll LTS'));
+          keys.push("Modification Time");
+          
values.push(moment(summaryResponse.objectInfo.modificationTime).format('ll 
LTS'));
+
+          setState({
+            keys: keys,
+            values: values
+          });
+        }).catch(error => {
+          showDataFetchError(error.toString());
+        });
+        return;
+      }
+
+      /** 
+       * Will iterate over the keys of the countStats to avoid multiple if 
blocks
+       * and check from the map for the respective key name / title to insert
+      */
+      const countStats: CountStats = summaryResponse.countStats ?? {};
+      const keyToNameMap: Record<string, string> = {
+        numVolume: 'Volumes',
+        numBucket: 'Buckets',
+        numDir: 'Total Directories',
+        numKey: 'Total Keys'
+      }
+      Object.keys(countStats).forEach((key: string) => {
+        if (countStats[key as keyof CountStats] !== undefined
+          && countStats[key as keyof CountStats] !== -1) {
+          keys.push(keyToNameMap[key]);
+          values.push(countStats[key as keyof CountStats]);
+        }
+      })
+
+      const {
+        keys: objectInfoKeys,
+        values: objectInfoValues
+      } = getObjectInfoMapping(summaryResponse);
+
+      keys.push(...objectInfoKeys);
+      values.push(...objectInfoValues);
+
+      setState({
+        keys: keys,
+        values: values
+      });
+    }).catch(error => {
+      showDataFetchError((error as AxiosError).toString());
+    });
+  }
+
+  function loadQuotaSummary(path: string) {
+    cancelRequests([
+      cancelQuotaSignal.current!
+    ]);
+
+    const { request, controller } = AxiosGetHelper(
+      `/api/v1/namespace/quota?path=${path}`,
+      cancelQuotaSignal.current
+    );
+    cancelQuotaSignal.current = controller;
+
+    request.then(response => {
+      const quotaResponse = response.data;
+
+      if (quotaResponse.status === 'INITIALIZING') {
+        return;
+      }
+      if (quotaResponse.status === 'TYPE_NOT_APPLICABLE') {
+        return;
+      }
+      if (quotaResponse.status === 'PATH_NOT_FOUND') {
+        showDataFetchError(`Invalid Path: ${path}`);
+        return;
+      }
+
+      const keys: string[] = [];
+      const values: (string | number | boolean | null)[] = [];
+      // Append quota information
+      // In case the object's quota isn't set
+      if (quotaResponse.allowed !== undefined && quotaResponse.allowed !== -1) 
{
+        keys.push('Quota Allowed');
+        values.push(byteToSize(quotaResponse.allowed, 3));
+      }
+
+      if (quotaResponse.used !== undefined && quotaResponse.used !== -1) {
+        keys.push('Quota Used');
+        values.push(byteToSize(quotaResponse.used, 3));
+      }
+      setState((prevState) => ({
+        keys: [...prevState.keys, ...keys],
+        values: [...prevState.values, ...values]
+      }));
+    }).catch(error => {
+      showDataFetchError(error.toString());
+    });
+  }
+
+  React.useEffect(() => {
+    setLoading(true);
+    loadMetadataSummary(path);
+    loadQuotaSummary(path);
+    setLoading(false);
+
+    return (() => {
+      cancelRequests([
+        cancelSummarySignal.current!,
+        keyMetadataSummarySignal.current!,
+        cancelQuotaSignal.current!
+      ]);
+    })
+  }, [path]);
+
+  const content = [];
+  for (const [i, v] of state.keys.entries()) {
+    content.push({
+      key: v,
+      value: state.values[i]
+    });
+  }
+
+  return (
+    <Table
+      size='small'
+      loading={loading}
+      dataSource={content}
+      bordered={true}
+      style={{
+        flex: '0 1 45%',
+        margin: '10px auto' }}
+      locale={{ filterTitle: '' }}>
+      <Table.Column title='Property' dataIndex='key' />
+      <Table.Column title='Value' dataIndex='value' />
+    </Table>
+  );
+}
+
+export default DUMetadata;
\ No newline at end of file
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duPieChart/duPieChart.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duPieChart/duPieChart.tsx
new file mode 100644
index 0000000000..2601905a14
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duPieChart/duPieChart.tsx
@@ -0,0 +1,211 @@
+/*
+ * 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 EChart from '@/v2/components/eChart/eChart';
+import { byteToSize } from '@/utils/common';
+import { DUSubpath } from '@/v2/types/diskUsage.types';
+
+//-------Types--------//
+type PieChartProps = {
+  path: string;
+  limit: number;
+  size: number;
+  subPaths: DUSubpath[];
+  subPathCount: number;
+  sizeWithReplica: number;
+  loading: boolean;
+}
+
+//-------Constants---------//
+const OTHER_PATH_NAME = 'Other Objects';
+const MIN_BLOCK_SIZE = 0.05;
+
+
+//----------Component---------//
+const DUPieChart: React.FC<PieChartProps> = ({
+  path,
+  limit,
+  size,
+  subPaths,
+  subPathCount,
+  sizeWithReplica,
+  loading
+}) => {
+
+  const [subpathSize, setSubpathSize]  = React.useState<number>(0);
+
+  function getSubpathSize(subpaths: DUSubpath[]): number {
+    const subpathSize = subpaths
+      .map((subpath) => subpath.size)
+      .reduce((acc, curr) => acc + curr, 0);
+    // If there is no subpaths, then the size will be total size of path
+    return (subPaths.length === 0) ? size : subpathSize;
+  }
+
+  function updatePieData() {
+    /**
+     * We need to calculate the size of "Other objects" in two cases:
+     * 
+     *  1) If we have more subpaths listed, than the limit.
+     *  2) If the limit is set to the maximum limit (30) and we have any 
number of subpaths.
+     *     In this case we won't necessarily have "Other objects", but we 
check if the
+     *     other objects's size is more than zero (we will have other objects 
if there are more than 30 subpaths,
+     *     but we can't check on that, as the response will always have
+     *     30 subpaths, but from the total size and the subpaths size we can 
calculate it).
+     */
+    let subpaths: DUSubpath[] = subPaths;
+
+    let pathLabels: string[] = [];
+    let percentage: string[] = [];
+    let sizeStr: string[];
+    let valuesWithMinBlockSize: number[] = [];
+
+    if (subPathCount > limit) {
+      // If the subpath count is greater than the provided limit
+      // Slice the subpath to the limit
+      subpaths = subpaths.slice(0, limit);
+      // Add the size of the subpath
+      const limitedSize = getSubpathSize(subpaths);
+      const remainingSize = size - limitedSize;
+      subpaths.push({
+        path: OTHER_PATH_NAME,
+        size: remainingSize,
+        sizeWithReplica: (sizeWithReplica === -1)
+          ? -1
+          : sizeWithReplica - remainingSize,
+        isKey: false
+      })
+    }
+
+    if (subPathCount === 0 || subpaths.length === 0) {
+      // No more subpaths available
+      pathLabels = [path.split('/').pop() ?? ''];
+      valuesWithMinBlockSize = [0.1];
+      percentage = ['100.00'];
+      sizeStr = [byteToSize(size, 1)];
+    } else {
+      pathLabels = subpaths.map(subpath => {
+        const subpathName = subpath.path.split('/').pop() ?? '';
+        // Diferentiate keys by removing trailing slash
+        return (subpath.isKey || subpathName === OTHER_PATH_NAME)
+          ? subpathName
+          : subpathName + '/';
+      });
+
+      let values: number[] = [0];
+      if (size > 0) {
+        values = subpaths.map(
+          subpath => (subpath.size / size)
+        );
+      }
+      const valueClone = structuredClone(values);
+      valuesWithMinBlockSize = valueClone?.map(
+        (val: number) => (val > 0)
+          ? val + MIN_BLOCK_SIZE
+          : val
+      );
+
+      percentage = values.map(value => (value * 100).toFixed(2));
+      sizeStr = subpaths.map((subpath) => byteToSize(subpath.size, 1));
+    }
+
+    return valuesWithMinBlockSize.map((key, idx) => {
+      return {
+        value: key,
+        name: pathLabels[idx],
+        size: sizeStr[idx],
+        percentage: percentage[idx]
+      }
+    });
+  }
+
+  React.useEffect(() => {
+    setSubpathSize(getSubpathSize(subPaths));
+  }, [subPaths, limit]);
+
+  const pieData = React.useMemo(() => updatePieData(), [path, subPaths, 
limit]);
+
+  const eChartsOptions = {
+    title: {
+      text: `${byteToSize(subpathSize, 1)} /  ${byteToSize(size, 1)}`,
+      left: 'center',
+      top: '95%'
+    },
+    tooltip: {
+      trigger: 'item',
+      formatter: ({ dataIndex, name, color }) => {
+        const nameEl = `<strong style='color: ${color}'>${name}</strong><br>`;
+        const dataEl = `Total Data Size: ${pieData[dataIndex]['size']}<br>`
+        const percentageEl = `Percentage: ${pieData[dataIndex]['percentage']} 
%`
+        return `${nameEl}${dataEl}${percentageEl}`
+      }
+    },
+    legend: {
+      top: '10%',
+      orient: 'vertical',
+      left: '0%',
+      width: '80%'
+    },
+    grid: {
+
+    },
+    series: [
+      {
+        type: 'pie',
+        radius: '70%',
+        data: pieData.map((value) => {
+          return {
+            value: value.value,
+            name: value.name
+          }
+        }),
+        emphasis: {
+          itemStyle: {
+            shadowBlur: 10,
+            shadowOffsetX: 0,
+            shadowColor: 'rgba(0, 0, 0, 0.5)'
+          }
+        }
+      }
+    ]
+  };
+
+  const handleLegendChange = ({selected}: {selected: Record<string, boolean>}) 
=> {
+    const filteredPath = subPaths.filter((value) => {
+      // In case of any leading '/' remove them and add a / at end
+      // to make it similar to legend
+      const splitPath = value.path?.split('/');
+      const pathName = splitPath[splitPath.length - 1] ?? '' + ((value.isKey) 
? '' : '/');
+      return selected[pathName];
+    })
+    const newSize = getSubpathSize(filteredPath);
+    setSubpathSize(newSize);
+  }
+
+  return (
+    <EChart
+      loading={loading}
+      option={eChartsOptions}
+      style={{ flex: '0 1 90%', height: '50vh' }}
+      eventHandler={{name: 'legendselectchanged', handler: 
handleLegendChange}}/>
+  );
+}
+
+export default DUPieChart;
\ No newline at end of file
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/eChart/eChart.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/eChart/eChart.tsx
index 79fa076033..9d483efd6b 100644
--- 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/eChart/eChart.tsx
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/eChart/eChart.tsx
@@ -28,6 +28,10 @@ export interface EChartProps {
   loading?: boolean;
   theme?: 'light';
   onClick?: () => any | void;
+  eventHandler?: {
+    name: string,
+    handler: (arg0: any) => void
+  };
 }
 
 const EChart = ({
@@ -36,7 +40,8 @@ const EChart = ({
   settings,
   loading,
   theme,
-  onClick
+  onClick,
+  eventHandler
 }: EChartProps): JSX.Element => {
   const chartRef = useRef<HTMLDivElement>(null);
   useEffect(() => {
@@ -47,6 +52,10 @@ const EChart = ({
       if (onClick) {
         chart.on('click', onClick);
       }
+
+      if (eventHandler) {
+        chart.on(eventHandler.name, eventHandler.handler);
+      }
     }
 
     // Add chart resize listener
@@ -71,6 +80,10 @@ const EChart = ({
       if (onClick) {
         chart!.on('click', onClick);
       }
+
+      if (eventHandler) {
+        chart!.on(eventHandler.name, eventHandler.handler);
+      }
     }
   }, [option, settings, theme]); // Whenever theme changes we need to add 
option and setting due to it being deleted in cleanup function
 
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.less
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.less
new file mode 100644
index 0000000000..dca3861df1
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.less
@@ -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.
+*/
+
+.du-alert-message {
+  background-color: #FFFFFF;
+  border: unset;
+  box-shadow: -1px -2px 28px -14px rgba(0,0,0,0.68);
+  -webkit-box-shadow: -1px -2px 28px -14px rgba(0,0,0,0.68);
+  -moz-box-shadow: -1px -2px 28px -14px rgba(0,0,0,0.68);
+  margin-bottom: 20px;
+}
+
+.content-div {
+  min-height: unset;
+
+  .breadcrumb-nav {
+    font-size: 0.9vw;
+    margin: 0px 0px 8px 8px;
+    .breadcrumb-nav-item {
+      background: transparent;
+      border: none !important;
+      cursor: pointer;
+      color: @primary-color;
+      
+    }
+  }
+
+  .du-table-header-section {
+    display: flex;
+    align-items: flex-end;
+    flex-direction: column;
+  }
+
+  .du-content {
+    width: 100%;
+    display: flex;
+  }
+
+  .tag-block {
+    display: flex;
+    column-gap: 8px;
+    padding: 0px 8px 16px 8px;
+  }
+}
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.tsx
new file mode 100644
index 0000000000..1e92780619
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.tsx
@@ -0,0 +1,144 @@
+/*
+ * 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, { useRef, useState } from 'react';
+import { AxiosError } from 'axios';
+import {
+  Alert
+} from 'antd';
+import {
+  InfoCircleFilled
+} from '@ant-design/icons';
+import { ValueType } from 'react-select';
+
+import DUMetadata from '@/v2/components/duMetadata/duMetadata';
+import DUPieChart from '@/v2/components/duPieChart/duPieChart';
+import SingleSelect, { Option } from '@/v2/components/select/singleSelect';
+import DUBreadcrumbNav from '@/v2/components/duBreadcrumbNav/duBreadcrumbNav';
+import { showDataFetchError } from '@/utils/common';
+import { AxiosGetHelper, cancelRequests } from '@/utils/axiosRequestHelper';
+
+import { DUResponse } from '@/v2/types/diskUsage.types';
+
+import './diskUsage.less';
+
+const LIMIT_OPTIONS: Option[] = [
+  { label: '5', value: '5' },
+  { label: '10', value: '10' },
+  { label: '15', value: '15' },
+  { label: '20', value: '20' },
+  { label: '30', value: '30' }
+]
+
+const DiskUsage: React.FC<{}> = () => {
+  const [loading, setLoading] = useState<boolean>(false);
+  const [limit, setLimit] = useState<Option>(LIMIT_OPTIONS[1]);
+  const [duResponse, setDUResponse] = useState<DUResponse>({
+    status: '',
+    path: '/',
+    subPathCount: 0,
+    size: 0,
+    sizeWithReplica: 0,
+    subPaths: [],
+    sizeDirectKey: 0
+  });
+
+  const cancelPieSignal = useRef<AbortController>();
+
+  function loadData(path: string) {
+    setLoading(true);
+    const { request, controller } = AxiosGetHelper(
+      `/api/v1/namespace/du?path=${path}&files=true&sortSubPaths=true`,
+      cancelPieSignal.current
+    );
+    cancelPieSignal.current = controller;
+
+    request.then(response => {
+      const duResponse: DUResponse = response.data;
+      const status = duResponse.status;
+      if (status === 'PATH_NOT_FOUND') {
+        setLoading(false);
+        showDataFetchError(`Invalid Path: ${path}`);
+        return;
+      }
+
+      setDUResponse(duResponse);
+      setLoading(false);
+    }).catch(error => {
+      setLoading(false);
+      showDataFetchError((error as AxiosError).toString());
+    });
+  }
+
+  function handleLimitChange(selected: ValueType<Option, false>) {
+    setLimit(selected as Option);
+  }
+
+  React.useEffect(() => {
+    //On mount load default data
+    loadData(duResponse.path)
+
+    return (() => {
+      cancelRequests([cancelPieSignal.current!]);
+    })
+  }, []);
+
+  return (
+    <>
+      <div className='page-header-v2'>
+        Disk Usage
+      </div>
+      <div style={{ padding: '24px' }}>
+        <Alert
+          className='du-alert-message'
+          message="Additional block size is added to small entities, for 
better visibility.
+            Please refer to pie-chart details for exact size information."
+          type="info"
+          icon={<InfoCircleFilled />}
+          showIcon={true}
+          closable={false} />
+        <div className='content-div'>
+          <DUBreadcrumbNav
+            path={duResponse.path}
+            subPaths={duResponse.subPaths}
+            updateHandler={loadData} />
+          <div className='du-table-header-section'>
+            <SingleSelect
+              options={LIMIT_OPTIONS}
+              defaultValue={limit}
+              placeholder='Limit'
+              onChange={handleLimitChange} />
+          </div>
+          <div className='du-content'>
+            <DUPieChart
+              loading={loading}
+              limit={Number.parseInt(limit.value)}
+              path={duResponse.path}
+              subPathCount={duResponse.subPathCount}
+              subPaths={duResponse.subPaths}
+              sizeWithReplica={duResponse.sizeWithReplica}
+              size={duResponse.size} />
+            <DUMetadata path={duResponse.path} />
+          </div>
+        </div>
+      </div>
+    </>
+  );
+}
+
+export default DiskUsage;
\ No newline at end of file
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 0aea9e8040..20907fd3ad 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
@@ -22,6 +22,7 @@ const Volumes = lazy(() => 
import('@/v2/pages/volumes/volumes'))
 const Buckets = lazy(() => import('@/v2/pages/buckets/buckets'));
 const Datanodes = lazy(() => import('@/v2/pages/datanodes/datanodes'));
 const Pipelines = lazy(() => import('@/v2/pages/pipelines/pipelines'));
+const DiskUsage = lazy(() => import('@/v2/pages/diskUsage/diskUsage'));
 
 export const routesV2 = [
   {
@@ -43,5 +44,9 @@ export const routesV2 = [
   {
     path: '/Pipelines',
     component: Pipelines
+  },
+  {
+    path: '/DiskUsage',
+    component: DiskUsage
   }
 ];
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/types/diskUsage.types.ts
similarity index 54%
copy from 
hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/routes-v2.tsx
copy to 
hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/diskUsage.types.ts
index 0aea9e8040..e649c143ae 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/types/diskUsage.types.ts
@@ -15,33 +15,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { lazy } from 'react';
 
-const Overview = lazy(() => import('@/v2/pages/overview/overview'));
-const Volumes = lazy(() => import('@/v2/pages/volumes/volumes'))
-const Buckets = lazy(() => import('@/v2/pages/buckets/buckets'));
-const Datanodes = lazy(() => import('@/v2/pages/datanodes/datanodes'));
-const Pipelines = lazy(() => import('@/v2/pages/pipelines/pipelines'));
+export type DUSubpath = {
+  path: string;
+  size: number;
+  sizeWithReplica: number;
+  isKey: boolean;
+}
 
-export const routesV2 = [
-  {
-    path: '/Overview',
-    component: Overview
-  },
-  {
-    path: '/Volumes',
-    component: Volumes
-  },
-  {
-    path: '/Buckets',
-    component: Buckets
-  },
-  {
-    path: '/Datanodes',
-    component: Datanodes
-  },
-  {
-    path: '/Pipelines',
-    component: Pipelines
-  }
-];
+export type DUResponse = {
+  status: string;
+  path: string;
+  subPathCount: number;
+  size: number;
+  sizeWithReplica: number;
+  subPaths: DUSubpath[];
+  sizeDirectKey: number;
+}
+
+export type PlotData = {
+  value: number;
+  name: string;
+  size: string;
+  percentage: string;
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to