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 8fbe403dc375..53a76d83f19c 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>; 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 000000000000..34e72b0889aa --- /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; +}; + +function getDescriptions(summaryData: SummaryData): React.ReactElement { + const { + datanodeDetails: { + uuid, + networkLocation, + ipAddress, + hostName + }, + containers: { UnderReplicated, UnClosed }, + metrics: { + decommissionStartTime, + numOfUnclosedPipelines, + numOfUnclosedContainers, + numOfUnderReplicatedContainers + } + } = summaryData; + return ( + + {uuid} + ({networkLocation}/{ipAddress}/{hostName}) + {decommissionStartTime} + {numOfUnclosedPipelines} + {numOfUnclosedContainers} + {numOfUnderReplicatedContainers} + {UnderReplicated} + {UnClosed} + + ); +} + + +const DecommissionSummary: React.FC = ({ + uuid = '' +}) => { + const [state, setState] = React.useState({ + summaryData: {}, + loading: false + }); + const cancelSignal = React.useRef(); + let content = ( + + ); + + 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 = ( + + ) + } + } + + useEffect(() => { + fetchDecommissionSummary(uuid); + return (() => { + cancelRequests([cancelSignal.current!]); + }) + }, []); + + const { summaryData } = state; + if (summaryData?.datanodeDetails + && summaryData?.metrics + && summaryData?.containers + ) { + content = getDescriptions(summaryData as SummaryData); + } + + return ( + +  {uuid} + + ); + +} + +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 000000000000..798287366c3f --- /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 591b0088b04b..fd6dd8dfe9bc 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 = ({ + 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 = ( <>
- + Ozone Used ({size(used)})
- + Non Ozone Used ({size(nonOzoneUsed)})
- + Remaining ({size(remaining)})
- + Container Pre-allocated ({size(committed)})
); - const metaElement = (showMeta) ? ( -
- {size(used + nonOzoneUsed)} / {size(capacity)} -
- ) : <>; - return ( -
- - {metaElement} + + {(showMeta) && +
+ {size(used + nonOzoneUsed)} / {size(capacity)} +
+ } + className='capacity-bar-v2' strokeWidth={strokeWidth} />
-
); } 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 000000000000..494d898509bf --- /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: , + STALE: , + DEAD: + }; + const icon = state in stateIconMap ? stateIconMap[state] : ''; + return {icon} {state}; +}; + +const renderDatanodeOpState = (opState: DatanodeOpState) => { + const opStateIconMap = { + IN_SERVICE: , + DECOMMISSIONING: , + DECOMMISSIONED: , + ENTERING_MAINTENANCE: , + IN_MAINTENANCE: + }; + const icon = opState in opStateIconMap ? opStateIconMap[opState] : ''; + return {icon} {opState}; +}; + +export const COLUMNS: ColumnsType = [ + { + 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') ? + : {uuid} + ); + } + }, + { + title: 'Storage Capacity', + dataIndex: 'storageUsed', + key: 'storageUsed', + sorter: (a: Datanode, b: Datanode) => a.storageRemaining - b.storageRemaining, + render: (_: string, record: Datanode) => ( + + ) + }, + { + 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) => ( +
+ + {pipeline.pipelineID} +
+ )) + } + + return ( + + {pipelines.length} pipelines + + ); + } + }, + { + title: () => ( + + Leader Count + + + + + ), + 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: () => ( + + Open Container + + + + + ), + 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 = ({ + 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 = { + selectedRowKeys: selectedRows, + onChange: (rows: React.Key[]) => { handleSelectionChange(rows) }, + getCheckboxProps: (record: Datanode) => ({ + disabled: isSelectable(record) + }), + }; + + React.useEffect(() => { + decommissioningUuids = decommissionUuids; + }, [decommissionUuids]) + + return ( +
+ + + ); +} + +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 12af3bb4281d..1e2de307b17b 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(); const [state, setState] = useState({ 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 000000000000..a1eee3852106 --- /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 000000000000..13022dc05e02 --- /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(); + const cancelDecommissionSignal = useRef(); + + const [state, setState] = useState({ + lastUpdated: 0, + columnOptions: defaultColumns, + dataSource: [] + }); + const [loading, setLoading] = useState(false); + const [selectedColumns, setSelectedColumns] = useState(defaultColumns); + const [selectedRows, setSelectedRows] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [searchColumn, setSearchColumn] = useState<'hostname' | 'uuid' | 'version' | 'revision'>('hostname'); + const [modalOpen, setModalOpen] = useState(false); + + const debouncedSearch = useDebounce(searchTerm, 300); + + function handleColumnChange(selected: ValueType) { + 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 ( + <> +
+ Datanodes + +
+
+
+
+
+ { }} + fixedColumn='hostname' + columnLength={columnOptions.length} /> + {selectedRows.length > 0 && + + } +
+ ) => setSearchTerm(e.target.value) + } + onChange={(value) => { + setSearchTerm(''); + setSearchColumn(value as 'hostname' | 'uuid' | 'version' | 'revision') + }} /> +
+ +
+
+ +
+ + Stop Tracking Datanode +
+ Are you sure, you want recon to stop tracking the selected {selectedRows.length} datanode(s)? +
+ + ); +} + +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 37ec3964bbb2..0aea9e804023 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 000000000000..96a370201539 --- /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; +}