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">
+ {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]