diff --git a/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/chartDefs.js b/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/chartDefs.js index 9946bf0a86..ef9dd7e9aa 100644 --- a/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/chartDefs.js +++ b/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/chartDefs.js @@ -14,7 +14,7 @@ * limitations under the License. * * ------------------------------------------------------------------------- */ import PropTypes from 'prop-types' -import { CartesianGrid } from 'recharts' +import { CartesianGrid, PolarAngleAxis, Cell } from 'recharts' import { Component, Fragment } from 'react' /** @@ -49,9 +49,17 @@ export const generateColorByMetric = (metric, metricHues, datasetId) => { * @param {Array} metrics - List of metrics. * @param {number} datasetId - The ID of the dataset. * @param {object} metricHues - Object containing hue values for different metrics. + * @param {string} coordinateType - Coordinate system type. + * @param {string} groupBy - Used in non-cartesian configurations to specify dataKey. * @returns {Component} A React component with SVG definitions and a CartesianGrid. */ -export const GetChartDefs = (metrics, datasetId, metricHues) => ( +export const GetChartDefs = ( + metrics, + datasetId, + metricHues, + coordinateType = 'CARTESIAN', + groupBy = 'pct' +) => ( {metrics.map((metric) => { @@ -72,7 +80,21 @@ export const GetChartDefs = (metrics, datasetId, metricHues) => ( ) })} - + {coordinateType === 'CARTESIAN' ? ( + + ) : ( + + )} ) @@ -81,3 +103,142 @@ GetChartDefs.propTypes = { metricHues: PropTypes.objectOf(PropTypes.number).isRequired, datasetId: PropTypes.number.isRequired, } + +/** + * Generates the configuration for a chart based on the provided parameters. + * + * @param {string} coordinateType - The coordinate system of the chart ('CARTESIAN' or 'POLAR'). + * @param {string} chartType - The type of chart to render. + * @param {Array} datasets - The datasets to be used for the chart. + * @param {Array} paginatedData - The paginated data for the chart. + * @returns {object} The configuration for the chart. + */ +export const GetChartConfig = ( + coordinateType, + chartType, + datasets, + paginatedData +) => { + const commonConfig = { + data: paginatedData, + style: !datasets.length ? { pointerEvents: 'none' } : {}, + } + + switch (coordinateType) { + case 'CARTESIAN': + return { + ...commonConfig, + barCategoryGap: 20, + padding: { top: 0, right: 60, bottom: 0, left: 60 }, + stackOffset: chartType === 'stackedBar' ? 'sign' : 'none', + } + case 'POLAR': + return { + ...commonConfig, + innerRadius: '25%', + outerRadius: '90%', + data: datasets, + startAngle: 90, + endAngle: -270, + } + default: + throw new Error(`Unsupported coordinateType: ${coordinateType}`) + } +} + +GetChartConfig.propTypes = { + coordinateType: PropTypes.string.isRequired, + chartType: PropTypes.string.isRequired, + datasets: PropTypes.arrayOf(PropTypes.object).isRequired, + paginatedData: PropTypes.arrayOf(PropTypes.object).isRequired, +} + +/** + * Generates the configuration for a chart element based on the provided parameters. + * + * @param {string} chartType - The type of chart to render. + * @param {object} metric - The metric details. + * @param {object} dataset - The dataset details. + * @param {string} coordinateType - The coordinate system of the chart ('CARTESIAN' or 'POLAR'). + * @param {object} theme - The theme object from MUI. + * @param {string} dsId - The dataset ID. + * @returns {object} The configuration for the chart element. + */ +export const GetChartElementConfig = ( + chartType, + metric, + dataset, + coordinateType, + theme, + dsId +) => { + const keyBase = `${metric.key}-${dataset.id}` + const commonConfig = { + key: keyBase, + type: 'monotone', + dataKey: keyBase, + fill: `url(#color${keyBase})`, + name: metric.name, + animationDuration: 500, + stackId: chartType === 'stackedBar' ? 'a' : undefined, + } + + switch (coordinateType) { + case 'POLAR': + return { + ...commonConfig, + background: { + filter: 'brightness(90%)', + fill: theme?.palette?.background?.default, + }, + dataKey: 'pct', + angleAxisId: 0, + cornerRadius: 10, + children: dataset.map((entry, index) => ( + 0 + ? `url(#color${entry.name}-${dsId})` + : theme?.palette?.text?.disabled + } + fillOpacity={entry?.pv && +entry?.pv > 0 ? 1 : 0.5} + /> + )), + } + case 'CARTESIAN': + switch (chartType) { + case 'area': + return { + ...commonConfig, + fillOpacity: 0.5, + stroke: 'transparent', + } + case 'line': + return { + ...commonConfig, + strokeWidth: 3, + activeDot: { + r: 8, + fill: `url(#color${keyBase})`, + stroke: 'white', + strokeWidth: 2, + }, + stroke: `url(#color${keyBase})`, + } + default: + return commonConfig + } + default: + throw new Error(`Unsupported coordinateType: ${coordinateType}`) + } +} + +GetChartElementConfig.propTypes = { + chartType: PropTypes.string.isRequired, + metric: PropTypes.object.isRequired, + dataset: PropTypes.object.isRequired, + coordinateType: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired, + dsId: PropTypes.string.isRequired, +} diff --git a/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/index.js b/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/index.js index 3e9ff1e621..9ff2029bfa 100644 --- a/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/index.js +++ b/src/fireedge/src/client/components/Charts/MultiChart/helpers/scripts/index.js @@ -21,6 +21,8 @@ import { import { generateColorByMetric, GetChartDefs, + GetChartConfig, + GetChartElementConfig, } from 'client/components/Charts/MultiChart/helpers/scripts/chartDefs' import { exportDataToPDF } from 'client/components/Charts/MultiChart/helpers/scripts/exportPDF' import { exportDataToCSV } from 'client/components/Charts/MultiChart/helpers/scripts/exportCSV' @@ -31,6 +33,8 @@ export { filterDataset, generateColorByMetric, GetChartDefs, + GetChartConfig, + GetChartElementConfig, exportDataToPDF, exportDataToCSV, } diff --git a/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/ChartRenderer.js b/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/ChartRenderer.js index 090810a19a..f1e2ee2e4a 100644 --- a/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/ChartRenderer.js +++ b/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/ChartRenderer.js @@ -13,26 +13,37 @@ * See the License for the specific language governing permissions and * * limitations under the License. * * ------------------------------------------------------------------------- */ -import React from 'react' +import React, { useMemo } from 'react' import PropTypes from 'prop-types' import { - BarChart, - LineChart, + Area, AreaChart, - XAxis, - YAxis, - Tooltip, Bar, + BarChart, Legend, Line, - Area, + LineChart, + RadialBar, + RadialBarChart, ResponsiveContainer, + Tooltip, + XAxis, + YAxis, } from 'recharts' import { DataGridTable } from 'client/components/Tables' +import { useTheme } from '@mui/material' import { CustomTooltip } from 'client/components/Tooltip' -import { generateColorByMetric } from 'client/components/Charts/MultiChart/helpers/scripts' +import { + generateColorByMetric, + GetChartConfig, + GetChartElementConfig, +} from 'client/components/Charts/MultiChart/helpers/scripts' +import { + FormatPolarDataset, + PolarTooltip, +} from 'client/components/Charts/MultiChart/helpers/subComponents' const CHART_TYPES = { BAR: 'bar', @@ -40,21 +51,32 @@ const CHART_TYPES = { AREA: 'area', TABLE: 'table', STACKED_BAR: 'stackedBar', + RADIAL_BAR: 'radialBar', } const ChartComponents = { - [CHART_TYPES.BAR]: BarChart, - [CHART_TYPES.STACKED_BAR]: BarChart, - [CHART_TYPES.LINE]: LineChart, - [CHART_TYPES.AREA]: AreaChart, - [CHART_TYPES.TABLE]: DataGridTable, + CARTESIAN: { + [CHART_TYPES.BAR]: BarChart, + [CHART_TYPES.STACKED_BAR]: BarChart, + [CHART_TYPES.LINE]: LineChart, + [CHART_TYPES.AREA]: AreaChart, + [CHART_TYPES.TABLE]: DataGridTable, + }, + POLAR: { + [CHART_TYPES.RADIAL_BAR]: RadialBarChart, + }, } const ChartElements = { - [CHART_TYPES.BAR]: Bar, - [CHART_TYPES.STACKED_BAR]: Bar, - [CHART_TYPES.LINE]: Line, - [CHART_TYPES.AREA]: Area, + CARTESIAN: { + [CHART_TYPES.BAR]: Bar, + [CHART_TYPES.STACKED_BAR]: Bar, + [CHART_TYPES.LINE]: Line, + [CHART_TYPES.AREA]: Area, + }, + POLAR: { + [CHART_TYPES.RADIAL_BAR]: RadialBar, + }, } /** @@ -71,6 +93,8 @@ const ChartElements = { * @param {string} props.groupBy - The variable to group data under. * @param {object} props.metricHues - Object containing hue values for different metrics. * @param {boolean} props.disableLegend - Disables the legend underneath the charts. + * @param {string} props.coordinateType - Cartesian or Polar coordinate system. + * @param {Function} props.onElementClick - Callback to handle element click event. * @returns {React.Component} The rendered chart component. */ export const ChartRenderer = ({ @@ -82,11 +106,31 @@ export const ChartRenderer = ({ tableColumns, humanReadableMetric, groupBy, + coordinateType, metricHues, disableLegend, + onElementClick, }) => { - const ChartComponent = ChartComponents[chartType] - const ChartElement = ChartElements[chartType] + const ChartComponent = ChartComponents[coordinateType][chartType] + const ChartElement = ChartElements[coordinateType][chartType] + const theme = useTheme() + + const polarDataset = useMemo( + () => (coordinateType === 'POLAR' ? FormatPolarDataset(datasets) : null), + [coordinateType, datasets] + ) + console.log('polarDataset: ', polarDataset) + + const chartConfig = useMemo( + () => + GetChartConfig( + coordinateType, + chartType, + coordinateType === 'CARTESIAN' ? datasets : polarDataset, + paginatedData + ), + [coordinateType, chartType, datasets, polarDataset, paginatedData] + ) return ( @@ -97,89 +141,125 @@ export const ChartRenderer = ({ selectedItems={selectedMetrics} /> ) : ( - - {datasets.map((dataset) => - customChartDefs( - dataset.metrics.map((m) => m.key), - dataset.id, - metricHues - ) + + {coordinateType === 'CARTESIAN' + ? datasets.map((dataset) => + customChartDefs( + dataset.metrics.map((m) => m.key), + dataset.id, + metricHues, + coordinateType, + groupBy + ) + ) + : customChartDefs( + polarDataset.map((entry) => entry.name), + datasets[0].id, + metricHues, + coordinateType, + groupBy + )} + + {coordinateType === 'CARTESIAN' && ( + <> + + + )} - - + ds.label)} - generateColor={generateColorByMetric} - formatMetric={humanReadableMetric} - metricHues={metricHues} - /> - } - cursor="pointer" - /> - {!disableLegend && ( - { - const [metric, datasetId] = value.split('-') - const currentDataset = datasets.find( - (ds) => ds.id === parseInt(datasetId, 10) - ) - - const datasetLabel = currentDataset.label - - const lastSelectedMetric = [...currentDataset.metrics] - .reverse() - .find((m) => selectedMetrics[m.key]) - - if (lastSelectedMetric && metric === lastSelectedMetric.key) { - return `${humanReadableMetric(metric)} (${datasetLabel})` - } - - return humanReadableMetric(metric) - }} - wrapperStyle={{ - wordWrap: 'break-word', - maxWidth: '100%', - }} - /> - )} - - {datasets.map((dataset) => - dataset.metrics.map((metric) => - selectedMetrics[metric.key] ? ( - ds.label)} + generateColor={generateColorByMetric} + formatMetric={humanReadableMetric} + metricHues={metricHues} /> - ) : null + ) : ( + + ) + } + cursor={coordinateType === 'CARTESIAN' ? 'pointer' : false} + /> + + {!disableLegend && + (coordinateType === 'CARTESIAN' ? ( + { + const [metric, datasetId] = value.split('-') + const currentDataset = datasets.find( + (ds) => ds.id === parseInt(datasetId, 10) + ) + + const datasetLabel = currentDataset.label + + const lastSelectedMetric = [...currentDataset.metrics] + .reverse() + .find((m) => selectedMetrics[m.key]) + + if (lastSelectedMetric && metric === lastSelectedMetric.key) { + return `${humanReadableMetric(metric)} (${datasetLabel})` + } + + return humanReadableMetric(metric) + }} + wrapperStyle={{ + wordWrap: 'break-word', + maxWidth: '100%', + }} + /> + ) : ( + + value + .split('_') + .map( + (word) => + word.charAt(0).toUpperCase() + + word.slice(1).toLowerCase() + ) + .join(' ') + } + iconSize={12} + layout="vertical" + verticalAlign="middle" + wrapperStyle={{ + top: -60, + left: 0, + lineHeight: '30px', + }} + /> + ))} + {coordinateType === 'CARTESIAN' ? ( + datasets.map((dataset) => + dataset.metrics.map((metric) => + selectedMetrics[metric.key] ? ( + + ) : null + ) ) + ) : ( + )} )} @@ -188,8 +268,14 @@ export const ChartRenderer = ({ } ChartRenderer.propTypes = { - chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table', 'stackedBar']) - .isRequired, + chartType: PropTypes.oneOf([ + 'bar', + 'line', + 'area', + 'table', + 'stackedBar', + 'radialBar', + ]).isRequired, datasets: PropTypes.arrayOf(PropTypes.object).isRequired, selectedMetrics: PropTypes.object.isRequired, customChartDefs: PropTypes.func.isRequired, @@ -197,11 +283,14 @@ ChartRenderer.propTypes = { tableColumns: PropTypes.arrayOf(PropTypes.object), humanReadableMetric: PropTypes.func.isRequired, groupBy: PropTypes.string.isRequired, + coordinateType: PropTypes.string, metricHues: PropTypes.objectOf(PropTypes.number).isRequired, disableLegend: PropTypes.bool, + onElementClick: PropTypes.func, } ChartRenderer.defaultProps = { - groupBy: 'NAME', + groupBy: 'pct', disableLegend: false, + coordinateType: 'CARTESIAN', } diff --git a/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/PolarChart.js b/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/PolarChart.js new file mode 100644 index 0000000000..a3ec25dc80 --- /dev/null +++ b/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/PolarChart.js @@ -0,0 +1,132 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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 { Component } from 'react' +import PropTypes from 'prop-types' +import { Box, Paper, Typography } from '@mui/material' +import { isDevelopment } from 'client/utils' + +/** + * Formats the input data for use in a polar chart. + * + * @param {Array|object} input - The data to be formatted. + * @returns {Array} The formatted dataset. + */ +export const FormatPolarDataset = (input) => { + const logError = (message) => { + if (isDevelopment) console.error(message) + } + + if (!Array.isArray(input) || input.length === 0 || !input[0].data) { + logError('FormatPolarDataset: Invalid input format.') + + return [] + } + + const dataset = input[0] + + if (!Array.isArray(dataset.data) || dataset.data.length === 0) { + logError('FormatPolarDataset: No data available.') + + return [] + } + + const dataPoint = dataset.data[0] + + Object.keys(dataPoint).forEach((key) => { + if (isNaN(parseFloat(dataPoint[key])) && dataPoint[key] !== null) { + logError( + `FormatPolarDataset: Non-numeric value encountered for key ${key}.` + ) + + return [] + } + }) + + const pairs = Object.keys(dataPoint) + .filter( + (key) => + key.endsWith('_USED') && + Object.keys(dataPoint).includes(key.replace('_USED', '')) + ) + .map((usedKey) => ({ usedKey, totalKey: usedKey.replace('_USED', '') })) + + return pairs.map(({ usedKey, totalKey }) => { + const uv = parseFloat(dataPoint[usedKey]) + const pv = parseFloat(dataPoint[totalKey]) + const pct = pv > 0 ? (uv / pv) * 100 : 0 + + return { + name: totalKey, + uv: uv.toFixed(2), + pv: pv.toFixed(2), + pct: pct.toFixed(2), + } + }) +} + +FormatPolarDataset.propTypes = { + input: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.object), + PropTypes.object, + ]).isRequired, +} + +/** + * Renders a tooltip for a polar chart. + * + * @param {object} props - The properties for the component. + * @param {boolean} props.active - Indicates whether the tooltip is active. + * @param {Array} props.payload - The data for the tooltip. + * @returns {Component|null} The rendered tooltip component or null. + */ +export const PolarTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + const data = payload[0].payload + + return ( + + + + {data.name + .split('_') + .map( + (word) => + word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ) + .join(' ')} + + + {data?.uv} / {data?.pv} + + {data?.pct ?? 0}% + + + ) + } + + return null +} + +PolarTooltip.propTypes = { + active: PropTypes.bool, + payload: PropTypes.arrayOf(PropTypes.object), +} diff --git a/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/index.js b/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/index.js index c65b4df6b6..07c745f5fe 100644 --- a/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/index.js +++ b/src/fireedge/src/client/components/Charts/MultiChart/helpers/subComponents/index.js @@ -16,5 +16,15 @@ import { ChartRenderer } from 'client/components/Charts/MultiChart/helpers/subComponents/ChartRenderer' import { NavigationController } from 'client/components/Charts/MultiChart/helpers/subComponents/NavigationController' import { ExportButton } from 'client/components/Charts/MultiChart/helpers/subComponents/Exporter' +import { + FormatPolarDataset, + PolarTooltip, +} from 'client/components/Charts/MultiChart/helpers/subComponents/PolarChart' -export { ChartRenderer, NavigationController, ExportButton } +export { + ChartRenderer, + ExportButton, + FormatPolarDataset, + NavigationController, + PolarTooltip, +} diff --git a/src/fireedge/src/client/components/Charts/MultiChart/index.js b/src/fireedge/src/client/components/Charts/MultiChart/index.js index 2c0429b2fc..38ce449b8c 100644 --- a/src/fireedge/src/client/components/Charts/MultiChart/index.js +++ b/src/fireedge/src/client/components/Charts/MultiChart/index.js @@ -14,18 +14,18 @@ * limitations under the License. * * ------------------------------------------------------------------------- */ import PropTypes from 'prop-types' -import React, { useState, useMemo, useEffect } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Box } from '@mui/material' import { ChartRenderer, - NavigationController, ExportButton, + NavigationController, } from 'client/components/Charts/MultiChart/helpers/subComponents' import { LoadingDisplay } from 'client/components/LoadingState' import { - processDataForChart, GetChartDefs, + processDataForChart, } from 'client/components/Charts/MultiChart/helpers/scripts' /** @@ -45,6 +45,9 @@ import { * @param {object} props.metricHues - Object containing hue values for different metrics. * @param {boolean} props.disableExport - Enable/Disable export button. * @param {boolean} props.disableLegend - Disables the legend underneath the charts. + * @param {string} props.coordinateType - Cartesian or polar coordinate system. + * @param {boolean} props.disableNavController - Disable navigation controls. + * @param {Function} props.onElementClick - OnClick callback function for the clicked element. * @returns {React.Component} MultiChart component. */ const MultiChart = ({ @@ -59,9 +62,12 @@ const MultiChart = ({ tableColumns, customChartDefs, metricNames, + coordinateType, groupBy, disableExport, + disableNavController, disableLegend, + onElementClick, metricHues: passedMetricHues, }) => { const [currentPage, setCurrentPage] = useState(0) @@ -185,31 +191,32 @@ const MultiChart = ({ return ( - {chartType !== 'table' && ( - - setCurrentPage((prev) => Math.max(prev - 1, 0))} - onNext={() => - setCurrentPage((prev) => - Math.min( - prev + 1, - Math.ceil(selectedXValues.length / ItemsPerPage) - 1 + {chartType !== 'table' && + (!disableNavController ? ( + + setCurrentPage((prev) => Math.max(prev - 1, 0))} + onNext={() => + setCurrentPage((prev) => + Math.min( + prev + 1, + Math.ceil(selectedXValues.length / ItemsPerPage) - 1 + ) ) - ) - } - isPrevDisabled={currentPage === 0} - isNextDisabled={ - currentPage === - Math.ceil(selectedXValues.length / ItemsPerPage) - 1 - } - selectedItems={selectedXValues} - items={xAxisLabels} - setSelectedItems={setSelectedXValues} - isFilterDisabled={isFilterDisabled} - isPaginationDisabled={isPaginationDisabled} - /> - - )} + } + isPrevDisabled={currentPage === 0} + isNextDisabled={ + currentPage === + Math.ceil(selectedXValues.length / ItemsPerPage) - 1 + } + selectedItems={selectedXValues} + items={xAxisLabels} + setSelectedItems={setSelectedXValues} + isFilterDisabled={isFilterDisabled} + isPaginationDisabled={isPaginationDisabled} + /> + + ) : null)} {!disableExport && ( @@ -229,9 +236,11 @@ const MultiChart = ({ tableColumns={tableColumns} paginatedData={paginatedData} humanReadableMetric={humanReadableMetric} + coordinateType={coordinateType} groupBy={groupBy} metricHues={metricHues} disableLegend={disableLegend} + onElementClick={onElementClick} /> )} @@ -256,13 +265,23 @@ MultiChart.propTypes = { ).isRequired, visibleDatasets: PropTypes.arrayOf(PropTypes.number), xAxisLabels: PropTypes.arrayOf(PropTypes.string), - chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table', 'stackedBar']), + chartType: PropTypes.oneOf([ + 'bar', + 'line', + 'area', + 'table', + 'stackedBar', + 'radialBar', + ]), selectedMetrics: PropTypes.object, error: PropTypes.string, isLoading: PropTypes.bool, groupBy: PropTypes.string, + coordinateType: PropTypes.string, disableExport: PropTypes.bool, + disableNavController: PropTypes.bool, disableLegend: PropTypes.bool, + onElementClick: PropTypes.func, ItemsPerPage: PropTypes.number.isRequired, tableColumns: PropTypes.arrayOf(PropTypes.object), customChartDefs: PropTypes.func.isRequired, @@ -274,11 +293,14 @@ MultiChart.defaultProps = { chartType: 'bar', ItemsPerPage: 10, groupBy: 'ID', + coordinateType: 'CARTESIAN', customChartDefs: GetChartDefs, metricNames: {}, metricHues: {}, disableExport: false, disableLegend: false, + onElementClick: () => {}, + disableNavController: false, } export default MultiChart diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/QuotaControls.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/QuotaControls.js index f113cab10e..05adecd115 100644 --- a/src/fireedge/src/client/components/Tabs/User/Quota/Components/QuotaControls.js +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/QuotaControls.js @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * * limitations under the License. * * ------------------------------------------------------------------------- */ -import { memo, useMemo, useEffect, useReducer } from 'react' +import { memo, useMemo, useEffect, useState, useCallback } from 'react' import PropTypes from 'prop-types' import { Grid, @@ -21,18 +21,33 @@ import { InputLabel, Select, MenuItem, - TextField, Button, Box, Typography, - InputAdornment, - IconButton, + useTheme, } from '@mui/material' -import { Cancel } from 'iconoir-react' -import { useUpdateUserQuotaMutation } from 'client/features/OneApi/user' +import { + useGetUserQuery, + useUpdateUserQuotaMutation, +} from 'client/features/OneApi/user' + import { useGeneralApi } from 'client/features/General' import { T } from 'client/constants' +import { + validateResourceId, + validateValue, + useQuotaControlReducer, + getConcatenatedValues, + getExistingValue, + quotaIdentifiers, + handleApplyGlobalQuotas, +} from 'client/components/Tabs/User/Quota/Components/helpers/scripts' + +import { + HybridInputField, + ResourceIDAutocomplete, +} from 'client/components/Tabs/User/Quota/Components/helpers/subcomponents' /** * QuotaControls Component @@ -42,115 +57,157 @@ import { T } from 'client/constants' * @param {string} props.userId - User ID * @param {string} props.selectedType - Selected quota type * @param {Function} props.setSelectedType - Function to set selected quota type + * @param {Array} props.existingResourceIDs - Existing resource IDs + * @param {object} props.clickedElement - Clicked element data. + * @param {object} props.nameMaps - Resource name mappings. */ export const QuotaControls = memo( - ({ quotaTypes, userId, selectedType, setSelectedType }) => { - const initialState = { - globalIds: '', - selectedIdentifiers: [], - globalValue: '', - isValid: true, - isApplyDisabled: true, - } + ({ + quotaTypes, + userId, + selectedType, + setSelectedType, + existingData, + clickedElement, + nameMaps, + }) => { + const [state, actions] = useQuotaControlReducer() - // eslint-disable-next-line no-shadow - const reducer = (state, action) => { - switch (action.type) { - case 'SET_GLOBAL_IDS': - return { ...state, globalIds: action.payload } - case 'SET_SELECTED_IDENTIFIERS': - return { ...state, selectedIdentifiers: action.payload } - case 'SET_GLOBAL_VALUE': - return { ...state, globalValue: action.payload } - case 'SET_IS_VALID': - return { ...state, isValid: action.payload } - case 'SET_IS_APPLY_DISABLED': - return { ...state, isApplyDisabled: action.payload } - default: - return state - } - } - - const [state, dispatch] = useReducer(reducer, initialState) + const [popoverAnchorEl, setPopoverAnchorEl] = useState(null) + const [touchedFields, setTouchedFields] = useState({}) const { enqueueError, enqueueSuccess } = useGeneralApi() const [updateUserQuota] = useUpdateUserQuotaMutation() + const { palette } = useTheme() useEffect(() => { - dispatch({ type: 'SET_SELECTED_IDENTIFIERS', payload: [] }) + if (!clickedElement) return + + if (selectedType === 'VM' && actions.setSelectedIdentifier) { + if (clickedElement.name && state.selectedIdentifier !== undefined) { + actions.setSelectedIdentifier(clickedElement.name) + } + + return + } + + if (actions.setGlobalIds && Array.isArray(state.globalIds)) { + const { ID } = clickedElement + const isElementSelected = state.globalIds.includes(ID) + actions.setGlobalIds( + isElementSelected + ? state.globalIds.filter((id) => id !== ID) + : [...state.globalIds, ID] + ) + } + }, [clickedElement]) + + useMemo(() => { + actions.setSelectedIdentifier('') }, [selectedType]) useEffect(() => { - const isValueNumeric = - !isNaN(state.globalValue) && state.globalValue !== '' - const isApplyValid = + actions.setGlobalIds([]) + actions.setGlobalValue('') + actions.setMarkForDeletion([]) + }, [selectedType]) + + const getNewValues = useCallback(() => { + let newValues + if (selectedType === 'VM') { + const identifier = state.selectedIdentifier + newValues = { + [identifier]: getExistingValue( + null, + identifier, + selectedType, + existingData + ), + } + } else { + newValues = existingData.reduce((acc, item) => { + const identifier = state.selectedIdentifier + acc[item.ID] = item[identifier] || '' + + return acc + }, {}) + } + + return newValues + }, [existingData, selectedType, state.selectedIdentifier]) + + useEffect(() => { + const newValues = getNewValues() + actions.setValues(newValues) + if (state.globalIds.length === 1 || selectedType === 'VM') { + // const existingValue = getExistingValue( + // selectedType === 'VM' ? null : state.globalIds[0], + // state.selectedIdentifier + // ) + actions.setGlobalValue(newValues?.[state?.selectedIdentifier]) + } + }, [getNewValues, state.globalIds, selectedType, state.selectedIdentifier]) + + useEffect(() => { + const isApplyEnabledForVM = + selectedType === 'VM' && validateValue(state.globalValue) + const isApplyEnabledForOthers = state.isValid && selectedType && - state.selectedIdentifiers.length > 0 && - isValueNumeric - dispatch({ type: 'SET_IS_APPLY_DISABLED', payload: !isApplyValid }) + state.selectedIdentifier.length > 0 && + (state.globalIds.length > 0 || selectedType === 'VM') && + validateValue(state.globalValue) + const isApplyValid = + selectedType === 'VM' ? isApplyEnabledForVM : isApplyEnabledForOthers + actions.setIsApplyDisabled(!isApplyValid) }, [ state.isValid, selectedType, - state.selectedIdentifiers, + state.selectedIdentifier, state.globalValue, + state.globalIds.length, ]) - const quotaIdentifiers = useMemo( - () => ({ - VM: [ - { id: 'VMS', displayName: 'Virtual Machines' }, - { id: 'RUNNING_VMS', displayName: 'Running VMs' }, - { id: 'MEMORY', displayName: 'Memory' }, - { id: 'RUNNING_MEMORY', displayName: 'Running Memory' }, - { id: 'CPU', displayName: 'CPU' }, - { id: 'RUNNING_CPU', displayName: 'Running CPU' }, - { id: 'SYSTEM_DISK_SIZE', displayName: 'System Disk Size' }, - ], - DATASTORE: [ - { id: 'SIZE', displayName: 'Size' }, - { id: 'IMAGES', displayName: 'Images' }, - ], - NETWORK: [{ id: 'LEASES', displayName: 'Leases' }], - IMAGE: [{ id: 'RVMS', displayName: 'Running VMs' }], - }), - [] + useEffect(() => { + const allValuesAreValid = state.globalIds.every((id) => + validateValue(state.values[id] || '') + ) + + actions.setIsValid(allValuesAreValid) + }, [state.globalIds, state.values]) + + useEffect(() => { + if (state.globalIds.length === 1) { + const singleGlobalId = state.globalIds[0] + const singleGlobalValue = state.values[singleGlobalId] || '' + actions.setGlobalValue(singleGlobalValue) + } + }, [state.globalIds, state.values]) + + const existingTemplate = useGetUserQuery({ id: userId }) + + const filteredResourceIDs = useMemo( + () => + existingData + ?.map(({ ID }) => ID) + ?.filter((id) => !state.globalIds.includes(id)), + [existingData, state.globalIds] ) - const validateResourceIds = (value) => { - const regex = /^(\d+)(,\d+)*$/ - const valid = regex.test(value) || value === '' - dispatch({ type: 'SET_IS_VALID', payload: valid }) - } - - const handleApplyGlobalQuotas = () => { - const idsArray = state.globalIds.split(',').map((id) => id.trim()) - - const quota = {} - state.selectedIdentifiers.forEach((identifier) => { - quota[identifier] = state.globalValue - }) - - state.isValid && - idsArray.forEach(async (resourceId) => { - const xmlData = quotasToXml(selectedType, resourceId, quota) - let result - try { - result = await updateUserQuota({ id: userId, template: xmlData }) - } catch (error) { - // result = { error: error.message } - } - - if (result && result.error) { - enqueueError(`Error updating quota for ID ${resourceId}`) // ${result.error}, currently excluded since message is globally transmitted - } else { - enqueueSuccess(`Quota for ID ${resourceId} updated successfully!`) - } - }) - } - return ( - - + + Type - dispatch({ - type: 'SET_SELECTED_IDENTIFIERS', - payload: e.target.value, - }) - } + onChange={(e) => actions.setSelectedIdentifier(e.target.value)} label="Identifiers" - renderValue={(selected) => { - const selectedNames = selected - .map((value) => { - const foundItem = quotaIdentifiers[selectedType]?.find( - (item) => item.id === value - ) - - return foundItem ? foundItem.displayName : '' - }) - .filter(Boolean) - - let displayText = selectedNames.join(', ') - - const maxLength = 30 - if (displayText.length > maxLength) { - displayText = `${displayText.substring(0, maxLength)}...` - } - - return displayText - }} > {quotaIdentifiers[selectedType]?.map(({ id, displayName }) => ( {displayName} @@ -274,35 +278,17 @@ export const QuotaControls = memo( - - dispatch({ type: 'SET_GLOBAL_VALUE', payload: e.target.value }) - } - variant="outlined" - fullWidth - InputProps={{ - inputProps: { - style: { padding: '16px' }, - 'data-cy': 'qc-value-input', - }, - endAdornment: state.globalValue && ( - - - dispatch({ type: 'SET_GLOBAL_VALUE', payload: '' }) - } - sx={{ - marginRight: '-4px', - }} - > - - - - ), - }} + @@ -310,7 +296,19 @@ export const QuotaControls = memo( variant="contained" color="primary" fullWidth - onClick={handleApplyGlobalQuotas} + onClick={() => + handleApplyGlobalQuotas( + state, + existingTemplate, + selectedType, + actions, + userId, + updateUserQuota, + enqueueError, + enqueueSuccess, + nameMaps + ) + } disabled={state.isApplyDisabled} size={'large'} data-cy={'qc-apply-button'} @@ -327,8 +325,28 @@ export const QuotaControls = memo( How to use Quota Controls:
  • Select the quota type from the dropdown.
  • -
  • Enter Resource IDs, separated by commas.
  • -
  • Select identifiers for the quota.
  • + + (Optional) Individual Resource Quotas . + + + + Select identifiers for the quota. + +
  • Enter the value for the selected quota.
  • Click Apply to set the quotas.
@@ -340,26 +358,6 @@ export const QuotaControls = memo( } ) -/** - * Convert quota data to XML format. - * - * @param {string} type - Quota type. - * @param {string} resourceId - Resource ID - * @param {object} quota - Quota data. - * @returns {string} XML representation of the quota. - */ -const quotasToXml = (type, resourceId, quota) => { - let innerXml = '' - - for (const [key, value] of Object.entries(quota)) { - innerXml += `<${key.toUpperCase()}>${value}` - } - - const finalXml = `` - - return finalXml -} - QuotaControls.displayName = 'QuotaControls' QuotaControls.propTypes = { @@ -372,4 +370,11 @@ QuotaControls.propTypes = { userId: PropTypes.string.isRequired, selectedType: PropTypes.string, setSelectedType: PropTypes.func, + existingData: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + clickedElement: PropTypes.object, + nameMaps: PropTypes.object, +} + +QuotaControls.defaultProps = { + existingData: [], } diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/common.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/common.js new file mode 100644 index 0000000000..5f15ee73ad --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/common.js @@ -0,0 +1,223 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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. * + * ------------------------------------------------------------------------- */ + +/** + * @param {Array} values - Array of values + * @param {Array} globalIds - Global ids array + * @param {Array} markedForDeletion - Array of ids to delete + * @returns {string} - Display value string + */ +export const getConcatenatedValues = (values, globalIds, markedForDeletion) => + globalIds + .map((id) => (markedForDeletion.includes(id) ? 'Delete' : values[id]) || '') + .filter((value) => value !== '') + .join(', ') + +/** + * @param {number} resourceId - Resource id + * @param {string} identifier - Quota identifier + * @param {string} selectedType - Selected quota type + * @param {Array} existingData - Existing resource data + * @returns {object} Resource data + */ +export const getExistingValue = ( + resourceId, + identifier, + selectedType, + existingData = [] +) => { + if (selectedType === 'VM') { + const vmQuotaObject = existingData[0] + + return vmQuotaObject ? vmQuotaObject[identifier] : '' + } else { + const resourceData = existingData.find((data) => data.ID === resourceId) + + return resourceData ? resourceData[identifier] : '' + } +} + +const findIdByName = (nameMaps, selectedType, resourceNameOrId) => { + if (!isNaN(parseInt(resourceNameOrId, 10))) { + return resourceNameOrId + } + + const typeMap = nameMaps[selectedType] || {} + const foundEntry = Object.entries(typeMap).find( + ([id, name]) => name === resourceNameOrId + ) + + return foundEntry ? foundEntry[0] : resourceNameOrId +} + +/** + * @param {object} state - The current state object containing the quota information. + * @param {object} existingTemplate - The existing quota template data to compare against. + * @param {string} selectedType - The type of quota selected (e.g., 'VM', 'DATASTORE'). + * @param {object} actions - An object containing reducer actions for updating state. + * @param {number|string} userId - The ID of the user whose quota is being updated. + * @param {Function} updateUserQuota - The mutation function to call for updating the quota. + * @param {Function} enqueueError - Function to enqueue an error notification. + * @param {Function} enqueueSuccess - Function to enqueue a success notification. + * @param {object} nameMaps - Object containing the mappings of resource IDs to their names. + * @returns {Promise} A promise that resolves when the operation is complete. + */ +export const handleApplyGlobalQuotas = async ( + state, + existingTemplate, + selectedType, + actions, + userId, + updateUserQuota, + enqueueError, + enqueueSuccess, + nameMaps +) => { + if (!state.isValid) return + + const getExistingQuota = (quotaType, resourceId, existingTemplateData) => { + const quotaKey = `${quotaType}_QUOTA` + const quotaData = existingTemplateData[quotaKey] + + if (!Array.isArray(quotaData) && quotaType === 'VM') { + return quotaData || {} + } else if (Array.isArray(quotaData)) { + return ( + quotaData?.find((q) => q?.ID?.toString() === resourceId.toString()) || + {} + ) + } + + return ( + [quotaData]?.find((q) => q?.ID?.toString() === resourceId.toString()) || + {} + ) + } + + const applyQuotaChange = async (resourceIdOrName, value) => { + try { + const actualId = + findIdByName(nameMaps, selectedType, resourceIdOrName) || + resourceIdOrName + + const quota = { [state.selectedIdentifier]: value } + const existingQuota = getExistingQuota( + selectedType, + actualId, + existingTemplate?.data + ) + + const xmlData = quotasToXml(selectedType, actualId, { + ...existingQuota, + ...quota, + }) + const result = await updateUserQuota({ id: userId, template: xmlData }) + + if (result.error) { + throw new Error(result.error.message) + } + enqueueSuccess(`Quota updated successfully for ID ${actualId}`) + } catch (error) { + enqueueError( + `Error updating quota for ID ${resourceIdOrName}: ${error.message}` + ) + } + } + + if (selectedType === 'VM') { + const vmValue = state.globalValue || '' + await applyQuotaChange(0, vmValue) + } else { + for (const resourceId of state.globalIds) { + const isMarkedForDeletion = state.markedForDeletion.includes(resourceId) + const value = isMarkedForDeletion ? null : state.values[resourceId] + if (value !== undefined && value !== '') { + await applyQuotaChange(resourceId, value) + } else { + enqueueError(`No value specified for Resource ID ${resourceId}`) + } + } + } + + // Clear state after all updates are attempted + actions.setGlobalIds([]) + actions.setGlobalValue('') + actions.setValues({}) + state?.markedForDeletion?.forEach((id) => actions.setUnmarkForDeletion(id)) +} + +/** + * Converts an array of resources or a single resource object + * into an object mapping IDs to names. + * + * @param {object | Array} dataPool - The resource data pool from the API response. + * @returns {object} - An object mapping resource IDs to their names. + */ +export const nameMapper = (dataPool) => { + if (dataPool?.isSuccess) { + const resources = Array.isArray(dataPool.data) + ? dataPool.data + : [dataPool.data] + + return resources.reduce((map, resource) => { + if (resource.ID && resource.NAME) { + map[resource.ID] = resource.NAME + } + + return map + }, {}) + } + + return {} +} + +/** + * Convert quota data to XML format. + * + * @param {string} type - Quota type. + * @param {string} resourceId - Resource ID + * @param {object} quota - Quota data. + * @returns {string} XML representation of the quota. + */ +const quotasToXml = (type, resourceId, quota) => { + let innerXml = '' + + for (const [key, value] of Object.entries(quota)) { + innerXml += `<${key.toUpperCase()}>${value}` + } + + const finalXml = `` + + return finalXml +} + +export const quotaIdentifiers = { + VM: [ + { id: 'VMS', displayName: 'Virtual Machines' }, + { id: 'RUNNING_VMS', displayName: 'Running VMs' }, + { id: 'MEMORY', displayName: 'Memory' }, + { id: 'RUNNING_MEMORY', displayName: 'Running Memory' }, + { id: 'CPU', displayName: 'CPU' }, + { id: 'RUNNING_CPU', displayName: 'Running CPU' }, + { id: 'SYSTEM_DISK_SIZE', displayName: 'System Disk Size' }, + ], + DATASTORE: [ + { id: 'SIZE', displayName: 'Size' }, + { id: 'IMAGES', displayName: 'Images' }, + ], + NETWORK: [{ id: 'LEASES', displayName: 'Leases' }], + IMAGE: [{ id: 'RVMS', displayName: 'Running VMs' }], +} diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/index.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/index.js new file mode 100644 index 0000000000..7d57bf7937 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/index.js @@ -0,0 +1,36 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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 { validateResourceId, validateValue } from './validation' +import { useQuotaControlReducer } from './reducer/useQuotaControlReducer' +import { + getConcatenatedValues, + getExistingValue, + quotaIdentifiers, + handleApplyGlobalQuotas, + nameMapper, +} from './common' + +export { + validateResourceId, + validateValue, + useQuotaControlReducer, + getConcatenatedValues, + getExistingValue, + quotaIdentifiers, + handleApplyGlobalQuotas, + nameMapper, +} diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/actions.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/actions.js new file mode 100644 index 0000000000..6945a72cf0 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/actions.js @@ -0,0 +1,99 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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. * + * ------------------------------------------------------------------------- */ +/** + * Action creator for setting global IDs in the state. The action contains the new globalIds to be set. + * + * @param {number[]|string[]} globalIds - The new array of global IDs to be updated in the state. + * @returns {{ type: string, payload: number[]|string[] }} The action object to dispatch. + */ +export const setGlobalIds = (globalIds) => ({ + type: 'SET_GLOBAL_IDS', + payload: globalIds, +}) + +/** + * Action creator for setting the selected identifier in the state. The action contains the selectedIdentifier to be set. + * + * @param {number|string} selectedIdentifier - The identifier that has been selected. + * @returns {{ type: string, payload: number|string }} The action object to dispatch. + */ +export const setSelectedIdentifier = (selectedIdentifier) => ({ + type: 'SET_SELECTED_IDENTIFIER', + payload: selectedIdentifier, +}) + +/** + * Action creator for setting a global value in the state. The action contains the globalValue to be set. + * + * @param {any} globalValue - The new value to update in the state. + * @returns {{ type: string, payload: any }} The action object to dispatch. + */ +export const setGlobalValue = (globalValue) => ({ + type: 'SET_GLOBAL_VALUE', + payload: globalValue, +}) + +/** + * Action creator for setting multiple values in the state at once. The action contains the values object to be set. + * + * @param {object} values - An object containing multiple values to update in the state. + * @returns {{type: string, payload: object}} The action object to dispatch. + */ +export const setValues = (values) => ({ type: 'SET_VALUES', payload: values }) + +/** + * Action creator to mark a resource ID for deletion in the state. + * + * @param {number|string} resourceId - The ID of the resource to be marked for deletion. + * @returns {{ type: string, payload: number|string }} The action object to dispatch. + */ +export const setMarkForDeletion = (resourceId) => ({ + type: 'MARK_FOR_DELETION', + payload: resourceId, +}) + +/** + * Action creator to unmark a resource ID from being marked for deletion in the state. + * + * @param {number|string} resourceId - The ID of the resource to unmark for deletion. + * @returns {{ type: string, payload: number|string }} The action object to dispatch. + */ +export const setUnmarkForDeletion = (resourceId) => ({ + type: 'UNMARK_FOR_DELETION', + payload: resourceId, +}) + +/** + * Action creator for setting the 'isValid' flag in the state, indicating whether the current state is valid or not. + * + * @param {boolean} isValid - Boolean flag indicating the validity of the state. + * @returns {{ type: string, payload: boolean }} The action object to dispatch. + */ +export const setIsValid = (isValid) => ({ + type: 'SET_IS_VALID', + payload: isValid, +}) + +/** + * Action creator for setting the 'isApplyDisabled' flag in the state, which controls whether the apply action is disabled. + * + * @param {boolean} isDisabled - Boolean flag indicating if the apply action should be disabled. + * @returns {{ type: string, payload: boolean }} The action object to dispatch. + */ +export const setIsApplyDisabled = (isDisabled) => ({ + type: 'SET_IS_APPLY_DISABLED', + payload: isDisabled, +}) diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/definitions.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/definitions.js new file mode 100644 index 0000000000..77f34a392b --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/definitions.js @@ -0,0 +1,61 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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 initialState = { + globalIds: [], + markedForDeletion: [], + selectedIdentifier: '', + globalValue: '', + values: {}, + isValid: true, + isApplyDisabled: true, +} + +/** + * @param {object} state - State variable. + * @param {object} action - Action object. + * @returns {object} - New state + */ +export const reducer = (state, action) => { + switch (action.type) { + case 'SET_GLOBAL_IDS': + return { ...state, globalIds: action.payload } + case 'SET_SELECTED_IDENTIFIER': + return { ...state, selectedIdentifier: action.payload } + case 'SET_GLOBAL_VALUE': + return { ...state, globalValue: action.payload } + case 'SET_IS_VALID': + return { ...state, isValid: action.payload } + case 'SET_IS_APPLY_DISABLED': + return { ...state, isApplyDisabled: action.payload } + case 'SET_VALUES': + return { ...state, values: action.payload } + case 'MARK_FOR_DELETION': + return { + ...state, + markedForDeletion: [...state.markedForDeletion, action.payload], + } + case 'UNMARK_FOR_DELETION': + return { + ...state, + markedForDeletion: state.markedForDeletion.filter( + (id) => id !== action.payload + ), + } + + default: + return state + } +} diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/useQuotaControlReducer.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/useQuotaControlReducer.js new file mode 100644 index 0000000000..93faa3e179 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/reducer/useQuotaControlReducer.js @@ -0,0 +1,33 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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 { useReducer } from 'react' +import * as actionCreators from './actions' +import { reducer, initialState } from './definitions' + +/** + *@returns {Array} - Array containing state and an actions object. + */ +export const useQuotaControlReducer = () => { + const [state, dispatch] = useReducer(reducer, initialState) + + const boundActionCreators = Object.keys(actionCreators).reduce((acc, key) => { + acc[key] = (...args) => dispatch(actionCreators[key](...args)) + + return acc + }, {}) + + return [state, boundActionCreators] +} diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/validation.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/validation.js new file mode 100644 index 0000000000..c520ab1483 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/scripts/validation.js @@ -0,0 +1,35 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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. * + * ------------------------------------------------------------------------- */ + +/** + * @param {number} value - Value to validate + * @param {Array} globalIds - Global ids array + * @param {Function} callback - State update + * @returns {boolean} - Is valid? + */ +export const validateResourceId = (value, globalIds, callback) => { + const regex = /^\d+$/ + const isValid = regex.test(value) && !globalIds.includes(value) + callback(isValid) + + return isValid +} + +/** + * @param {number} value - Value to validate + * @returns {boolean} - Is valid? + */ +export const validateValue = (value) => value === 'Delete' || !isNaN(value) diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/HybridInput.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/HybridInput.js new file mode 100644 index 0000000000..fbed0ca989 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/HybridInput.js @@ -0,0 +1,216 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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 PropTypes from 'prop-types' +import { Component } from 'react' +import { + TextField, + InputAdornment, + IconButton, + Popover, + Paper, + Grid, +} from '@mui/material' +import { Cancel } from 'iconoir-react' + +/** + * @param {object} props - The props for the component. + * @param {string} props.selectedType - The currently selected quota type. + * @param {object} props.state - The state object containing various state indicators. + * @param {object} props.actions - An object containing reducer actions to mutate the state. + * @param {Function} props.validateValue - A function to validate the input value. + * @param {Function} props.getConcatenatedValues - A function to concatenate multiple values. + * @param {Function} props.setPopoverAnchorEl - A function to set the anchor for the popover. + * @param {HTMLElement} props.popoverAnchorEl - The anchor element for the popover. + * @param {object} props.palette - The MUI theme palette. + * @param {object} props.touchedFields - An object representing the touched state of fields. + * @param {Function} props.setTouchedFields - A function to set fields as touched upon interaction. + * @returns {Component} - Input component + */ +export const HybridInputField = ({ + selectedType, + state, + actions, + validateValue, + getConcatenatedValues, + setPopoverAnchorEl, + popoverAnchorEl, + palette, + touchedFields, + setTouchedFields, +}) => { + const isDisabled = () => + state.selectedIdentifier === '' || + (selectedType !== 'VM' && state.globalIds?.length === 0) || + (state.globalIds?.length === 1 && + state.markedForDeletion.includes(state.globalIds[0])) + + const getValue = () => { + if (selectedType === 'VM') { + return state.globalValue + } else if (state.globalIds.length > 1) { + return getConcatenatedValues( + state.values, + state.globalIds, + state.markedForDeletion + ) + } else { + return state.markedForDeletion.includes(state.globalIds[0]) + ? 'Delete' + : state.globalValue + } + } + + return ( + <> + { + const value = e.target.value + if (validateValue(value)) { + if (state.globalIds.length === 1 || selectedType === 'VM') { + actions.setGlobalValue(value) + if (selectedType !== 'VM') { + actions.setValues({ + ...state.values, + [state?.globalIds[0]]: value, + }) + } + } else { + const updatedValues = { ...state.values } + state.globalIds.forEach((id) => { + if (!state.markedForDeletion.includes(id)) { + updatedValues[id] = value + } + }) + actions.setValues(updatedValues) + } + } + }} + onClick={(event) => { + if (state.globalIds.length > 1) { + setPopoverAnchorEl(event.currentTarget) + } + }} + variant="outlined" + fullWidth + InputProps={{ + inputProps: { + style: { padding: '16px' }, + 'data-cy': 'qc-value-input', + }, + endAdornment: state.globalValue && + state.globalIds.length <= 1 && + !state.markedForDeletion.includes(state.globalIds[0]) && ( + + actions.setGlobalValue('')} + sx={{ + marginRight: '-4px', + }} + > + + + + ), + }} + /> + setPopoverAnchorEl(null)} + anchorOrigin={{ vertical: 'center', horizontal: 'right' }} + transformOrigin={{ vertical: 'center', horizontal: 'left' }} + sx={{ + '& .MuiPaper-root': { + padding: 2, + maxWidth: '500px', + borderRadius: 2, + boxShadow: '0px 4px 20px rgba(0,0,0,0.1)', + }, + }} + > + + + {state.globalIds.map((id, index) => ( + + { + const value = e.target.value + if (validateValue(value)) { + actions.setValues({ ...state.values, [id]: value }) + } + }} + variant="outlined" + fullWidth + size="small" + error={touchedFields[id] && !validateValue(state.values[id])} + helperText={ + touchedFields[id] && + !validateValue(state.values[id]) && + 'Invalid value' + } + onBlur={() => + setTouchedFields((prev) => ({ ...prev, [id]: true })) + } + /> + + ))} + + + + + ) +} + +HybridInputField.propTypes = { + selectedType: PropTypes.string, + state: PropTypes.shape({ + selectedIdentifier: PropTypes.string, + globalIds: PropTypes.arrayOf(PropTypes.string), + globalValue: PropTypes.string, + markedForDeletion: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), + values: PropTypes.objectOf(PropTypes.string), + }), + actions: PropTypes.shape({ + setGlobalValue: PropTypes.func, + setValues: PropTypes.func, + setMarkForDeletion: PropTypes.func, + setUnmarkForDeletion: PropTypes.func, + }), + validateValue: PropTypes.func, + getConcatenatedValues: PropTypes.func, + setPopoverAnchorEl: PropTypes.func, + popoverAnchorEl: PropTypes.instanceOf(Element), + palette: PropTypes.object, + touchedFields: PropTypes.objectOf(PropTypes.bool), + setTouchedFields: PropTypes.func, +} diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/ResourceIDAutocomplete.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/ResourceIDAutocomplete.js new file mode 100644 index 0000000000..912d46b1bc --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/ResourceIDAutocomplete.js @@ -0,0 +1,205 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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 PropTypes from 'prop-types' +import { Component, useState } from 'react' +import { Autocomplete, Box, Chip, IconButton, TextField } from '@mui/material' +import { Trash } from 'iconoir-react' + +/** + * @param {object} root0 - Component + * @param {string} root0.selectedType - Selected Quota type. + * @param {object} root0.state - Variable states. + * @param {object} root0.actions - Reducer actions object. + * @param {Function} root0.validateResourceId - Validates resource IDs. + * @param {Array} root0.filteredResourceIDs - Filtered resource IDs. + * @param {object} root0.palette - MUI theme. + * @param {object} root0.nameMaps - Object containing name mappings for resources. + * @returns {Component} - Autocomplete input. + */ +export const ResourceIDAutocomplete = ({ + selectedType, + state, + actions, + validateResourceId, + filteredResourceIDs, + palette, + nameMaps, +}) => { + const [inputValue, setInputValue] = useState('') + + return ( + { + setInputValue(newInputValue) + }} + options={filteredResourceIDs} + getOptionLabel={(option) => + nameMaps[selectedType]?.[option.toString()] ?? option.toString() + } + freeSolo + value={state.globalIds ?? []} + disabled={selectedType === 'VM'} + onKeyDown={(event) => { + if (event.key === 'Enter' && event.target.value) { + event.preventDefault() + const newId = event.target.value + if (validateResourceId(newId, state?.globalIds, actions.setIsValid)) { + const newValue = [...state.globalIds, newId] + actions.setGlobalIds(newValue) + } + } + }} + renderOption={(option, { selected }) => ( + { + if (!state.globalIds.includes(option?.key)) { + const newValue = [...state.globalIds, option?.key] + actions.setGlobalIds(newValue) + } + }} + > + {option?.key} + { + event.stopPropagation() + if (!state.globalIds.includes(option?.key)) { + const newValue = [...state.globalIds, option?.key] + actions.setGlobalIds(newValue) + } + actions.setMarkForDeletion(option?.key) + }} + > + + + + )} + renderInput={(params) => ( + { + const value = event.target.value.trim() + if ( + value && + validateResourceId(value, state?.globalIds, actions.setIsValid) + ) { + if (!state.globalIds.includes(value)) { + actions.setGlobalIds([...state.globalIds, value]) + } + } + setInputValue('') + }} + variant="outlined" + label="Resource IDs" + placeholder={ + inputValue || state?.globalIds?.length > 1 + ? '' + : 'Select or type a Resource ID' + } + fullWidth + error={!state.isValid} + helperText={ + !state.isValid && + 'Invalid format or duplicate ID. Please enter a positive number.' + } + sx={{ + '.MuiOutlinedInput-root': { + minHeight: '56px', + }, + '.MuiOutlinedInput-input': { + padding: '16px', + }, + }} + /> + )} + onChange={(_event, value) => { + state.globalIds.forEach((id) => { + if (!value.includes(id)) { + actions.setUnmarkForDeletion(id) + } + }) + if (value.length === 0) { + actions.setValues({}) + actions.setGlobalIds([]) + actions.setGlobalValue('') + } + }} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + { + actions.setUnmarkForDeletion(option) + const newValue = state.globalIds.filter((id) => id !== option) + actions.setGlobalIds(newValue) + }} + /> + )) + } + /> + ) +} + +ResourceIDAutocomplete.propTypes = { + selectedType: PropTypes.string, + state: PropTypes.shape({ + globalIds: PropTypes.array, + isValid: PropTypes.bool, + markedForDeletion: PropTypes.array, + }), + actions: PropTypes.objectOf(PropTypes.func), + validateResourceId: PropTypes.func, + filteredResourceIDs: PropTypes.arrayOf(PropTypes.any), + palette: PropTypes.shape({ + action: PropTypes.shape({ + hover: PropTypes.string, + }), + error: PropTypes.shape({ + main: PropTypes.string, + }), + background: PropTypes.shape({ + default: PropTypes.string, + }), + }), + nameMaps: PropTypes.object, +} + +ResourceIDAutocomplete.defaultProps = { + palette: {}, +} diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/index.js b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/index.js new file mode 100644 index 0000000000..d89619e626 --- /dev/null +++ b/src/fireedge/src/client/components/Tabs/User/Quota/Components/helpers/subcomponents/index.js @@ -0,0 +1,19 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2023, OpenNebula Project, OpenNebula Systems * + * * + * Licensed 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 { ResourceIDAutocomplete } from './ResourceIDAutocomplete' +import { HybridInputField } from './HybridInput' + +export { ResourceIDAutocomplete, HybridInputField } diff --git a/src/fireedge/src/client/components/Tabs/User/Quota/index.js b/src/fireedge/src/client/components/Tabs/User/Quota/index.js index 3f7b1b2bf3..6e35297d2f 100644 --- a/src/fireedge/src/client/components/Tabs/User/Quota/index.js +++ b/src/fireedge/src/client/components/Tabs/User/Quota/index.js @@ -14,13 +14,16 @@ * limitations under the License. * * ------------------------------------------------------------------------- */ import PropTypes from 'prop-types' -import { Component, useState } from 'react' -import { Box, Grid, Card, CardContent, Typography } from '@mui/material' +import { Component, useState, useMemo } from 'react' +import { Box, Card, CardContent, Grid, Typography } from '@mui/material' import { MultiChart } from 'client/components/Charts' import { transformApiResponseToDataset } from 'client/components/Charts/MultiChart/helpers/scripts' import { QuotaControls } from 'client/components/Tabs/User/Quota/Components' +import { nameMapper } from 'client/components/Tabs/User/Quota/Components/helpers/scripts' import { useGetUserQuery } from 'client/features/OneApi/user' - +import { useGetDatastoresQuery } from 'client/features/OneApi/datastore' +import { useGetVNetworksQuery } from 'client/features/OneApi/network' +import { useGetImagesQuery } from 'client/features/OneApi/image' /** * QuotasInfoTab component. * @@ -29,10 +32,39 @@ import { useGetUserQuery } from 'client/features/OneApi/user' * @returns {Component} Rendered component. */ const QuotasInfoTab = ({ id }) => { + const datastoresResponse = useGetDatastoresQuery() + const networksResponse = useGetVNetworksQuery() + const imagesResponse = useGetImagesQuery() + const [dsNameMap, setDsNameMap] = useState({}) + const [imgNameMap, setImgNameMap] = useState({}) + const [netNameMap, setNetNameMap] = useState({}) const [selectedType, setSelectedType] = useState('VM') + const [clickedElement, setClickedElement] = useState(null) const queryInfo = useGetUserQuery({ id }) const apiData = queryInfo?.data || {} + useMemo(() => { + if (datastoresResponse.isSuccess && datastoresResponse.data) { + setDsNameMap(nameMapper(datastoresResponse)) + } + if (networksResponse.isSuccess && networksResponse.data) { + setNetNameMap(nameMapper(networksResponse)) + } + if (imagesResponse.isSuccess && imagesResponse.data) { + setImgNameMap(nameMapper(imagesResponse)) + } + }, [datastoresResponse, networksResponse, imagesResponse]) + + const nameMaps = { + DATASTORE: dsNameMap, + NETWORK: netNameMap, + IMAGE: imgNameMap, + } + + const handleChartElementClick = (data) => { + setClickedElement(data) + } + const generateKeyMap = (data) => { const keyMap = {} if (Array.isArray(data)) { @@ -79,6 +111,30 @@ const QuotasInfoTab = ({ id }) => { return metricNames } + const processDataset = (dataset) => { + if (!dataset || !dataset.data) return { ...dataset } + + const newData = dataset.data.map((item) => { + const newItem = { ...item } + if (newItem.ID && nameMaps?.[selectedType]?.[newItem.ID]) { + newItem.ID = nameMaps?.[selectedType]?.[newItem.ID] + } + Object.keys(newItem).forEach((key) => { + const value = parseFloat(newItem[key]) + if (value < 0) { + newItem[key] = '0' + } + }) + + return newItem + }) + + return { + ...dataset, + data: newData, + } + } + const quotaTypesConfig = [ { title: 'VM Quota', @@ -135,6 +191,8 @@ const QuotasInfoTab = ({ id }) => { (_datasetObj, index) => quotaTypesConfig[index].type === selectedType ) + const processedDataset = processDataset(selectedDataset?.dataset) + return ( { flexDirection: 'column', height: '100%', minWidth: '300px', - minHeight: '300px', + minHeight: '600px', }} > { height: '100%', }} > - - + + { > Quota Controls - + + ID + )} + clickedElement={clickedElement} + nameMaps={nameMaps} + /> + @@ -206,8 +272,8 @@ const QuotasInfoTab = ({ id }) => { > { ? 'Error fetching data' : '' } - groupBy={'ID'} disableExport={true} + coordinateType={selectedType === 'VM' ? 'POLAR' : 'CARTESIAN'} + disableNavController={selectedType === 'VM'} metricNames={dynamicMetricNames} + onElementClick={handleChartElementClick} /> diff --git a/src/fireedge/src/client/components/Tooltip/MultiChart/AccountingTooltip.js b/src/fireedge/src/client/components/Tooltip/MultiChart/AccountingTooltip.js index 036fce90cf..5a6dc72fea 100644 --- a/src/fireedge/src/client/components/Tooltip/MultiChart/AccountingTooltip.js +++ b/src/fireedge/src/client/components/Tooltip/MultiChart/AccountingTooltip.js @@ -16,7 +16,7 @@ import PropTypes from 'prop-types' import React, { useMemo } from 'react' -import { Paper, Typography, Box } from '@mui/material' +import { Box, Paper, Typography } from '@mui/material' export const CustomTooltip = React.memo( ({ active, payload, labels, generateColor, formatMetric, metricHues }) => {