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 70b8dd5ea3 HDDS-11157. Improve Datanodes page UI (#7168)
70b8dd5ea3 is described below

commit 70b8dd5ea3b433c2f1b7f8816b269a71d9a2466e
Author: Abhishek Pal <[email protected]>
AuthorDate: Thu Sep 19 15:10:45 2024 +0530

    HDDS-11157. Improve Datanodes page UI (#7168)
---
 .../src/utils/axiosRequestHelper.tsx               |   2 +-
 .../decommissioningSummary.tsx                     | 139 +++++++++
 .../src/v2/components/storageBar/storageBar.less   |  45 +++
 .../src/v2/components/storageBar/storageBar.tsx    |  49 ++--
 .../src/v2/components/tables/datanodesTable.tsx    | 314 +++++++++++++++++++++
 .../src/v2/pages/buckets/buckets.tsx               |  14 +-
 .../src/v2/pages/datanodes/datanodes.less          |  52 ++++
 .../src/v2/pages/datanodes/datanodes.tsx           | 309 ++++++++++++++++++++
 .../recon/ozone-recon-web/src/v2/routes-v2.tsx     |   5 +
 .../ozone-recon-web/src/v2/types/datanode.types.ts | 167 +++++++++++
 10 files changed, 1064 insertions(+), 32 deletions(-)

diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/axiosRequestHelper.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/axiosRequestHelper.tsx
index 8fbe403dc3..53a76d83f1 100644
--- 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/axiosRequestHelper.tsx
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/axiosRequestHelper.tsx
@@ -37,7 +37,7 @@ export const AxiosGetHelper = (
 export const AxiosPutHelper = (
   url: string,
   data: any = {},
-  controller: AbortController,
+  controller: AbortController | undefined,
   message: string = '',  //optional
 ): { request: Promise<AxiosResponse<any, any>>; controller: AbortController } 
=> {
   controller && controller.abort(message);
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/decommissioningSummary/decommissioningSummary.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/decommissioningSummary/decommissioningSummary.tsx
new file mode 100644
index 0000000000..34e72b0889
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/decommissioningSummary/decommissioningSummary.tsx
@@ -0,0 +1,139 @@
+/*
+ * 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, { useEffect } from 'react';
+import { AxiosError } from 'axios';
+import { Descriptions, Popover, Result } from 'antd';
+import { SummaryData } from '@/v2/types/datanode.types';
+import { AxiosGetHelper, cancelRequests } from '@/utils/axiosRequestHelper';
+import { showDataFetchError } from '@/utils/common';
+import Spin from 'antd/es/spin';
+
+type DecommisioningSummaryProps = {
+  uuid: string;
+}
+
+type DecommisioningSummaryState = {
+  loading: boolean;
+  summaryData: SummaryData | Record<string, unknown>;
+};
+
+function getDescriptions(summaryData: SummaryData): React.ReactElement {
+  const {
+    datanodeDetails: {
+      uuid,
+      networkLocation,
+      ipAddress,
+      hostName
+    },
+    containers: { UnderReplicated, UnClosed },
+    metrics: {
+      decommissionStartTime,
+      numOfUnclosedPipelines,
+      numOfUnclosedContainers,
+      numOfUnderReplicatedContainers
+    }
+  } = summaryData;
+  return (
+    <Descriptions size="small" bordered column={1} title={`Decommission 
Status: DECOMMISSIONING`}>
+      <Descriptions.Item label="Datanode"> <b>{uuid}</b></Descriptions.Item>
+      <Descriptions.Item 
label="Location">({networkLocation}/{ipAddress}/{hostName})</Descriptions.Item>
+      <Descriptions.Item label="Decommissioning Started 
at">{decommissionStartTime}</Descriptions.Item>
+      <Descriptions.Item label="No. of Unclosed 
Pipelines">{numOfUnclosedPipelines}</Descriptions.Item>
+      <Descriptions.Item label="No. of Unclosed 
Containers">{numOfUnclosedContainers}</Descriptions.Item>
+      <Descriptions.Item label="No. of Under-Replicated 
Containers">{numOfUnderReplicatedContainers}</Descriptions.Item>
+      <Descriptions.Item 
label="Under-Replicated">{UnderReplicated}</Descriptions.Item>
+      <Descriptions.Item label="Unclosed">{UnClosed}</Descriptions.Item>
+    </Descriptions>
+  );
+}
+
+
+const DecommissionSummary: React.FC<DecommisioningSummaryProps> = ({
+  uuid = ''
+}) => {
+  const [state, setState] = React.useState<DecommisioningSummaryState>({
+    summaryData: {},
+    loading: false
+  });
+  const cancelSignal = React.useRef<AbortController>();
+  let content = (
+    <Spin
+      size='large'
+      style={{ margin: '15px 15px 10px 15px' }} />
+  );
+
+  async function fetchDecommissionSummary(selectedUuid: string) {
+    setState({
+      ...state,
+      loading: true
+    });
+    try {
+      const { request, controller } = AxiosGetHelper(
+        `/api/v1/datanodes/decommission/info/datanode?uuid=${selectedUuid}`,
+        cancelSignal.current
+      );
+      cancelSignal.current = controller;
+      const datanodesInfoResponse = await request;
+      setState({
+        ...state,
+        loading: false,
+        summaryData: datanodesInfoResponse?.data?.DatanodesDecommissionInfo[0] 
?? {}
+      });
+    } catch (error) {
+      setState({
+        ...state,
+        loading: false,
+        summaryData: {}
+      });
+      showDataFetchError((error as AxiosError).toString());
+      content = (
+        <Result
+          status='error'
+          title='Unable to fetch Decommission Summary data'
+          className='decommission-summary-result' />
+      )
+    }
+  }
+
+  useEffect(() => {
+    fetchDecommissionSummary(uuid);
+    return (() => {
+      cancelRequests([cancelSignal.current!]);
+    })
+  }, []);
+
+  const { summaryData } = state;
+  if (summaryData?.datanodeDetails
+      && summaryData?.metrics
+      && summaryData?.containers
+  ) {
+    content = getDescriptions(summaryData as SummaryData);
+  }
+
+  return (
+    <Popover
+      content={content}
+      placement="rightTop" trigger="hover">
+      &nbsp;{uuid}
+    </Popover>
+  );
+
+}
+
+export default DecommissionSummary;
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/storageBar/storageBar.less
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/storageBar/storageBar.less
new file mode 100644
index 0000000000..798287366c
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/storageBar/storageBar.less
@@ -0,0 +1,45 @@
+/*
+* 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.
+*/
+
+@progress-gray: #d0d0d0;
+@progress-light-blue: rgb(230, 235, 248);
+@progress-blue: #1890ff;
+@progress-green: #52c41a;
+@progress-red: #FFA39E;
+
+.storage-cell-container-v2 {
+  .capacity-bar-v2 {
+    font-size: 1em;
+  }
+}
+
+.ozone-used-bg-v2 {
+  color: @progress-green !important;
+}
+
+.non-ozone-used-bg-v2 {
+  color: @progress-blue !important;
+}
+
+.remaining-bg-v2 {
+  color: @progress-light-blue !important;
+}
+
+.committed-bg-v2 {
+  color: @progress-red !important;
+}
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/storageBar/storageBar.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/storageBar/storageBar.tsx
index 591b0088b0..fd6dd8dfe9 100644
--- 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/storageBar/storageBar.tsx
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/storageBar/storageBar.tsx
@@ -20,72 +20,73 @@ import React from 'react';
 import { Progress } from 'antd';
 import filesize from 'filesize';
 import Icon from '@ant-design/icons';
-import { withRouter } from 'react-router-dom';
 import Tooltip from 'antd/lib/tooltip';
 
 import { FilledIcon } from '@/utils/themeIcons';
 import { getCapacityPercent } from '@/utils/common';
 import type { StorageReport } from '@/v2/types/overview.types';
 
+import './storageBar.less';
+
 const size = filesize.partial({
   standard: 'iec',
   round: 1
 });
 
 type StorageReportProps = {
-  showMeta: boolean;
+  showMeta?: boolean;
+  strokeWidth?: number;
 } & StorageReport
 
 
-const StorageBar = (props: StorageReportProps = {
-  capacity: 0,
-  used: 0,
-  remaining: 0,
-  committed: 0,
-  showMeta: true,
+const StorageBar: React.FC<StorageReportProps> = ({
+  capacity = 0,
+  used = 0,
+  remaining = 0,
+  committed = 0,
+  showMeta = false,
+  strokeWidth = 3
 }) => {
-  const { capacity, used, remaining, committed, showMeta } = props;
 
   const nonOzoneUsed = capacity - remaining - used;
   const totalUsed = capacity - remaining;
   const tooltip = (
     <>
       <div>
-        <Icon component={FilledIcon} className='ozone-used-bg' />
+        <Icon component={FilledIcon} className='ozone-used-bg-v2' />
         Ozone Used ({size(used)})
       </div>
       <div>
-        <Icon component={FilledIcon} className='non-ozone-used-bg' />
+        <Icon component={FilledIcon} className='non-ozone-used-bg-v2' />
         Non Ozone Used ({size(nonOzoneUsed)})
       </div>
       <div>
-        <Icon component={FilledIcon} className='remaining-bg' />
+        <Icon component={FilledIcon} className='remaining-bg-v2' />
         Remaining ({size(remaining)})
       </div>
       <div>
-        <Icon component={FilledIcon} className='committed-bg' />
+        <Icon component={FilledIcon} className='committed-bg-v2' />
         Container Pre-allocated ({size(committed)})
       </div>
     </>
   );
-  const metaElement = (showMeta) ? (
-    <div>
-      {size(used + nonOzoneUsed)} / {size(capacity)}
-    </div>
-  ) : <></>;
-
 
   return (
-    <div className='storage-cell-container'>
-      <Tooltip title={tooltip} placement='bottomLeft'>
-        {metaElement}
+      <Tooltip
+        title={tooltip}
+        placement='bottomLeft'
+        className='storage-cell-container-v2' >
+        {(showMeta) &&
+          <div>
+            {size(used + nonOzoneUsed)} / {size(capacity)}
+          </div>
+        }
         <Progress
           strokeLinecap='round'
           percent={getCapacityPercent(totalUsed, capacity)}
           success={{ percent: getCapacityPercent(used, capacity) }}
-          className='capacity-bar' strokeWidth={3} />
+          className='capacity-bar-v2' strokeWidth={strokeWidth} />
       </Tooltip>
-    </div>
   );
 }
 
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/datanodesTable.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/datanodesTable.tsx
new file mode 100644
index 0000000000..494d898509
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/datanodesTable.tsx
@@ -0,0 +1,314 @@
+/*
+ * 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 moment from 'moment';
+import { Popover, Tooltip } from 'antd'
+import {
+  CheckCircleFilled,
+  CloseCircleFilled,
+  HourglassFilled,
+  InfoCircleOutlined,
+  WarningFilled
+} from '@ant-design/icons';
+import Table, {
+  ColumnsType,
+  TablePaginationConfig
+} from 'antd/es/table';
+import { TableRowSelection } from 'antd/es/table/interface';
+
+import StorageBar from '@/v2/components/storageBar/storageBar';
+import DecommissionSummary from 
'@/v2/components/decommissioningSummary/decommissioningSummary';
+
+import { ReplicationIcon } from '@/utils/themeIcons';
+import { getTimeDiffFromTimestamp } from '@/v2/utils/momentUtils';
+
+import {
+  Datanode,
+  DatanodeOpState,
+  DatanodeOpStateList,
+  DatanodeState,
+  DatanodeStateList,
+  DatanodeTableProps
+} from '@/v2/types/datanode.types';
+import { Pipeline } from '@/v2/types/pipelines.types';
+
+
+let decommissioningUuids: string | string[] = [];
+
+const headerIconStyles: React.CSSProperties = {
+  display: 'flex',
+  alignItems: 'center'
+}
+
+const renderDatanodeState = (state: DatanodeState) => {
+  const stateIconMap = {
+    HEALTHY: <CheckCircleFilled twoToneColor='#1da57a' 
className='icon-success' />,
+    STALE: <HourglassFilled className='icon-warning' />,
+    DEAD: <CloseCircleFilled className='icon-failure' />
+  };
+  const icon = state in stateIconMap ? stateIconMap[state] : '';
+  return <span>{icon} {state}</span>;
+};
+
+const renderDatanodeOpState = (opState: DatanodeOpState) => {
+  const opStateIconMap = {
+    IN_SERVICE: <CheckCircleFilled twoToneColor='#1da57a' 
className='icon-success' />,
+    DECOMMISSIONING: <HourglassFilled className='icon-warning' />,
+    DECOMMISSIONED: <WarningFilled className='icon-warning' />,
+    ENTERING_MAINTENANCE: <HourglassFilled className='icon-warning' />,
+    IN_MAINTENANCE: <WarningFilled className='icon-warning' />
+  };
+  const icon = opState in opStateIconMap ? opStateIconMap[opState] : '';
+  return <span>{icon} {opState}</span>;
+};
+
+export const COLUMNS: ColumnsType<Datanode> = [
+  {
+    title: 'Hostname',
+    dataIndex: 'hostname',
+    key: 'hostname',
+    sorter: (a: Datanode, b: Datanode) => a.hostname.localeCompare(
+      b.hostname, undefined, { numeric: true }
+    ),
+    defaultSortOrder: 'ascend' as const
+  },
+  {
+    title: 'State',
+    dataIndex: 'state',
+    key: 'state',
+    filterMultiple: true,
+    filters: DatanodeStateList.map(state => ({ text: state, value: state })),
+    onFilter: (value, record: Datanode) => record.state === value,
+    render: (text: DatanodeState) => renderDatanodeState(text),
+    sorter: (a: Datanode, b: Datanode) => a.state.localeCompare(b.state)
+  },
+  {
+    title: 'Operational State',
+    dataIndex: 'opState',
+    key: 'opState',
+    filterMultiple: true,
+    filters: DatanodeOpStateList.map(state => ({ text: state, value: state })),
+    onFilter: (value, record: Datanode) => record.opState === value,
+    render: (text: DatanodeOpState) => renderDatanodeOpState(text),
+    sorter: (a: Datanode, b: Datanode) => a.opState.localeCompare(b.opState)
+  },
+  {
+    title: 'UUID',
+    dataIndex: 'uuid',
+    key: 'uuid',
+    sorter: (a: Datanode, b: Datanode) => a.uuid.localeCompare(b.uuid),
+    defaultSortOrder: 'ascend' as const,
+    render: (uuid: string, record: Datanode) => {
+      return (
+        //1. Compare Decommission Api's UUID with all UUID in table and show 
Decommission Summary
+        (decommissioningUuids && decommissioningUuids.includes(record.uuid) && 
record.opState !== 'DECOMMISSIONED') ?
+          <DecommissionSummary uuid={uuid} /> : <span>{uuid}</span>
+      );
+    }
+  },
+  {
+    title: 'Storage Capacity',
+    dataIndex: 'storageUsed',
+    key: 'storageUsed',
+    sorter: (a: Datanode, b: Datanode) => a.storageRemaining - 
b.storageRemaining,
+    render: (_: string, record: Datanode) => (
+      <StorageBar
+        strokeWidth={6}
+        capacity={record.storageTotal}
+        used={record.storageUsed}
+        remaining={record.storageRemaining}
+        committed={record.storageCommitted} />
+    )
+  },
+  {
+    title: 'Last Heartbeat',
+    dataIndex: 'lastHeartbeat',
+    key: 'lastHeartbeat',
+    sorter: (a: Datanode, b: Datanode) => moment(a.lastHeartbeat).unix() - 
moment(b.lastHeartbeat).unix(),
+    render: (heartbeat: number) => {
+      return heartbeat > 0 ? getTimeDiffFromTimestamp(heartbeat) : 'NA';
+    }
+  },
+  {
+    title: 'Pipeline ID(s)',
+    dataIndex: 'pipelines',
+    key: 'pipelines',
+    render: (pipelines: Pipeline[], record: Datanode) => {
+      const renderPipelineIds = (pipelineIds: Pipeline[]) => {
+        return pipelineIds?.map((pipeline: any, index: any) => (
+          <div key={index} className='pipeline-container-v2'>
+            <ReplicationIcon
+              replicationFactor={pipeline.replicationFactor}
+              replicationType={pipeline.replicationType}
+              leaderNode={pipeline.leaderNode}
+              isLeader={pipeline.leaderNode === record.hostname} />
+            {pipeline.pipelineID}
+          </div >
+        ))
+      }
+
+      return (
+        <Popover
+          content={
+            renderPipelineIds(pipelines)
+          }
+          title="Related Pipelines"
+          placement="bottomLeft"
+          trigger="hover">
+          <strong>{pipelines.length}</strong> pipelines
+        </Popover>
+      );
+    }
+  },
+  {
+    title: () => (
+      <span style={headerIconStyles} >
+        Leader Count
+        <Tooltip
+          title='The number of Ratis Pipelines in which the given datanode is 
elected as a leader.' >
+          <InfoCircleOutlined style={{ paddingLeft: '4px' }} />
+        </Tooltip>
+      </span>
+    ),
+    dataIndex: 'leaderCount',
+    key: 'leaderCount',
+    sorter: (a: Datanode, b: Datanode) => a.leaderCount - b.leaderCount
+  },
+  {
+    title: 'Containers',
+    dataIndex: 'containers',
+    key: 'containers',
+    sorter: (a: Datanode, b: Datanode) => a.containers - b.containers
+  },
+  {
+    title: () => (
+      <span style={headerIconStyles}>
+        Open Container
+        <Tooltip title='The number of open containers per pipeline.'>
+          <InfoCircleOutlined style={{ paddingLeft: '4px' }} />
+        </Tooltip>
+      </span>
+    ),
+    dataIndex: 'openContainers',
+    key: 'openContainers',
+    sorter: (a: Datanode, b: Datanode) => a.openContainers - b.openContainers
+  },
+  {
+    title: 'Version',
+    dataIndex: 'version',
+    key: 'version',
+    sorter: (a: Datanode, b: Datanode) => a.version.localeCompare(b.version),
+    defaultSortOrder: 'ascend' as const
+  },
+  {
+    title: 'Setup Time',
+    dataIndex: 'setupTime',
+    key: 'setupTime',
+    sorter: (a: Datanode, b: Datanode) => a.setupTime - b.setupTime,
+    render: (uptime: number) => {
+      return uptime > 0 ? moment(uptime).format('ll LTS') : 'NA';
+    }
+  },
+  {
+    title: 'Revision',
+    dataIndex: 'revision',
+    key: 'revision',
+    sorter: (a: Datanode, b: Datanode) => a.revision.localeCompare(b.revision),
+    defaultSortOrder: 'ascend' as const
+  },
+  {
+    title: 'Build Date',
+    dataIndex: 'buildDate',
+    key: 'buildDate',
+    sorter: (a: Datanode, b: Datanode) => 
a.buildDate.localeCompare(b.buildDate),
+    defaultSortOrder: 'ascend' as const
+  },
+  {
+    title: 'Network Location',
+    dataIndex: 'networkLocation',
+    key: 'networkLocation',
+    sorter: (a: Datanode, b: Datanode) => 
a.networkLocation.localeCompare(b.networkLocation),
+    defaultSortOrder: 'ascend' as const
+  }
+];
+
+const DatanodesTable: React.FC<DatanodeTableProps> = ({
+  data,
+  handleSelectionChange,
+  decommissionUuids,
+  selectedColumns,
+  loading = false,
+  selectedRows = [],
+  searchColumn = 'hostname',
+  searchTerm = ''
+}) => {
+
+  function filterSelectedColumns() {
+    const columnKeys = selectedColumns.map((column) => column.value);
+    return COLUMNS.filter(
+      (column) => columnKeys.indexOf(column.key as string) >= 0
+    );
+  }
+
+  function getFilteredData(data: Datanode[]) {
+    return data?.filter(
+      (datanode: Datanode) => datanode[searchColumn].includes(searchTerm)
+    ) ?? [];
+  }
+
+  function isSelectable(record: Datanode) {
+    // Disable checkbox for any datanode which is not DEAD to prevent removal
+    return record.state !== 'DEAD' && true;
+  }
+
+  const paginationConfig: TablePaginationConfig = {
+    showTotal: (total: number, range) => (
+      `${range[0]}-${range[1]} of ${total} Datanodes`
+    ),
+    showSizeChanger: true
+  };
+
+  const rowSelection: TableRowSelection<Datanode> = {
+    selectedRowKeys: selectedRows,
+    onChange: (rows: React.Key[]) => { handleSelectionChange(rows) },
+    getCheckboxProps: (record: Datanode) => ({
+      disabled: isSelectable(record)
+    }),
+  };
+
+  React.useEffect(() => {
+    decommissioningUuids = decommissionUuids;
+  }, [decommissionUuids])
+
+  return (
+    <div>
+      <Table
+        rowSelection={rowSelection}
+        dataSource={getFilteredData(data)}
+        columns={filterSelectedColumns()}
+        loading={loading}
+        rowKey='uuid'
+        pagination={paginationConfig}
+        scroll={{ x: 'max-content', scrollToFirstRowOnChange: true }}
+        locale={{ filterTitle: '' }} />
+    </div>
+  );
+}
+
+export default DatanodesTable;
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/buckets/buckets.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/buckets/buckets.tsx
index 12af3bb428..1e2de307b1 100644
--- 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/buckets/buckets.tsx
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/buckets/buckets.tsx
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
 import moment from 'moment';
 import { ValueType } from 'react-select';
 import { useLocation } from 'react-router-dom';
@@ -28,7 +28,7 @@ import MultiSelect from '@/v2/components/select/multiSelect';
 import SingleSelect, { Option } from '@/v2/components/select/singleSelect';
 
 import { AutoReloadHelper } from '@/utils/autoReloadHelper';
-import { AxiosGetHelper } from "@/utils/axiosRequestHelper";
+import { AxiosGetHelper, cancelRequests } from "@/utils/axiosRequestHelper";
 import { showDataFetchError } from '@/utils/common';
 import { useDebounce } from '@/v2/hooks/debounce.hook';
 
@@ -111,7 +111,7 @@ function getFilteredBuckets(
 
 const Buckets: React.FC<{}> = () => {
 
-  let cancelSignal: AbortController;
+  const cancelSignal = useRef<AbortController>();
 
   const [state, setState] = useState<BucketsState>({
     totalCount: 0,
@@ -170,11 +170,11 @@ const Buckets: React.FC<{}> = () => {
     setLoading(true);
     const { request, controller } = AxiosGetHelper(
       '/api/v1/buckets',
-      cancelSignal,
+      cancelSignal.current,
       '',
       { limit: selectedLimit.value }
     );
-    cancelSignal = controller;
+    cancelSignal.current = controller;
     request.then(response => {
       const bucketsResponse: BucketResponse = response.data;
       const totalCount = bucketsResponse.totalCount;
@@ -230,7 +230,7 @@ const Buckets: React.FC<{}> = () => {
     });
   }
 
-  let autoReloadHelper: AutoReloadHelper = new AutoReloadHelper(loadData);
+  const autoReloadHelper: AutoReloadHelper = new AutoReloadHelper(loadData);
 
   useEffect(() => {
     autoReloadHelper.startPolling();
@@ -245,7 +245,7 @@ const Buckets: React.FC<{}> = () => {
 
     return (() => {
       autoReloadHelper.stopPolling();
-      cancelSignal && cancelSignal.abort();
+      cancelRequests([cancelSignal.current!]);
     })
   }, []);
 
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/datanodes/datanodes.less
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/datanodes/datanodes.less
new file mode 100644
index 0000000000..a1eee38521
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/datanodes/datanodes.less
@@ -0,0 +1,52 @@
+/*
+* 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.
+*/
+
+.content-div {
+  min-height: unset;
+
+  .table-header-section {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    .table-filter-section {
+      font-size: 14px;
+      font-weight: normal;
+      display: flex;
+      column-gap: 8px;
+      padding: 16px 8px;
+      align-items: center;
+    }
+  }
+
+  .tag-block {
+    display: flex;
+    column-gap: 8px;
+    padding: 0px 8px 16px 8px;
+  }
+}
+
+.pipeline-container-v2 {
+ padding: 6px 0px; 
+}
+
+.decommission-summary-result {
+  .ant-result-title {
+    font-size: 15px;
+  }
+}
\ No newline at end of file
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/datanodes/datanodes.tsx
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/datanodes/datanodes.tsx
new file mode 100644
index 0000000000..13022dc05e
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/datanodes/datanodes.tsx
@@ -0,0 +1,309 @@
+/*
+ * 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, {
+  useEffect,
+  useRef,
+  useState
+} from 'react';
+import moment from 'moment';
+import { AxiosError } from 'axios';
+import {
+  Button,
+  Modal
+} from 'antd';
+import {
+  DeleteOutlined,
+  WarningFilled,
+} from '@ant-design/icons';
+import { ValueType } from 'react-select';
+
+import Search from '@/v2/components/search/search';
+import MultiSelect, { Option } from '@/v2/components/select/multiSelect';
+import DatanodesTable, { COLUMNS } from 
'@/v2/components/tables/datanodesTable';
+import AutoReloadPanel from '@/components/autoReloadPanel/autoReloadPanel';
+import { showDataFetchError } from '@/utils/common';
+import { AutoReloadHelper } from '@/utils/autoReloadHelper';
+import {
+  AxiosGetHelper,
+  AxiosPutHelper,
+  cancelRequests
+} from '@/utils/axiosRequestHelper';
+
+import { useDebounce } from '@/v2/hooks/debounce.hook';
+import {
+  Datanode,
+  DatanodeDecomissionInfo,
+  DatanodeResponse,
+  DatanodesResponse,
+  DatanodesState
+} from '@/v2/types/datanode.types';
+
+import './datanodes.less'
+
+
+const defaultColumns = COLUMNS.map(column => ({
+  label: (typeof column.title === 'string')
+    ? column.title
+    : (column.title as Function)().props.children[0],
+  value: column.key as string
+}));
+
+const SearchableColumnOpts = [{
+  label: 'Hostname',
+  value: 'hostname'
+}, {
+  label: 'UUID',
+  value: 'uuid'
+}, {
+  label: 'Version',
+  value: 'version'
+}];
+
+let decommissionUuids: string | string[] = [];
+const COLUMN_UPDATE_DECOMMISSIONING = 'DECOMMISSIONING';
+
+const Datanodes: React.FC<{}> = () => {
+
+  const cancelSignal = useRef<AbortController>();
+  const cancelDecommissionSignal = useRef<AbortController>();
+
+  const [state, setState] = useState<DatanodesState>({
+    lastUpdated: 0,
+    columnOptions: defaultColumns,
+    dataSource: []
+  });
+  const [loading, setLoading] = useState<boolean>(false);
+  const [selectedColumns, setSelectedColumns] = 
useState<Option[]>(defaultColumns);
+  const [selectedRows, setSelectedRows] = useState<React.Key[]>([]);
+  const [searchTerm, setSearchTerm] = useState<string>('');
+  const [searchColumn, setSearchColumn] = useState<'hostname' | 'uuid' | 
'version' | 'revision'>('hostname');
+  const [modalOpen, setModalOpen] = useState<boolean>(false);
+
+  const debouncedSearch = useDebounce(searchTerm, 300);
+
+  function handleColumnChange(selected: ValueType<Option, true>) {
+    setSelectedColumns(selected as Option[]);
+  }
+
+  async function loadDecommisionAPI() {
+    decommissionUuids = [];
+    const { request, controller } = await AxiosGetHelper(
+      '/api/v1/datanodes/decommission/info',
+      cancelDecommissionSignal.current
+    );
+    cancelDecommissionSignal.current = controller;
+    return request
+  };
+
+  async function loadDataNodeAPI() {
+    const { request, controller } = await AxiosGetHelper(
+      '/api/v1/datanodes',
+      cancelSignal.current
+    );
+    cancelSignal.current = controller;
+    return request;
+  };
+
+  async function removeDatanode(selectedRowKeys: string[]) {
+    setLoading(true);
+    const { request, controller } = await AxiosPutHelper(
+      '/api/v1/datanodes/remove',
+      selectedRowKeys,
+      cancelSignal.current
+    );
+    cancelSignal.current = controller;
+    request.then(() => {
+      loadData();
+    }).catch((error) => {
+      showDataFetchError(error.toString());
+    }).finally(() => {
+      setLoading(false);
+      setSelectedRows([]);
+    });
+  }
+
+  const loadData = async () => {
+    setLoading(true);
+    // Need to call decommission API on each interval to get updated status
+    // before datanode API call to compare UUID's
+    // update 'Operation State' column in table manually before rendering
+    try {
+      let decomissionResponse = await loadDecommisionAPI();
+      decommissionUuids = 
decomissionResponse.data?.DatanodesDecommissionInfo?.map(
+        (item: DatanodeDecomissionInfo) => item.datanodeDetails.uuid
+      );
+    } catch (error) {
+      decommissionUuids = [];
+      showDataFetchError((error as AxiosError).toString());
+    }
+
+    try {
+      const datanodesAPIResponse = await loadDataNodeAPI();
+      const datanodesResponse: DatanodesResponse = datanodesAPIResponse.data;
+      const datanodes: DatanodeResponse[] = datanodesResponse.datanodes;
+      const dataSource: Datanode[] = datanodes?.map(
+        (datanode) => ({
+          hostname: datanode.hostname,
+          uuid: datanode.uuid,
+          state: datanode.state,
+          opState: (decommissionUuids?.includes(datanode.uuid) && 
datanode.opState !== 'DECOMMISSIONED')
+            ? COLUMN_UPDATE_DECOMMISSIONING
+            : datanode.opState,
+          lastHeartbeat: datanode.lastHeartbeat,
+          storageUsed: datanode.storageReport.used,
+          storageTotal: datanode.storageReport.capacity,
+          storageCommitted: datanode.storageReport.committed,
+          storageRemaining: datanode.storageReport.remaining,
+          pipelines: datanode.pipelines,
+          containers: datanode.containers,
+          openContainers: datanode.openContainers,
+          leaderCount: datanode.leaderCount,
+          version: datanode.version,
+          setupTime: datanode.setupTime,
+          revision: datanode.revision,
+          buildDate: datanode.buildDate,
+          networkLocation: datanode.networkLocation
+        })
+      );
+      setLoading(false);
+      setState({
+        ...state,
+        dataSource: dataSource,
+        lastUpdated: Number(moment())
+      });
+    } catch (error) {
+      setLoading(false);
+      showDataFetchError((error as AxiosError).toString())
+    }
+  }
+
+  const autoReloadHelper: AutoReloadHelper = new AutoReloadHelper(loadData);
+
+  useEffect(() => {
+    autoReloadHelper.startPolling();
+    loadData();
+
+    return (() => {
+      autoReloadHelper.stopPolling();
+      cancelRequests([
+        cancelSignal.current!,
+        cancelDecommissionSignal.current!
+      ]);
+    });
+  }, []);
+
+  function handleSelectionChange(rows: React.Key[]) {
+    setSelectedRows(rows);
+  }
+
+  function handleModalOk() {
+    setModalOpen(false);
+    removeDatanode(selectedRows as string[])
+  };
+
+  function handleModalCancel() {
+    setModalOpen(false);
+    setSelectedRows([]);
+  };
+
+  const { dataSource, lastUpdated, columnOptions } = state;
+
+  return (
+    <>
+      <div className='page-header-v2'>
+        Datanodes
+        <AutoReloadPanel
+          isLoading={loading}
+          lastRefreshed={lastUpdated}
+          togglePolling={autoReloadHelper.handleAutoReloadToggle}
+          onReload={loadData} />
+      </div>
+      <div style={{ padding: '24px' }}>
+        <div className='content-div'>
+          <div className='table-header-section'>
+            <div className='table-filter-section'>
+              <MultiSelect
+                options={columnOptions}
+                defaultValue={selectedColumns}
+                selected={selectedColumns}
+                placeholder='Columns'
+                onChange={handleColumnChange}
+                onTagClose={() => { }}
+                fixedColumn='hostname'
+                columnLength={columnOptions.length} />
+              {selectedRows.length > 0 &&
+                <Button
+                  type="primary"
+                  icon={<DeleteOutlined />}
+                  style={{
+                    background: '#FF4D4E',
+                    borderColor: '#FF4D4E'
+                  }}
+                  loading={loading}
+                  onClick={() => { setModalOpen(true) }}> Remove
+                </Button>
+              }
+            </div>
+            <Search
+              disabled={dataSource?.length < 1}
+              searchOptions={SearchableColumnOpts}
+              searchInput={searchTerm}
+              searchColumn={searchColumn}
+              onSearchChange={
+                (e: React.ChangeEvent<HTMLInputElement>) => 
setSearchTerm(e.target.value)
+              }
+              onChange={(value) => {
+                setSearchTerm('');
+                setSearchColumn(value as 'hostname' | 'uuid' | 'version' | 
'revision')
+              }} />
+          </div>
+          <DatanodesTable
+            loading={loading}
+            data={dataSource}
+            selectedColumns={selectedColumns}
+            selectedRows={selectedRows}
+            searchColumn={searchColumn}
+            searchTerm={debouncedSearch}
+            handleSelectionChange={handleSelectionChange}
+            decommissionUuids={decommissionUuids} />
+        </div>
+      </div>
+      <Modal
+          title=''
+          centered={true}
+          visible={modalOpen}
+          onOk={handleModalOk}
+          onCancel={handleModalCancel}
+          closable={false}
+          width={400} >
+            <div style={{
+              margin: '0px 0px 5px 0px',
+              fontSize: '16px',
+              fontWeight: 'bold'
+            }}>
+              <WarningFilled className='icon-warning' style={{paddingRight: 
'8px'}}/>
+              Stop Tracking Datanode
+            </div>
+            Are you sure, you want recon to stop tracking the selected 
<strong>{selectedRows.length}</strong> datanode(s)?
+        </Modal>
+    </>
+  );
+}
+
+export default Datanodes;
\ 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 37ec3964bb..0aea9e8040 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
@@ -20,6 +20,7 @@ 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 const routesV2 = [
@@ -35,6 +36,10 @@ export const routesV2 = [
     path: '/Buckets',
     component: Buckets
   },
+  {
+    path: '/Datanodes',
+    component: Datanodes
+  },
   {
     path: '/Pipelines',
     component: Pipelines
diff --git 
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/datanode.types.ts
 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/datanode.types.ts
new file mode 100644
index 0000000000..96a3702015
--- /dev/null
+++ 
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/datanode.types.ts
@@ -0,0 +1,167 @@
+/*
+ * 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 { Pipeline } from "@/v2/types/pipelines.types";
+import { StorageReport } from "@/v2/types/overview.types";
+import { Option as MultiOption } from "@/v2/components/select/multiSelect";
+
+// Corresponds to HddsProtos.NodeState
+export const DatanodeStateList = ['HEALTHY', 'STALE', 'DEAD'] as const;
+type DatanodeStateType = typeof DatanodeStateList;
+export type DatanodeState = DatanodeStateType[number];
+
+// Corresponds to HddsProtos.NodeOperationalState
+export const DatanodeOpStateList = [
+  'IN_SERVICE',
+  'DECOMMISSIONING',
+  'DECOMMISSIONED',
+  'ENTERING_MAINTENANCE',
+  'IN_MAINTENANCE'
+] as const;
+export type DatanodeOpState = typeof DatanodeOpStateList[number];
+
+export type DatanodeResponse = {
+  hostname: string;
+  state: DatanodeState;
+  opState: DatanodeOpState;
+  lastHeartbeat: string;
+  storageReport: StorageReport;
+  pipelines: Pipeline[];
+  containers: number;
+  openContainers: number;
+  leaderCount: number;
+  uuid: string;
+  version: string;
+  setupTime: number;
+  revision: string;
+  buildDate: string;
+  networkLocation: string;
+}
+
+export type DatanodesResponse = {
+  totalCount: number;
+  datanodes: DatanodeResponse[];
+}
+
+export type Datanode = {
+  hostname: string;
+  state: DatanodeState;
+  opState: DatanodeOpState;
+  lastHeartbeat: string;
+  storageUsed: number;
+  storageTotal: number;
+  storageRemaining: number;
+  storageCommitted: number;
+  pipelines: Pipeline[];
+  containers: number;
+  openContainers: number;
+  leaderCount: number;
+  uuid: string;
+  version: string;
+  setupTime: number;
+  revision: string;
+  buildDate: string;
+  networkLocation: string;
+}
+
+export type DatanodeDetails = {
+  uuid: string;
+}
+
+export type DatanodeDecomissionInfo = {
+  datanodeDetails: DatanodeDetails
+}
+
+export type DatanodesState = {
+  dataSource: Datanode[];
+  lastUpdated: number;
+  columnOptions: MultiOption[];
+}
+
+// Datanode Summary endpoint types
+type summaryByteString = {
+  string: string;
+  bytes: {
+    validUtf8: boolean;
+    empty: boolean;
+  }
+}
+
+type SummaryPort = {
+  name: string;
+  value: number;
+}
+
+type SummaryDatanodeDetails = {
+  level: number;
+  parent: unknown | null;
+  cost: number;
+  uuid: string;
+  uuidString: string;
+  ipAddress: string;
+  hostName: string;
+  ports: SummaryPort;
+  certSerialId: null,
+  version: string | null;
+  setupTime: number;
+  revision: string | null;
+  buildDate: string;
+  persistedOpState: string;
+  persistedOpStateExpiryEpochSec: number;
+  initialVersion: number;
+  currentVersion: number;
+  decommissioned: boolean;
+  maintenance: boolean;
+  ipAddressAsByteString: summaryByteString;
+  hostNameAsByteString: summaryByteString;
+  networkName: string;
+  networkLocation: string;
+  networkFullPath: string;
+  numOfLeaves: number;
+  networkNameAsByteString: summaryByteString;
+  networkLocationAsByteString: summaryByteString
+}
+
+type SummaryMetrics = {
+  decommissionStartTime: string;
+  numOfUnclosedPipelines: number;
+  numOfUnderReplicatedContainers: number;
+  numOfUnclosedContainers: number;
+}
+
+type SummaryContainers = {
+  UnderReplicated: string[];
+  UnClosed: string[];
+}
+
+export type SummaryData = {
+  datanodeDetails: SummaryDatanodeDetails;
+  metrics: SummaryMetrics;
+  containers: SummaryContainers;
+}
+
+export type DatanodeTableProps = {
+  loading: boolean;
+  selectedRows: React.Key[];
+  data: Datanode[];
+  decommissionUuids: string | string[];
+  searchColumn: 'hostname' | 'uuid' | 'version' | 'revision';
+  searchTerm: string;
+  selectedColumns: MultiOption[];
+  handleSelectionChange: (arg0: React.Key[]) => void;
+}


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


Reply via email to