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 3da4104634c8..1dd1ede48db4 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 @@ -144,7 +144,7 @@ const NavBar: React.FC = ({ Heatmap diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/plots/heatmapPlot.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/plots/heatmapPlot.tsx new file mode 100644 index 000000000000..a58a7704dac0 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/plots/heatmapPlot.tsx @@ -0,0 +1,151 @@ +/* + * 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 { AgChartsReact } from 'ag-charts-react'; +import { byteToSize } from '@/utils/common'; +import { HeatmapResponse } from '@/v2/types/heatmap.types'; + +type HeatmapPlotProps = { + data: HeatmapResponse; + onClick: (arg0: string) => void; + colorScheme: string[]; + entityType: string; +}; + +const capitalize = (str: T) => { + return str.charAt(0).toUpperCase() + str.slice(1) as Capitalize; +} + +const HeatmapPlot: React.FC = ({ + data, + onClick, + colorScheme, + entityType = '' +}) => { + + const tooltipContent = (params: any) => { + let tooltipContent = ` + Size: + ${byteToSize(params.datum.size, 1)} + `; + if (params.datum.accessCount !== undefined) { + tooltipContent += `
+ Access count: + ${params.datum.accessCount } + `; + } + else{ + tooltipContent += `
+ Max Access Count: + ${params.datum.maxAccessCount} + `;} + if (params.datum.label !== '') { + tooltipContent += `
+ Entity Name: + ${params.datum.label ? params.datum.label.split('/').slice(-1) : ''} + `; + } + tooltipContent += '
'; + return tooltipContent; + }; + + const heatmapConfig = { + type: 'treemap', + labelKey: 'label',// the name of the key to fetch the label value from + sizeKey: 'normalizedSize',// the name of the key to fetch the value that will determine tile size + colorKey: 'color', + title: { color: '#424242', fontSize: 14, fontFamily: 'Roboto', fontWeight: '600' }, + subtitle: { color: '#424242', fontSize: 12, fontFamily: 'Roboto', fontWeight: '400' }, + tooltip: { + renderer: (params) => { + return { + content: tooltipContent(params) + }; + } + }, + formatter: ({ highlighted }: { highlighted: boolean }) => { + const stroke = highlighted ? '#CED4D9' : '#FFFFFF'; + return { stroke }; + }, + labels: { + color: '#FFFFFF', + fontWeight: 'bold', + fontSize: 12 + }, + tileStroke: '#FFFFFF', + tileStrokeWidth: 1.4, + colorDomain: [ + 0.000, + 0.050, + 0.100, + 0.150, + 0.200, + 0.250, + 0.300, + 0.350, + 0.400, + 0.450, + 0.500, + 0.550, + 0.600, + 0.650, + 0.700, + 0.750, + 0.800, + 0.850, + 0.900, + 0.950, + 1.000 + ], + colorRange: [...colorScheme], + groupFill: '#E6E6E6', + groupStroke: '#E1E2E6', + nodePadding: 3, + labelShadow: { enabled: false }, //labels shadow + gradient: false, + highlightStyle: { + text: { + color: '#424242', + }, + item: { + fill: 'rgba(0, 0 ,0, 0.0)', + }, + }, + listeners: { + nodeClick: (event) => { + var data = event.datum; + // Leaf level box should not call API + if (!data.color) + if (data.path) { + onClick(data.path); + } + }, + }, + } + + const options = { + data, + series: [heatmapConfig], + title: { text: `${capitalize(entityType)} Heatmap`} + }; + + return +} + +export default HeatmapPlot; \ No newline at end of file diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/heatmap.constants.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/heatmap.constants.tsx new file mode 100644 index 000000000000..63a8476648f6 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/constants/heatmap.constants.tsx @@ -0,0 +1,47 @@ +/* + * 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. + */ + +export const colourScheme = { + amberAlert: [ + '#FFCF88', + '#FFCA87', + '#FFC586', + '#FFC085', + '#FFBB83', + '#FFB682', + '#FFB181', + '#FFA676', + '#FF9F6F', + '#FF9869', + '#FF9262', + '#FF8B5B', + '#FF8455', + '#FF7D4E', + '#FF8282', + '#FF7776', + '#FF6D6A', + '#FF625F', + '#FF5753', + '#FF4D47', + '#FF423B' + ] +}; + +export const TIME_PERIODS: string[] = ['24H', '7D', '90D'] +export const ENTITY_TYPES: string[] = ['key', 'bucket', 'volume'] +export const ROOT_PATH = '/' diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/heatmap/heatmap.less b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/heatmap/heatmap.less new file mode 100644 index 000000000000..57eaf8391d8d --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/heatmap/heatmap.less @@ -0,0 +1,86 @@ +/* +* 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; + padding-bottom: 10px; + + .heatmap-header-section { + display: flex; + justify-content: space-between; + align-items: stretch; + + @media (max-width: 1680px) { + flex-direction: column; + } + + .heatmap-filter-section { + font-size: 14px; + font-weight: normal; + display: flex; + column-gap: 12px; + + .path-input-container { + display: inline-flex; + align-items: flex-start; + + .path-input-element { + margin: 0px 0px 0px 10px; + width: 20vw; + } + } + + .entity-dropdown-button { + display: inline-block; + } + + .date-dropdown-button { + display: inline-block; + } + + .input-bar { + display: inline-block; + margin-left: 25px; + } + + .input { + padding-left: 5px; + margin-right: 10px; + display: inline-block; + width: 400px; + } + } + + .heatmap-legend-container { + display: flex !important; + align-self: flex-start; + + .heatmap-legend-item { + display: flex; + align-items: center; + margin-left: 20px; + } + } + } + + #heatmap-plot-container { + height: 600px; + width: 84vw; + margin-left: -12px; + } +} diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/heatmap/heatmap.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/heatmap/heatmap.tsx new file mode 100644 index 000000000000..5cf2d64bd50e --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/pages/heatmap/heatmap.tsx @@ -0,0 +1,388 @@ +/* + * 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, { ChangeEvent, useRef, useState } from 'react'; +import moment, { Moment } from 'moment'; +import { Row, Button, Menu, Input, Dropdown, DatePicker, Form, Result, message, Spin } from 'antd'; +import { MenuProps } from 'antd/es/menu'; +import { DownOutlined, LoadingOutlined, UndoOutlined } from '@ant-design/icons'; + + +import { showDataFetchError } from '@/utils/common'; +import { AxiosGetHelper } from '@/utils/axiosRequestHelper'; +import * as CONSTANTS from '@/v2/constants/heatmap.constants'; +import { HeatmapChild, HeatmapResponse, HeatmapState, InputPathState, InputPathValidTypes, IResponseError } from '@/v2/types/heatmap.types'; +import HeatmapPlot from '@/v2/components/plots/heatmapPlot'; + +import './heatmap.less'; +import { useLocation } from 'react-router-dom'; +import { AxiosResponse } from 'axios'; + +let minSize = Infinity; +let maxSize = 0; + +const Heatmap: React.FC<{}> = () => { + + const [state, setState] = useState({ + heatmapResponse: { + label: '', + path: '', + children: [], + size: 0, + maxAccessCount: 0, + minAccessCount: 0 + }, + entityType: CONSTANTS.ENTITY_TYPES[0], + date: CONSTANTS.TIME_PERIODS[0] + }); + + const [inputPathState, setInputPathState] = useState({ + inputPath: CONSTANTS.ROOT_PATH, + isInputPathValid: undefined, + helpMessage: '' + }); + + const [isLoading, setLoading] = useState(false); + const [treeEndpointFailed, setTreeEndpointFailed] = useState(false); + + const location = useLocation(); + const cancelSignal = useRef(); + const cancelDisabledFeatureSignal = useRef(); + + const [isHeatmapEnabled, setIsHeatmapEnabled] = useState(location?.state?.isHeatmapEnabled); + + + function handleChange(e: ChangeEvent) { + const value = e.target.value; + // Only allow letters, numbers,underscores and forward slashes and hyphen + const regex = /^[a-zA-Z0-9_/-]*$/; + + let inputValid = undefined; + let helpMessage = ''; + if (!regex.test(value)) { + helpMessage = 'Please enter valid path'; + inputValid = 'error'; + } + setInputPathState({ + inputPath: value, + isInputPathValid: inputValid as InputPathValidTypes, + helpMessage: helpMessage + }); + } + + function handleSubmit() { + updateHeatmap(inputPathState.inputPath, state.entityType, state.date); + } + + const normalize = (min: number, max: number, size: number) => { + // Since there can be a huge difference between the largest entity size + // and the smallest entity size, it might cause some blocks to render smaller + // we are normalizing the size to ensure all entities are visible + //Normaized Size using Deviation and mid Point + const mean = (max + min) / 2; + const highMean = (max + mean) / 2; + const lowMean1 = (min + mean) / 2; + const lowMean2 = (lowMean1 + min) / 2; + + if (size > highMean) { + const newsize = highMean + (size * 0.1); + return (newsize); + } + if (size < lowMean2) { + const diff = (lowMean2 - size) / 2; + const newSize = lowMean2 - diff; + return (newSize); + } + + return size; + }; + + const updateSize = (obj: HeatmapResponse | HeatmapChild) => { + //Normalize Size so other blocks also get visualized if size is large in bytes minimize and if size is too small make it big + // it will only apply on leaf level as checking color property + if (obj.hasOwnProperty('size') && obj.hasOwnProperty('color')) { + + // hide block at key,volume,bucket level if size accessCount and maxAccessCount are zero apply normalized size only for leaf level + if ((obj as HeatmapChild)?.size === 0 && (obj as HeatmapChild)?.accessCount === 0) { + obj['normalizedSize'] = 0; + } else if ((obj as HeatmapResponse)?.size === 0 && (obj as HeatmapResponse)?.maxAccessCount === 0) { + obj['normalizedSize'] = 0; + } + else if (obj?.size === 0 && ((obj as HeatmapChild)?.accessCount >= 0 || (obj as HeatmapResponse).maxAccessCount >= 0)) { + obj['normalizedSize'] = 1; + obj.size = 0; + } + else { + const newSize = normalize(minSize, maxSize, obj.size); + obj['normalizedSize'] = newSize; + } + } + + if (obj.hasOwnProperty('children')) { + (obj as HeatmapResponse)?.children.forEach(child => updateSize(child)); + } + return obj as HeatmapResponse; + }; + + const updateHeatmap = (path: string, entityType: string, date: string | number) => { + // Only perform requests if the heatmap is enabled + if (isHeatmapEnabled) { + setLoading(true); + // We want to ensure these are not empty as they will be passed as path params + if (date && path && entityType) { + const { request, controller } = AxiosGetHelper( + `/api/v1/heatmap/readaccess?startDate=${date}&path=${path}&entityType=${entityType}`, + cancelSignal.current + ); + cancelSignal.current = controller; + + request.then(response => { + if (response?.status === 200) { + minSize = response.data.minAccessCount; + maxSize = response.data.maxAccessCount; + const heatmapResponse: HeatmapResponse = updateSize(response.data); + setLoading(false); + setState(prevState => ({ + ...prevState, + heatmapResponse: heatmapResponse + })); + } else { + const error = new Error((response.status).toString()) as IResponseError; + error.status = response.status; + error.message = `Failed to fetch Heatmap Response with status ${error.status}` + throw error; + } + }).catch(error => { + setLoading(false); + setInputPathState(prevState => ({ + ...prevState, + inputPath: CONSTANTS.ROOT_PATH + })); + setTreeEndpointFailed(true); + if (error.response.status !== 404) { + showDataFetchError(error.message.toString()); + } + }); + } else { + setLoading(false); + } + + } + } + + const updateHeatmapParent = (path: string) => { + setInputPathState(prevState => ({ + ...prevState, + inputPath: path + })); + } + + function isDateDisabled(current: Moment) { + return current > moment() || current < moment().subtract(90, 'day'); + } + + function getIsHeatmapEnabled() { + const disabledfeaturesEndpoint = `/api/v1/features/disabledFeatures`; + const { request, controller } = AxiosGetHelper( + disabledfeaturesEndpoint, + cancelDisabledFeatureSignal.current + ) + cancelDisabledFeatureSignal.current = controller; + request.then(response => { + setIsHeatmapEnabled(!response?.data?.includes('HEATMAP')); + }).catch(error => { + showDataFetchError((error as Error).toString()); + }); + } + + React.useEffect(() => { + // We do not know if heatmap is enabled or not, so set it + if (isHeatmapEnabled === undefined) { + getIsHeatmapEnabled(); + } + updateHeatmap(inputPathState.inputPath, state.entityType, state.date); + + return (() => { + cancelSignal.current && cancelSignal.current.abort(); + }) + }, [isHeatmapEnabled, state.entityType, state.date]); + + const handleDatePickerChange = (date: moment.MomentInput) => { + setState(prevState => ({ + ...prevState, + date: moment(date).unix() + })); + }; + + const handleMenuChange: MenuProps["onClick"] = (e) => { + if (CONSTANTS.ENTITY_TYPES.includes(e.key as string)) { + minSize = Infinity; + maxSize = 0; + setState(prevState => ({ + ...prevState, + entityType: e.key as string, + })); + } + }; + + const handleCalendarChange: MenuProps["onClick"] = (e) => { + if (CONSTANTS.TIME_PERIODS.includes(e.key as string)) { + setState(prevState => ({ + ...prevState, + date: e.key + })); + } + }; + + const { date, entityType, heatmapResponse } = state; + const { inputPath, helpMessage, isInputPathValid } = inputPathState; + + const menuCalendar = ( + + + 24 Hour + + + 7 Days + + + 90 Days + + + + { e.stopPropagation() }} + disabledDate={isDateDisabled} /> + + + + ); + + const entityTypeMenu = ( + + + Volume + + + Bucket + + + Key + + + ); + + function getErrorContent() { + if (!isHeatmapEnabled) { + return + } + + if (treeEndpointFailed) { + return + } + } + + return ( + <> +
+ Heatmap +
+
+ { + (!isHeatmapEnabled || treeEndpointFailed) + ? getErrorContent() + :
+
+
+
+

Path

+ + + +
+
+ + + +
+
+ + + +
+
+
+
+
+ Less Accessed +
+
+
+ Moderate Accessed +
+
+
+ Most Accessed +
+
+
+ {isLoading + ? + : (Object.keys(heatmapResponse).length > 0 && (heatmapResponse.label !== null || heatmapResponse.path !== null)) + ?
+ +
+ : + } +
+ } +
+ + ); +} + +export default Heatmap; \ 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 c3ff1b97e3ce..fb2dc0b9c45d 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 @@ -17,9 +17,6 @@ */ import { lazy } from 'react'; -import { Heatmap } from '@/views/heatMap/heatmap'; -import NotFound from '@/v2/pages/notFound/notFound'; - const Overview = lazy(() => import('@/v2/pages/overview/overview')); const Volumes = lazy(() => import('@/v2/pages/volumes/volumes')) const Buckets = lazy(() => import('@/v2/pages/buckets/buckets')); @@ -29,6 +26,7 @@ const DiskUsage = lazy(() => import('@/v2/pages/diskUsage/diskUsage')); const Containers = lazy(() => import('@/v2/pages/containers/containers')); const Insights = lazy(() => import('@/v2/pages/insights/insights')); const OMDBInsights = lazy(() => import('@/v2/pages/insights/omInsights')); +const Heatmap = lazy(() => import('@/v2/pages/heatmap/heatmap')); export const routesV2 = [ @@ -68,7 +66,6 @@ export const routesV2 = [ path: '/Om', component: OMDBInsights }, - // TODO: Replace with V2 heatmap once rea { path: '/Heatmap', component: Heatmap diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/heatmap.types.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/heatmap.types.ts new file mode 100644 index 000000000000..a76db22a6fec --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/types/heatmap.types.ts @@ -0,0 +1,51 @@ +/* + * 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. + */ + +export type InputPathValidTypes = 'error' | 'success' | 'warning' | 'validating' | undefined; + +export type HeatmapChild = { + label: string; + size: number; + accessCount: number; + color: number; +} + +export type InputPathState = { + inputPath: string; + isInputPathValid: InputPathValidTypes; + helpMessage: string; +} + +export type HeatmapResponse = { + label: string; + path: string; + maxAccessCount: number; + minAccessCount: number; + size: number; + children: HeatmapChild[]; +} + +export type HeatmapState = { + heatmapResponse: HeatmapResponse; + entityType: string; + date: string | number; +} + +export interface IResponseError extends Error { + status?: number; +} \ No newline at end of file