diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json index 30fed20baeb6..37663e3704e3 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/db.json @@ -6866,5 +6866,146 @@ "selectedRowKeys": [ "b5907812-a5f2-11ea-bb37-0242ac130011" ] + }, + "utilization": { + "globalStorage": { + "totalUsedSpace": 30679040, + "totalFreeSpace": 539776978944, + "totalCapacity": 879519597390 + }, + "globalNamespace": { + "totalUsedSpace": 12349932, + "totalKeys": 1576 + }, + "usedSpaceBreakdown": { + "openKeyBytes": 19255266, + "committedKeyBytes": 1249923, + "preAllocatedContainerBytes": 1022024 + }, + "dataNodeUsage": [ + { + "datanodeUuid": "1bf314dc-3eba-4774-9dbc-6d957cc7670b", + "hostName": "ozone-datanode-7.ozone_default", + "capacity": 125645656770, + "used": 4382720, + "remaining": 77110996992, + "committed": 0, + "minimumFreeSpace": 104857600, + "reserved": 12565822 + }, + { + "datanodeUuid": "8adead67-85a6-4ba9-942f-cd313f1472f9", + "hostName": "ozone-datanode-6.ozone_default", + "capacity": 125645656770, + "used": 4382720, + "remaining": 77110996992, + "committed": 0, + "minimumFreeSpace": 104857600, + "reserved": 12565822 + }, + { + "datanodeUuid": "953b91f4-c03a-45d8-9fbe-142887531cb1", + "hostName": "ozone-datanode-5.ozone_default", + "capacity": 125645656770, + "used": 4382720, + "remaining": 77110996992, + "committed": 0, + "minimumFreeSpace": 104857600, + "reserved": 12565822 + }, + { + "datanodeUuid": "44617070-a300-48af-a0e3-117167b008b6", + "hostName": "ozone-datanode-2.ozone_default", + "capacity": 125645656770, + "used": 4382720, + "remaining": 77110996992, + "committed": 0, + "minimumFreeSpace": 104857600, + "reserved": 12565822 + }, + { + "datanodeUuid": "d3ade292-6ec1-47b2-bd9e-045c1acc2c29", + "hostName": "ozone-datanode-1.ozone_default", + "capacity": 125645656770, + "used": 4382720, + "remaining": 77110996992, + "committed": 0, + "minimumFreeSpace": 104857600, + "reserved": 12565822 + }, + { + "datanodeUuid": "149a1640-e600-4e98-b62d-397804059f0e", + "hostName": "ozone-datanode-3.ozone_default", + "capacity": 125645656770, + "used": 4382720, + "remaining": 77110996992, + "committed": 0, + "minimumFreeSpace": 104857600, + "reserved": 12565822 + }, + { + "datanodeUuid": "17e2562a-dae8-416f-a749-7d4e8a0a781e", + "hostName": "ozone-datanode-4.ozone_default", + "capacity": 125645656770, + "used": 4382720, + "remaining": 77110996992, + "committed": 0, + "minimumFreeSpace": 104857600, + "reserved": 12565822 + } + ] + }, + "pendingDeletionDN": { + "status": "FINISHED", + "totalPendingDeletionSize": 12203, + "pendingDeletionPerDataNode": [ + { + "hostName": "ozone-datanode-5.ozone_default", + "datanodeUuid": "953b91f4-c03a-45d8-9fbe-142887531cb1", + "pendingBlockSize": 1200 + }, + { + "hostName": "ozone-datanode-3.ozone_default", + "datanodeUuid": "149a1640-e600-4e98-b62d-397804059f0e", + "pendingBlockSize": 803 + }, + { + "hostName": "ozone-datanode-4.ozone_default", + "datanodeUuid": "17e2562a-dae8-416f-a749-7d4e8a0a781e", + "pendingBlockSize": -1 + }, + { + "hostName": "ozone-datanode-7.ozone_default", + "datanodeUuid": "1bf314dc-3eba-4774-9dbc-6d957cc7670b", + "pendingBlockSize": 2200 + }, + { + "hostName": "ozone-datanode-1.ozone_default", + "datanodeUuid": "d3ade292-6ec1-47b2-bd9e-045c1acc2c29", + "pendingBlockSize": -1 + }, + { + "hostName": "ozone-datanode-2.ozone_default", + "datanodeUuid": "44617070-a300-48af-a0e3-117167b008b6", + "pendingBlockSize": 300 + }, + { + "hostName": "ozone-datanode-6.ozone_default", + "datanodeUuid": "8adead67-85a6-4ba9-942f-cd313f1472f9", + "pendingBlockSize": 2730 + } + ], + "totalNodesQueried": 7, + "totalNodeQueriesFailed": 2 + }, + "pendingDeletionOM": { + "totalSize": 240430, + "pendingDirectorySize": 120040, + "pendingKeySize": 120390 + }, + "pendingDeletionSCM": { + "totalBlocksize": 24030, + "totalReplicatedBlockSize": 120040, + "totalBlocksCount": 120390 } } diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/routes.json b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/routes.json index af586efb3fae..213879b77f6d 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/routes.json +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/api/routes.json @@ -53,5 +53,10 @@ "/keys/deletePending/dirs?limit=*": "/dirdeletePending", "/datanodes/decommission/info": "/decommissioninfo", "/datanodes/decommission/info/datanode?uuid=*": "/DatanodesDecommissionInfo", - "/datanodes/remove": "/datanodesRemove" + "/datanodes/remove": "/datanodesRemove", + + "/storageDistribution": "/utilization", + "/pendingDeletion?component=dn": "/pendingDeletionDN", + "/pendingDeletion?component=om": "/pendingDeletionOM", + "/pendingDeletion?component=scm": "/pendingDeletionSCM" } \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/themeIcons.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/themeIcons.tsx index 906a528cd282..d5cc414994a0 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/themeIcons.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/utils/themeIcons.tsx @@ -98,3 +98,30 @@ export class ReplicationIcon extends React.PureComponent return icon; } } + +interface IGraphLegendIconProps { + color: string; + height?: number; +}; +export class GraphLegendIcon extends React.PureComponent { + render() { + const { color, height = 14 } = this.props; + + return ( + + + + ) + } +}; \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewCardWrapper.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewCardWrapper.tsx similarity index 100% rename from hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewCardWrapper.tsx rename to hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewCardWrapper.tsx diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSimpleCard.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSimpleCard.tsx similarity index 100% rename from hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSimpleCard.tsx rename to hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSimpleCard.tsx diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewStorageCard.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewStorageCard.tsx new file mode 100644 index 000000000000..ace92d4f9d95 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewStorageCard.tsx @@ -0,0 +1,312 @@ +/* + * 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, { HTMLAttributes, useMemo, useState } from 'react'; +import filesize from 'filesize'; +import { Card, Row, Col, Table, Tag, Modal } from 'antd'; + +import EChart from '@/v2/components/eChart/eChart'; + +import { StorageReport } from '@/v2/types/overview.types'; +import { InfoCircleFilled } from '@ant-design/icons'; +import { Link } from 'react-router-dom'; +import ErrorCard from '@/v2/components/errors/errorCard'; + +// ------------- Types -------------- // +type OverviewStorageCardProps = { + loading?: boolean; + storageReport: StorageReport; + error?: string | null; +} + +const size = filesize.partial({ round: 1 }); + +function getUsagePercentages( + { used, remaining, capacity, committed }: StorageReport): ({ + ozoneUsedPercentage: number, + nonOzoneUsedPercentage: number, + committedPercentage: number, + usagePercentage: number + }) { + return { + ozoneUsedPercentage: Math.floor(used / capacity * 100), + nonOzoneUsedPercentage: Math.floor((capacity - remaining - used) / capacity * 100), + committedPercentage: Math.floor(committed / capacity * 100), + usagePercentage: Math.round((capacity - remaining) / capacity * 100) + } +} + +// ------------- Styles -------------- // +const cardHeadStyle: React.CSSProperties = { fontSize: '14px' }; +const cardBodyStyle: React.CSSProperties = { padding: '16px' }; +const cardStyle: React.CSSProperties = { + boxSizing: 'border-box', + height: '100%' +} +const cardErrorStyle: React.CSSProperties = { + borderColor: '#FF4D4E', + borderWidth: '1.4px' +} +const eChartStyle: React.CSSProperties = { + width: '280px', + height: '200px' +} + + +// ------------- Component -------------- // +const OverviewStorageCard: React.FC = ({ + loading = false, + storageReport = { + capacity: 0, + used: 0, + remaining: 0, + committed: 0 + }, + error +}) => { + + if (error) { + return + } + + const [isInfoOpen, setInfoOpen] = useState(false); + + const { + ozoneUsedPercentage, + nonOzoneUsedPercentage, + committedPercentage, + usagePercentage + } = useMemo(() => + getUsagePercentages(storageReport), + [ + storageReport.capacity, + storageReport.committed, + storageReport.remaining, + storageReport.used, + ] + ) + + let capacityData = [{ + value: ozoneUsedPercentage, + itemStyle: { + color: '#52C41A' + } + }, { + value: nonOzoneUsedPercentage, + itemStyle: { + color: '#1890FF' + } + }, { + value: committedPercentage, + itemStyle: { + color: '#FF595E' + } + }] + // Remove all zero values + // because guage chart shows a dot if value is zero + capacityData = capacityData.filter((val) => val.value > 0) + + const eChartOptions = { + title: { + left: 'center', + bottom: 'bottom', + text: `${size(storageReport.capacity - storageReport.remaining)} / ${size(storageReport.capacity)}`, + textStyle: { + fontWeight: 'normal', + fontFamily: 'Roboto' + } + }, + series: [ + { + type: 'gauge', + startAngle: 90, + endAngle: -270, + radius: '70%', + center: ['50%', '45%'], + bottom: '50%', + pointer: { + show: false + }, + progress: { + show: true, + overlap: true, + roundCap: true, + clip: true + }, + splitLine: { + show: false + }, + axisTick: { + show: false + }, + axisLabel: { + show: false, + distance: 50 + }, + detail: { + rich: { + value: { + fontSize: 24, + fontWeight: 400, + fontFamily: 'Roboto', + color: '#1B232A' + }, + percent: { + fontSize: 20, + fontWeight: 400, + color: '#1B232A' + } + }, + formatter: `{value|${usagePercentage}}{percent|%}`, + offsetCenter: [0, 0] + }, + data: capacityData + } + ] + } + + const showInfo = () => { + setInfoOpen(true); + } + + const closeInfo = () => { + setInfoOpen(false); + } + + const titleElement = ( +
+
+ + Cluster Capacity +
+ View Usage +
+ ) + + const tableData = [ + { + key: 'ozone-used', + usage: Ozone Used, + size: size(storageReport.used), + desc: 'Size of Data used by Ozone for storing actual files in the Datanodes' + }, + { + key: 'non-ozone-used', + usage: Non Ozone Used, + size: size(storageReport.capacity - storageReport.remaining - storageReport.used), + desc: 'Size of data used by Ozone for other files like logs, DB data etc.' + }, + { + key: 'remaining', + usage: + Remaining + , + size: size(storageReport.remaining), + desc: 'Space which is free after considering replication and Non-Ozone used space' + }, + { + key: 'pre-allocated', + usage: Container Pre-allocated, + size: size(storageReport.committed), + desc: 'Space which is pre-allocated for containers' + } + ]; + + return ( + <> + +

Cluster capacity fetches the data from Datanode reports that Recon receives.

+

+ The displayed sizes include the replicated data size.
+ Ex: A 1KB key will display 3KB in RATIS (THREE) replication +

+ + + 79) ? { ...cardStyle, ...cardErrorStyle } : cardStyle} > + + + + + +
({ + 'data-testid': `capacity-${record.key}` + }) as HTMLAttributes} /> + + + + + ) +} + +export default OverviewStorageCard; diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSummaryCard.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSummaryCard.tsx similarity index 98% rename from hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSummaryCard.tsx rename to hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSummaryCard.tsx index 9214c456b6c7..bf6ed392263b 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/overviewCard/overviewSummaryCard.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/cards/overviewSummaryCard.tsx @@ -21,7 +21,6 @@ import { Card, Row, Table } from 'antd'; import { ColumnType } from 'antd/es/table'; import { Link } from 'react-router-dom'; -import ErrorMessage from '@/v2/components/errors/errorCard'; import ErrorCard from '@/v2/components/errors/errorCard'; // ------------- Types -------------- // diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/navBar/navBar.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/navBar/navBar.tsx index 3cc6b2aca91c..f0ec7bc81950 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/navBar/navBar.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/navBar/navBar.tsx @@ -120,6 +120,12 @@ const NavBar: React.FC = ({ ), ( + }> + Cluster Capacity + + + ),( isHeatmapEnabled && }> diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/capacity.constants.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/capacity.constants.tsx new file mode 100644 index 000000000000..1699d7ffeb40 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/capacity.constants.tsx @@ -0,0 +1,61 @@ +/* + * 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 { UtilizationResponse, SCMPendingDeletion, OMPendingDeletion, DNPendingDeletion } from "@/v2/types/capacity.types"; + +export const DEFAULT_CAPACITY_UTILIZATION: UtilizationResponse = { + globalStorage: { + totalUsedSpace: 0, + totalFreeSpace: 0, + totalCapacity: 0 + }, + globalNamespace: { + totalUsedSpace: 0, + totalKeys: 0 + }, + usedSpaceBreakdown: { + openKeyBytes: 0, + committedKeyBytes: 0, + preAllocatedContainerBytes: 0 + }, + dataNodeUsage: [] +}; + +export const DEFAULT_SCM_PENDING_DELETION: SCMPendingDeletion = { + totalBlocksize: 0, + totalReplicatedBlockSize: 0, + totalBlocksCount: 0 +}; + +export const DEFAULT_OM_PENDING_DELETION: OMPendingDeletion = { + totalSize: 0, + pendingDirectorySize: 0, + pendingKeySize: 0 +}; + +export const DEFAULT_DN_PENDING_DELETION: DNPendingDeletion = { + status: "NOT_STARTED", + totalPendingDeletionSize: 0, + pendingDeletionPerDataNode: [{ + hostName: 'unknown-host', + datanodeUuid: 'unknown-uuid', + pendingBlockSize: 0 + }], + totalNodesQueried: 0, + totalNodeQueriesFailed: 0 +}; diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useAutoReload.hook.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useAutoReload.hook.tsx index baa8190bfc91..c18b478798ce 100644 --- a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useAutoReload.hook.tsx +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/hooks/useAutoReload.hook.tsx @@ -27,6 +27,7 @@ export function useAutoReload( const [isPolling, setIsPolling] = useState(false); const refreshFunctionRef = useRef(refreshFunction); const lastPollCallRef = useRef(0); // This is used to store the last time poll was called + const [intervalMs, setIntervalMs] = useState(interval); // Update the ref when the function changes refreshFunctionRef.current = refreshFunction; @@ -39,8 +40,10 @@ export function useAutoReload( } }; - const startPolling = () => { + const startPolling = (customInterval?: number) => { stopPolling(); + const effectiveInterval = customInterval ?? intervalMs; + setIntervalMs(effectiveInterval); const poll = () => { /** * Prevent any extra polling calls within 100ms of the last call, @@ -53,7 +56,7 @@ export function useAutoReload( refreshFunctionRef.current(); lastPollCallRef.current = Date.now(); } - intervalRef.current = window.setTimeout(poll, interval); + intervalRef.current = window.setTimeout(poll, effectiveInterval); }; poll(); setIsPolling(true); diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.less b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.less new file mode 100644 index 000000000000..77673b2d32fc --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.less @@ -0,0 +1,178 @@ +/* + * 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. + */ + +.data-container { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + + .section-title { + flex-grow: 0; + font-family: Roboto; + font-size: 20px; + font-weight: 500; + font-stretch: normal; + font-style: normal; + line-height: 1.4; + letter-spacing: normal; + text-align: left; + color: #1b2329; + } + + .node-select-container { + display: flex; + flex-direction: column; + gap: 16px; + margin: 16px 0px auto 0px; + height: 11em; + } + + .cluster-card-data-container { + display: flex; + justify-content: space-between; + gap: 16px; + + &.vertical-layout { + flex-direction: column; + gap: 0px; + } + + .cluster-card-statistic { + flex: 2 1 10em; + } + + .data-detail-item { + display: flex; + width: 100%; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + border-radius: 3px; + + .data-detail-breakdown-container { + justify-content: flex-end; + flex-wrap: wrap; + .data-detail-breakdown-item { + margin-left: 20px; + .data-detail-breakdown-label { + font-size: 12px; + color: #5a656d; + margin-right: 4px; + } + + .data-detail-breakdown-value { + font-size: 14px; + font-weight: 500; + } + + } + } + } + + .data-detail-breakdown-container { + .ant-statistic-title { + margin-bottom: 0; + } + + .ant-statistic-content { + .ant-statistic-content-prefix { + margin-right: 1px; + } + .ant-statistic-content-value { + font-size: 16px; + } + } + } + } + + .stacked-progress { + display: flex; + width: 100%; + height: 8px; + border-radius: 100px; + overflow: hidden; + margin: 24px auto 8px auto; + } + .stacked-progress-empty { + width: 100%; + height: 8px; + border-radius: 100px; + background-color: #f4f5f6; + } +} + +.data-breakdown-section { + display: flex; + gap: 16px; + width: 100%; + justify-content: space-between; + align-items: stretch; + + > .ant-card { + flex: 1 1 0; + min-width: 0; + } +} + +.unused-space-breakdown { + display: grid; + grid-template-columns: 150px auto; + grid-column-gap: 20px; + grid-row-gap: 4px; + + .ant-tag { + text-align: center; + } +} + +.ant-statistic-title { + font-size: 12px; +} + +// This is for the suffix part of the value ex: TB, GB etc +.ant-statistic-content-suffix { + font-family: Roboto; + font-size: 14px; + font-weight: normal; + font-stretch: normal; + font-style: normal; + line-height: 1.43; + letter-spacing: normal; + text-align: left; + vertical-align: text-top; + color: rgba(0, 0, 0, 0.85); + margin: 3px 0 0 1px; +} + +.ant-divider-horizontal { + margin: 16px 0; +} + +.ant-card-body { + // This is to enforce 16px padding for card body which is 12px by default + padding: 16px !important; +} + +.dn-select-option-label { + .dn-select-option-uuid { + font-size: 14px; + color: #5a656d; + margin-left: 10px; + } +} \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.tsx new file mode 100644 index 000000000000..50f292958f48 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/capacity.tsx @@ -0,0 +1,370 @@ +/* + * 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 { Popover, Tag, Tooltip, Typography } from 'antd'; +import React from 'react'; +import AutoReloadPanel from '@/components/autoReloadPanel/autoReloadPanel'; + +import './capacity.less'; +import { showDataFetchError } from '@/utils/common' +import moment from 'moment'; +import CapacityBreakdown from '@/v2/pages/capacity/components/CapacityBreakdown'; +import CapacityDetail from '@/v2/pages/capacity/components/CapacityDetail'; +import { + datanodesPendingDeletionDesc, + otherUsedSpaceDesc, + ozoneUsedSpaceDesc, + totalCapacityDesc +} from '@/v2/pages/capacity/constants/descriptions.constants'; +import WrappedInfoIcon from '@/v2/pages/capacity/components/WrappedInfoIcon'; +import filesize from 'filesize'; +import { InfoCircleOutlined, WarningFilled, CheckCircleFilled } from '@ant-design/icons'; +import { useApiData } from '@/v2/hooks/useAPIData.hook'; +import * as CONSTANTS from '@/v2/constants/capacity.constants'; +import { UtilizationResponse, SCMPendingDeletion, OMPendingDeletion, DNPendingDeletion, DataNodeUsage } from '@/v2/types/capacity.types'; +import { useAutoReload } from '@/v2/hooks/useAutoReload.hook'; +import { AUTO_RELOAD_INTERVAL_DEFAULT } from '@/constants/autoReload.constants'; + +type CapacityState = { + isDNPending: boolean; + lastUpdated: number; +}; + +const Capacity: React.FC = () => { + const PENDING_POLL_INTERVAL = 5 * 1000; + + const [state, setState] = React.useState({ + isDNPending: true, + lastUpdated: 0 + }); + + const storageDistribution = useApiData( + '/api/v1/storageDistribution', + CONSTANTS.DEFAULT_CAPACITY_UTILIZATION, + { + retryAttempts: 2, + onError: (error) => showDataFetchError(error) + } + ); + + const scmPendingDeletes = useApiData( + '/api/v1/pendingDeletion?component=scm', + CONSTANTS.DEFAULT_SCM_PENDING_DELETION, + { + retryAttempts: 2, + onError: (error) => showDataFetchError(error) + } + ); + + const omPendingDeletes = useApiData( + '/api/v1/pendingDeletion?component=om', + CONSTANTS.DEFAULT_OM_PENDING_DELETION, + { + retryAttempts: 2, + onError: (error) => showDataFetchError(error) + } + ); + + const dnPendingDeletes = useApiData( + '/api/v1/pendingDeletion?component=dn', + CONSTANTS.DEFAULT_DN_PENDING_DELETION, + { + retryAttempts: 2, + initialFetch: false, + onError: (error) => showDataFetchError(error) + } + ); + + const [selectedDatanode, setSelectedDatanode] = React.useState(storageDistribution.data.dataNodeUsage[0]?.hostName ?? ""); + + // Seed selected datanode once data loads so dependent calculations work + React.useEffect(() => { + const firstHost = storageDistribution.data.dataNodeUsage[0]?.hostName; + if (!selectedDatanode && firstHost) { + setSelectedDatanode(firstHost); + } + }, [selectedDatanode, storageDistribution.data.dataNodeUsage]); + + const loadDNData = () => { + dnPendingDeletes.refetch(); + setState({ + isDNPending: dnPendingDeletes.data.status !== "FINISHED", + lastUpdated: Number(moment()) + }) + } + + const autoReload = useAutoReload(loadDNData); + const lastIntervalRef = React.useRef(AUTO_RELOAD_INTERVAL_DEFAULT); + + const selectedDNDetails: DataNodeUsage & { pendingBlockSize: number } = React.useMemo(() => { + const selected = storageDistribution.data.dataNodeUsage.find(datanode => datanode.hostName === selectedDatanode) + ?? storageDistribution.data.dataNodeUsage[0]; + return { + ...(selected ?? { + datanodeUuid: "unknown-uuid", + hostName: "unknown-host", + capacity: 0, + used: 0, + remaining: 0, + committed: 0, + minimumFreeSpace: 0, + reserved: 0 + }), + ...dnPendingDeletes.data.pendingDeletionPerDataNode?.find(dn => dn.hostName === (selected?.hostName ?? selectedDatanode)) ?? { + hostName: "unknown-host", + datanodeUuid: "unknown-uuid", + pendingBlockSize: 0 + } + } + }, [selectedDatanode, storageDistribution.data.dataNodeUsage, dnPendingDeletes.data.pendingDeletionPerDataNode]); + + // Dynamically adjust polling interval based on DN pending status + React.useEffect(() => { + const pending = dnPendingDeletes.loading || dnPendingDeletes.data.status !== "FINISHED"; + const targetInterval = pending ? PENDING_POLL_INTERVAL : AUTO_RELOAD_INTERVAL_DEFAULT; + + if (!autoReload.isPolling) { + lastIntervalRef.current = targetInterval; + return; + } + + if (lastIntervalRef.current !== targetInterval) { + lastIntervalRef.current = targetInterval; + autoReload.startPolling(targetInterval); + } + }, [dnPendingDeletes.loading, dnPendingDeletes.data.status, autoReload.isPolling, autoReload.startPolling]); + + const dnReportStatus = ( + (dnPendingDeletes.data.totalNodeQueriesFailed ?? 0) > 0 + ? + { (dnPendingDeletes.data.totalNodesQueried ?? 0) + - (dnPendingDeletes.data.totalNodeQueriesFailed ?? 0) + } / { (dnPendingDeletes.data.totalNodesQueried ?? 0) } DNs + + }> + + Datanodes + + : + {dnPendingDeletes.data.totalNodesQueried ?? 0} / {dnPendingDeletes.data.totalNodesQueried ?? 0} DNs + + }> + + Datanodes + + ) + + const unusedSpaceBreakdown = ( + + UNUSED + + Minimum Free Space + {filesize(selectedDNDetails.minimumFreeSpace, {round: 1})} + Remaining + {filesize(selectedDNDetails.remaining, { round: 1})} + + } + > + + + + ) + + return ( + <> +
+ Cluster Capacity + +
+
+ Cluster + + TOTAL + + + ), + value: storageDistribution.data.globalStorage.totalCapacity, + }, { + title: 'OZONE USED SPACE', + value: storageDistribution.data.globalStorage.totalUsedSpace, + color: '#f4a233' + }, { + title: ( + + OTHER USED SPACE + + + ), + value: ( + storageDistribution.data.globalStorage.totalCapacity + - storageDistribution.data.globalStorage.totalFreeSpace + - storageDistribution.data.globalStorage.totalUsedSpace + ), + color: '#11073a' + }, { + title: 'CONTAINER PRE-ALLOCATED', + value: storageDistribution.data.usedSpaceBreakdown.preAllocatedContainerBytes, + color: '#f47b2d' + }, { + title: 'REMAINING SPACE', + value: storageDistribution.data.globalStorage.totalFreeSpace, + color: '#4553ee' + }]} + /> + Service + + Ozone Used Space + + + )} + loading={storageDistribution.loading} + items={[{ + title: 'TOTAL', + value: storageDistribution.data.globalStorage.totalUsedSpace + }, { + title: 'OPEN KEYS', + value: storageDistribution.data.usedSpaceBreakdown.openKeyBytes, + color: '#f47c2d' + }, { + title: 'COMMITTED KEYS', + value: storageDistribution.data.usedSpaceBreakdown.committedKeyBytes, + color: '#f4a233' + }, { + title: ( + dnPendingDeletes.data.status !== "FINISHED" || dnPendingDeletes.loading + ? ( + + PENDING DELETION + + + ) + : 'PENDING DELETION' + ), + value: ( + omPendingDeletes.data.totalSize + + scmPendingDeletes.data.totalBlocksize + + (dnPendingDeletes.data.totalPendingDeletionSize ?? 0) + ), + color: "#10073b" + }]} + /> +
+ + DATANODES + + + ), + loading: dnPendingDeletes.loading || dnPendingDeletes.data.status !== "FINISHED", + size: dnPendingDeletes.data.totalPendingDeletionSize ?? 0, + breakdown: [{ + label: 'BLOCKS', + value: dnPendingDeletes.data.totalPendingDeletionSize ?? 0, + color: '#f4a233' + }] + }]} /> + ({ + label: datanode.hostName, + value: datanode.hostName + }))} + disabledOpts={ + (dnPendingDeletes.data.pendingDeletionPerDataNode ?? []) + .filter(dn => dn.pendingBlockSize === -1) + .map(dn => dn.hostName) + } + optsClass={'dn-select-option'} + dataDetails={[{ + title: 'USED SPACE', + size: (selectedDNDetails.used ?? 0) + (selectedDNDetails.pendingBlockSize ?? 0), + breakdown: [{ + label: 'PENDING DELETION', + value: selectedDNDetails.pendingBlockSize ?? 0, + color: '#f4a233' + }, { + label: 'OZONE USED', + value: selectedDNDetails.used ?? 0, + color: '#10073b' + }] + }, { + title: 'FREE SPACE', + size: (selectedDNDetails.remaining ?? 0) + (selectedDNDetails.committed ?? 0), + breakdown: [{ + label: unusedSpaceBreakdown, + value: selectedDNDetails.remaining ?? 0, + color: '#f4a233' + }, { + label: 'OZONE PRE-ALLOCATED', + value: selectedDNDetails.committed ?? 0, + color: '#10073b' + }] + }]} /> +
+
+ + + ) + +}; + +export default Capacity; diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityBreakdown.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityBreakdown.tsx new file mode 100644 index 000000000000..4ca85a32d5f6 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityBreakdown.tsx @@ -0,0 +1,73 @@ +/* + * 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 { GraphLegendIcon } from '@/utils/themeIcons'; +import StackedProgress from '@/v2/pages/capacity/components/StackedProgress'; +import { cardHeadStyle, statisticValueStyle } from '@/v2/pages/capacity/constants/styles.constants'; +import { Card, Statistic } from 'antd'; +import filesize from 'filesize'; +import React from 'react'; + +type GridItem = { + title: string | React.ReactNode; + value: number; + color?: string; + format?: 'bytes' | 'number' | 'percentage'; +}; + +type ClusterCardProps = { + title: string | React.ReactNode; + items: GridItem[]; + loading: boolean; +}; + +const getProgressSegments = (items: GridItem[]) => { + return items.filter(item => item.color).map((item) => ({ + value: item.value, + color: item.color!, + label: item.title + } as Segment)); +} + +const CapacityBreakdown: React.FC = ({ title, items, loading }) => { + + return ( + +
+ {items.map((item, idx) => { + // Split the size into the value and the unit + const size = filesize(item.value, { round: 1 }).split(' '); + return ( + : undefined} + value={size[0]} + suffix={size[1]} + valueStyle={statisticValueStyle} + className='cluster-card-statistic' + /> + ) + })} +
+ +
+ ); +}; + +export default CapacityBreakdown; \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityDetail.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityDetail.tsx new file mode 100644 index 000000000000..4f30d8a3ce4c --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/capacity/components/CapacityDetail.tsx @@ -0,0 +1,163 @@ +/* + * 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 { EChart } from '@/components/eChart/eChart'; +import { GraphLegendIcon } from '@/utils/themeIcons'; +import { cardHeadStyle, statisticValueStyle } from '@/v2/pages/capacity/constants/styles.constants'; +import { Segment } from '@/v2/types/capacity.types'; +import { Card, Divider, Row, Select, Spin, Statistic } from 'antd'; +import filesize from 'filesize'; +import React from 'react'; + +type DataDetailItem = { + title: string | React.ReactNode; + size: number; + breakdown: Segment[]; + loading?: boolean; +} + +type CapacityDetailProps = { + title: string | React.ReactNode; + showDropdown: boolean; + dataDetails: DataDetailItem[]; + dropdownItems?: { + label: React.ReactNode | string; + value: string; + }[]; + disabledOpts?: string[]; + optsClass?: string; + handleSelect?: React.Dispatch> + loading: boolean; + extra?: React.ReactNode; +}; + +const getEchartOptions = (title: string | React.ReactNode, data: DataDetailItem) => { + const option = { + grid: { + left: 2, + right: 4, + top: 16, + bottom: 0 + }, + xAxis: { + // Use linear scale to support zero values safely + type: 'value', + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false } + }, + yAxis: { + type: 'category', + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + }, + }; + + const breakdownLen = data.breakdown.length; + const series = data.breakdown.map((breakdown, idx) => ({ + type: 'bar', + ...(breakdownLen > 1 && { stack: title }), + itemStyle: { + ...(idx === breakdownLen - 1 && { borderRadius: [0, 50, 50, 0] }), + ...(idx === 0 && { borderRadius: [50, 0, 0, 50] }), + ...(breakdownLen === 1 && { borderRadius: [50, 50, 50, 50] }), + color: breakdown.color, + }, + data: [breakdown.value], + barWidth: '10px', + barGap: '2px' + })); + + return { + ...option, + series + } as any +} + + +const CapacityDetail: React.FC = ( + { + title, + showDropdown, + dropdownItems, + disabledOpts, + optsClass, + dataDetails, + handleSelect, + loading, + extra + } +) => { + + const options = dropdownItems?.map((item) => ({ + label: item.label, + value: item.value, + ...(disabledOpts?.includes(item.value) && { disabled: true }), + ...(optsClass && { className: optsClass }), + })) ?? []; + + return ( + + { showDropdown && options.length > 0 && +
+ Node Selector: +