From 2609d3d57ddf8dbaf9987fabb1e5697d6a393f0e Mon Sep 17 00:00:00 2001 From: Abhishek Pal Date: Mon, 16 Sep 2024 14:50:08 +0530 Subject: [PATCH 01/10] HDDS-11162. Improve Disk Usage page UI --- .../v2/components/duMetadata/duMetadata.tsx | 383 ++++++++++++++++++ .../src/v2/pages/diskUsage/diskUsage.less | 50 +++ .../src/v2/pages/diskUsage/diskUsage.tsx | 252 ++++++++++++ .../ozone-recon-web/src/v2/routes-v2.tsx | 6 + .../src/v2/types/diskUsage.types.ts | 46 +++ 5 files changed, 737 insertions(+) create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duMetadata/duMetadata.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.less create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.tsx create mode 100644 hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/diskUsage.types.ts diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duMetadata/duMetadata.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duMetadata/duMetadata.tsx new file mode 100644 index 00000000000..28c6432090f --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/duMetadata/duMetadata.tsx @@ -0,0 +1,383 @@ +/* + * 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 { AxiosGetHelper, cancelRequests } from '@/utils/axiosRequestHelper'; +import { byteToSize, showDataFetchError } from '@/utils/common'; +import { Acl } from '@/v2/types/acl.types'; +import { Drawer, Table } from 'antd'; +import { AxiosError } from 'axios'; +import moment from 'moment'; +import React, { useEffect, useRef, useState } from 'react'; + + +// ------------- Types -------------- // +type CountStats = { + numBucket: number; + numDir: number; + numKey: number; + numVolume: number; +}; + +type LocationInfo = { + blockID: { + containerBlockID: { + containerID: number; + localID: number; + }; + blockCommitSequenceId: number; + containerID: number; + localID: number; + }; + length: number; + offset: number; + token: null; + createVersion: number; + pipeline: null; + partNumber: number; + containerID: number; + localID: number; + blockCommitSequenceId: number; +}; + +type ObjectInfo = { + bucketName: string; + bucketLayout: string; + encInfo: null; + fileName: string; + keyName: string; + name: string; + owner: string; + volume: string; + volumeName: string; + sourceVolume: string | null; + sourceBucket: string | null; + usedBytes: number | null; + usedNamespace: number; + storageType: string; + creationTime: number; + dataSize: number; + modificationTime: number; + quotaInBytes: number; + quotaInNamespace: number; +} + +type ReplicationConfig = { + replicationFactor: string; + requiredNodes: number; + replicationType: string; +} + +type ObjectInfoResponse = ObjectInfo & { + acls: Acl[]; + versioningEnabled: boolean; + metadata: Record; + file: boolean; + keyLocationVersions: { + version: number; + locationList: LocationInfo[]; + multipartKey: boolean; + blocksLatestVersionOnly: LocationInfo[]; + locationLists: LocationInfo[][]; + locationListCount: number; + }[]; + versioning: boolean; + encryptionInfo: null; + replicationConfig: ReplicationConfig; +}; + +type SummaryResponse = { + countStats: CountStats; + objectInfo: ObjectInfoResponse; + path: string; + status: string; + type: string; +} + +type KeySummaryResponse = { + status: string; + path: string; + size: number; + sizeWithReplica: number; + subPathCount: number; + subPaths: never[]; + sizeDirectKey: number; +} + +type MetadataProps = { + path: string; +}; + +type MetadataState = { + keys: string[]; + values: (string | number | boolean | null)[]; +}; + + +// ------------- Component -------------- // +const DUMetadata: React.FC = ({ + path = '/' +}) => { + const [loading, setLoading] = useState(false); + const [state, setState] = useState({ + keys: [], + values: [] + }); + const cancelSummarySignal = useRef(); + const keyMetadataSummarySignal = useRef(); + const cancelQuotaSignal = useRef(); + + const getObjectInfoMapping = React.useCallback((summaryResponse) => { + + const keys: string[] = []; + const values: (string | number | boolean | null)[] = []; + /** + * We are creating a specific set of keys under Object Info response + * which do not require us to modify anything + */ + const selectedInfoKeys = [ + 'bucketName', 'bucketLayout', 'encInfo', 'fileName', 'keyName', + 'name', 'owner', 'sourceBucket', 'sourceVolume', 'storageType', 'usedBytes', + 'usedNamespace', 'volumeName', 'volume' + ] as const; + const objectInfo: ObjectInfo = summaryResponse.objectInfo ?? {}; + + selectedInfoKeys.forEach((key) => { + if (objectInfo[key as keyof ObjectInfo] !== undefined && objectInfo[key as keyof ObjectInfo] !== -1) { + // We will use regex to convert the Object key from camel case to space separated title + // The following regex will match abcDef and produce Abc Def + let keyName = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + keyName = keyName.charAt(0).toUpperCase() + keyName.slice(1); + keys.push(keyName); + values.push(objectInfo[key as keyof ObjectInfo]); + } + }); + + if (objectInfo?.creationTime !== undefined && objectInfo?.creationTime !== -1) { + keys.push('Creation Time'); + values.push(moment(objectInfo.creationTime).format('ll LTS')); + } + + if (objectInfo?.dataSize !== undefined && objectInfo?.dataSize !== -1) { + keys.push('Data Size'); + values.push(byteToSize(objectInfo.dataSize, 3)); + } + + if (objectInfo?.modificationTime !== undefined && objectInfo?.modificationTime !== -1) { + keys.push('Modification Time'); + values.push(moment(objectInfo.modificationTime).format('ll LTS')); + } + + if (objectInfo?.quotaInBytes !== undefined && objectInfo?.quotaInBytes !== -1) { + keys.push('Quota In Bytes'); + values.push(byteToSize(objectInfo.quotaInBytes, 3)); + } + + if (objectInfo?.quotaInNamespace !== undefined && objectInfo?.quotaInNamespace !== -1) { + keys.push('Quota In Namespace'); + values.push(byteToSize(objectInfo.quotaInNamespace, 3)); + } + + if (summaryResponse.objectInfo?.replicationConfig?.replicationFactor !== undefined) { + keys.push('Replication Factor'); + values.push(summaryResponse.objectInfo.replicationConfig.replicationFactor); + } + + if (summaryResponse.objectInfo?.replicationConfig?.replicationType !== undefined) { + keys.push('Replication Type'); + values.push(summaryResponse.objectInfo.replicationConfig.replicationType); + } + + if (summaryResponse.objectInfo?.replicationConfig?.requiredNodes !== undefined + && summaryResponse.objectInfo?.replicationConfig?.requiredNodes !== -1) { + keys.push('Replication Required Nodes'); + values.push(summaryResponse.objectInfo.replicationConfig.requiredNodes); + } + + return { keys, values } + }, [path]); + + function loadMetadataSummary(path: string) { + cancelRequests([ + cancelSummarySignal.current!, + keyMetadataSummarySignal.current! + ]); + const keys: string[] = []; + const values: (string | number | boolean | null)[] = []; + + const { request, controller } = AxiosGetHelper( + `/api/v1/namespace/summary?path=${path}`, + cancelSummarySignal.current + ); + cancelSummarySignal.current = controller; + + request.then(response => { + const summaryResponse: SummaryResponse = response.data; + keys.push('Entity Type'); + values.push(summaryResponse.type); + + if (summaryResponse.status === 'INITIALIZING') { + showDataFetchError(`The metadata is currently initializing. Please wait a moment and try again later`); + return; + } + + if (summaryResponse.status === 'PATH_NOT_FOUND') { + showDataFetchError(`Invalid Path: ${path}`); + return; + } + + // If the entity is a Key then fetch the Key metadata only + if (summaryResponse.type === 'KEY') { + const { request: metadataRequest, controller: metadataNewController } = AxiosGetHelper( + `/api/v1/namespace/du?path=${path}&replica=true`, + keyMetadataSummarySignal.current + ); + keyMetadataSummarySignal.current = metadataNewController; + metadataRequest.then(response => { + keys.push('File Size'); + values.push(byteToSize(response.data.size, 3)); + keys.push('File Size With Replication'); + values.push(byteToSize(response.data.sizeWithReplica, 3)); + + setState({ + keys: keys, + values: values + }); + }).catch(error => { + showDataFetchError(error.toString()); + }); + return; + } + + /** + * Will iterate over the keys of the countStats to avoid multiple if blocks + * and check from the map for the respective key name / title to insert + */ + const countStats: CountStats = summaryResponse.countStats ?? {}; + const keyToNameMap: Record = { + numVolume: 'Volumes', + numBucket: 'Buckets', + numDir: 'Total Directories', + numKey: 'Total Keys' + } + Object.keys(countStats).forEach((key: string) => { + if (countStats[key as keyof CountStats] !== undefined + && countStats[key as keyof CountStats] !== -1) { + keys.push(keyToNameMap[key]); + values.push(countStats[key as keyof CountStats]); + } + }) + + const { + keys: objectInfoKeys, + values: objectInfoValues + } = getObjectInfoMapping(summaryResponse); + + keys.push(...objectInfoKeys); + values.push(...objectInfoValues); + + setState({ + keys: keys, + values: values + }); + }).catch(error => { + showDataFetchError((error as AxiosError).toString()); + }); + } + + function loadQuotaSummary(path: string) { + cancelRequests([ + cancelQuotaSignal.current! + ]); + + const { request, controller } = AxiosGetHelper( + `/api/v1/namespace/quota?path=${path}`, + cancelQuotaSignal.current + ); + cancelQuotaSignal.current = controller; + + request.then(response => { + const quotaResponse = response.data; + + if (quotaResponse.status === 'INITIALIZING') { + return; + } + if (quotaResponse.status === 'TYPE_NOT_APPLICABLE') { + return; + } + if (quotaResponse.status === 'PATH_NOT_FOUND') { + showDataFetchError(`Invalid Path: ${path}`); + return; + } + + const keys: string[] = []; + const values: (string | number | boolean | null)[] = []; + // Append quota information + // In case the object's quota isn't set + if (quotaResponse.allowed !== undefined && quotaResponse.allowed !== -1) { + keys.push('Quota Allowed'); + values.push(byteToSize(quotaResponse.allowed, 3)); + } + + if (quotaResponse.used !== undefined && quotaResponse.used !== -1) { + keys.push('Quota Used'); + values.push(byteToSize(quotaResponse.used, 3)); + } + setState((prevState) => ({ + keys: [...prevState.keys, ...keys], + values: [...prevState.values, ...values] + })); + }).catch(error => { + showDataFetchError(error.toString()); + }); + } + + React.useEffect(() => { + setLoading(true); + loadMetadataSummary(path); + loadQuotaSummary(path); + setLoading(false); + + return (() => { + cancelRequests([ + cancelSummarySignal.current!, + keyMetadataSummarySignal.current!, + cancelQuotaSignal.current! + ]); + }) + }, []); + + const content = []; + for (const [i, v] of state.keys.entries()) { + content.push({ + key: v, + value: state.values[i] + }); + } + + return ( + + + +
+ ); +} + +export default DUMetadata; \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.less b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.less new file mode 100644 index 00000000000..76d7c1be31a --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.less @@ -0,0 +1,50 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +.du-alert-message { + background-color: #FFFFFF; + border: unset; + box-shadow: -1px -2px 28px -14px rgba(0,0,0,0.68); + -webkit-box-shadow: -1px -2px 28px -14px rgba(0,0,0,0.68); + -moz-box-shadow: -1px -2px 28px -14px rgba(0,0,0,0.68); + margin-bottom: 20px; +} + +.content-div { + min-height: unset; + + .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; + } + } + + .tag-block { + display: flex; + column-gap: 8px; + padding: 0px 8px 16px 8px; + } +} diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.tsx new file mode 100644 index 00000000000..3c878f400ec --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/diskUsage/diskUsage.tsx @@ -0,0 +1,252 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useRef, useState } from 'react'; +import { AxiosError } from 'axios'; +import { + Alert, Layout, +} from 'antd'; +import { + InfoCircleFilled +} from '@ant-design/icons'; +import { ValueType } from 'react-select'; + +import SingleSelect, { Option } from '@/v2/components/select/singleSelect'; +import { byteToSize, showDataFetchError } from '@/utils/common'; +import { AxiosGetHelper, cancelRequests } from '@/utils/axiosRequestHelper'; + +import { DUResponse, DUState, DUSubpath, PlotData } from '@/v2/types/diskUsage.types'; + +import './diskUsage.less'; +import DUMetadata from '@/v2/components/duMetadata/duMetadata'; + +const OTHER_PATH_NAME = 'Other Objects'; +const MIN_BLOCK_SIZE = 0.05; + +const LIMIT_OPTIONS: Option[] = [ + { label: '5', value: '5' }, + { label: '10', value: '10' }, + { label: '15', value: '15' }, + { label: '20', value: '20' }, + { label: '30', value: '30' } +] + +const DiskUsage: React.FC<{}> = () => { + const [loading, setLoading] = useState(false); + const [limit, setLimit] = useState