1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-22 18:50:08 +03:00

F OpenNebula/one#6121: Quota components refactor (#2803)

This commit is contained in:
vichansson 2023-11-06 19:26:07 +02:00 committed by GitHub
parent 97c683f9f6
commit afea9c2fea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1779 additions and 361 deletions

View File

@ -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<string>} 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'
) => (
<Fragment key={`defs-${datasetId}`}>
<defs>
{metrics.map((metric) => {
@ -72,7 +80,21 @@ export const GetChartDefs = (metrics, datasetId, metricHues) => (
)
})}
</defs>
<CartesianGrid stroke={'#ccc'} strokeDasharray="4 4" strokeOpacity={0.1} />
{coordinateType === 'CARTESIAN' ? (
<CartesianGrid
stroke={'#ccc'}
strokeDasharray="4 4"
strokeOpacity={0.1}
/>
) : (
<PolarAngleAxis
type="number"
domain={[0, 100]}
dataKey={groupBy}
angleAxisId={0}
tick={false}
/>
)}
</Fragment>
)
@ -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) => (
<Cell
key={`cell-${index}`}
fill={
entry?.pv && +entry?.pv > 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,
}

View File

@ -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,
}

View File

@ -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 (
<ResponsiveContainer height="100%" width="100%">
@ -97,89 +141,125 @@ export const ChartRenderer = ({
selectedItems={selectedMetrics}
/>
) : (
<ChartComponent
data={paginatedData}
barCategoryGap={20}
style={!datasets.length ? { pointerEvents: 'none' } : {}}
padding={{ top: 0, right: 60, bottom: 0, left: 60 }}
>
{datasets.map((dataset) =>
customChartDefs(
dataset.metrics.map((m) => m.key),
dataset.id,
metricHues
)
<ChartComponent {...chartConfig}>
{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' && (
<>
<XAxis interval={0} dataKey={groupBy} />
<YAxis />
</>
)}
<XAxis interval={0} dataKey={groupBy} />
<YAxis />
<Tooltip
content={
<CustomTooltip
labels={datasets.map((ds) => ds.label)}
generateColor={generateColorByMetric}
formatMetric={humanReadableMetric}
metricHues={metricHues}
/>
}
cursor="pointer"
/>
{!disableLegend && (
<Legend
formatter={(value) => {
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] ? (
<ChartElement
key={`${metric.key}-${dataset.id}`}
type="monotone"
dataKey={`${metric.key}-${dataset.id}`}
fill={`url(#color${metric.key}-${dataset.id})`}
name={metric.name}
animationDuration={500}
stackId={
chartType === CHART_TYPES.STACKED_BAR ? 'a' : undefined
}
{...(chartType === 'area' && {
fillOpacity: 0.5,
stroke: 'transparent',
})}
{...(chartType === 'line' && {
strokeWidth: 3,
activeDot: {
r: 8,
fill: `url(#color${metric.key}-${dataset.id})`,
stroke: 'white',
strokeWidth: 2,
},
stroke: `url(#color${metric.key}-${dataset.id})`,
})}
coordinateType === 'CARTESIAN' ? (
<CustomTooltip
labels={datasets.map((ds) => ds.label)}
generateColor={generateColorByMetric}
formatMetric={humanReadableMetric}
metricHues={metricHues}
/>
) : null
) : (
<PolarTooltip />
)
}
cursor={coordinateType === 'CARTESIAN' ? 'pointer' : false}
/>
{!disableLegend &&
(coordinateType === 'CARTESIAN' ? (
<Legend
formatter={(value) => {
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%',
}}
/>
) : (
<Legend
formatter={(value) =>
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] ? (
<ChartElement
{...GetChartElementConfig(
chartType,
metric,
dataset,
coordinateType,
theme
)}
onClick={onElementClick}
/>
) : null
)
)
) : (
<ChartElement
{...GetChartElementConfig(
chartType,
polarDataset?.[0]?.name, // Can always use the first datasets ID for polar charts
polarDataset,
coordinateType,
theme,
datasets?.[0]?.id
)}
onClick={onElementClick}
/>
)}
</ChartComponent>
)}
@ -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',
}

View File

@ -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 (
<Box>
<Paper
elevation={3}
sx={{
padding: 1,
maxWidth: 200,
}}
>
<Typography variant="body2" sx={{ fontWeight: 'bold', mb: 1 }}>
{data.name
.split('_')
.map(
(word) =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ')}
</Typography>
<Typography variant="body2">
{data?.uv} / {data?.pv}
</Typography>
<Typography variant="body2">{data?.pct ?? 0}%</Typography>
</Paper>
</Box>
)
}
return null
}
PolarTooltip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.arrayOf(PropTypes.object),
}

View File

@ -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,
}

View File

@ -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 (
<Box width="100%" height="100%" display="flex" flexDirection="column">
<Box display="flex" justifyContent="space-between" minHeight="40px">
{chartType !== 'table' && (
<Box flex={1}>
<NavigationController
onPrev={() => setCurrentPage((prev) => Math.max(prev - 1, 0))}
onNext={() =>
setCurrentPage((prev) =>
Math.min(
prev + 1,
Math.ceil(selectedXValues.length / ItemsPerPage) - 1
{chartType !== 'table' &&
(!disableNavController ? (
<Box flex={1}>
<NavigationController
onPrev={() => 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}
/>
</Box>
)}
}
isPrevDisabled={currentPage === 0}
isNextDisabled={
currentPage ===
Math.ceil(selectedXValues.length / ItemsPerPage) - 1
}
selectedItems={selectedXValues}
items={xAxisLabels}
setSelectedItems={setSelectedXValues}
isFilterDisabled={isFilterDisabled}
isPaginationDisabled={isPaginationDisabled}
/>
</Box>
) : null)}
{!disableExport && (
<Box flex={1} display="flex" justifyContent="flex-end">
<ExportButton data={datasets} />
@ -229,9 +236,11 @@ const MultiChart = ({
tableColumns={tableColumns}
paginatedData={paginatedData}
humanReadableMetric={humanReadableMetric}
coordinateType={coordinateType}
groupBy={groupBy}
metricHues={metricHues}
disableLegend={disableLegend}
onElementClick={onElementClick}
/>
)}
</Box>
@ -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

View File

@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Grid container spacing={2} direction="column" sx={{ flex: 1 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'auto',
}}
>
<Grid
container
spacing={2}
direction="column"
sx={{ flex: 1, overflow: 'auto' }}
>
<Grid item>
<FormControl
fullWidth
@ -159,7 +216,7 @@ export const QuotaControls = memo(
>
<InputLabel>Type</InputLabel>
<Select
value={selectedType}
value={selectedType || ''}
onChange={(e) => setSelectedType(e.target.value)}
label="Type"
inputProps={{ 'data-cy': 'qc-type-selector-input' }}
@ -177,44 +234,17 @@ export const QuotaControls = memo(
</FormControl>
</Grid>
<Grid item>
<TextField
label="Resource IDs (comma-separated)"
value={state.globalIds}
onChange={(e) => {
const value = e.target.value
dispatch({ type: 'SET_GLOBAL_IDS', payload: value })
validateResourceIds(value)
}}
variant="outlined"
fullWidth
error={!state.isValid}
helperText={
!state.isValid &&
'Invalid format. Please enter a single ID or comma-separated IDs.'
}
InputProps={{
inputProps: {
style: { padding: '16px' },
'data-cy': 'qc-id-input',
},
endAdornment: state.globalIds && (
<InputAdornment position="end">
<IconButton
edge="end"
onClick={() =>
dispatch({ type: 'SET_GLOBAL_IDS', payload: '' })
}
sx={{
marginRight: '-4px',
}}
>
<Cancel />
</IconButton>
</InputAdornment>
),
}}
<ResourceIDAutocomplete
selectedType={selectedType}
state={state}
actions={actions}
validateResourceId={validateResourceId}
filteredResourceIDs={filteredResourceIDs}
palette={palette}
nameMaps={nameMaps}
/>
</Grid>
<Grid item>
<FormControl
fullWidth
@ -222,38 +252,12 @@ export const QuotaControls = memo(
style={{ height: 'auto', maxHeight: '100px', overflow: 'auto' }}
data-cy="qc-identifier-selector"
>
<InputLabel>Identifiers</InputLabel>
<InputLabel>Identifier</InputLabel>
<Select
multiple
value={state.selectedIdentifiers}
value={state.selectedIdentifier || ''}
inputProps={{ 'data-cy': 'qc-identifier-selector-input' }}
onChange={(e) =>
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 }) => (
<MenuItem
@ -264,7 +268,7 @@ export const QuotaControls = memo(
.split(' ')
.join('')}`}
style={{
opacity: state.selectedIdentifiers.includes(id) ? 1 : 0.5,
opacity: state.selectedIdentifier === id ? 1 : 0.5,
}}
>
{displayName}
@ -274,35 +278,17 @@ export const QuotaControls = memo(
</FormControl>
</Grid>
<Grid item>
<TextField
label="Value"
value={state.globalValue}
onChange={(e) =>
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 && (
<InputAdornment position="end">
<IconButton
edge="end"
onClick={() =>
dispatch({ type: 'SET_GLOBAL_VALUE', payload: '' })
}
sx={{
marginRight: '-4px',
}}
>
<Cancel />
</IconButton>
</InputAdornment>
),
}}
<HybridInputField
selectedType={selectedType}
state={state}
actions={actions}
validateValue={validateValue}
getConcatenatedValues={getConcatenatedValues}
setPopoverAnchorEl={setPopoverAnchorEl}
popoverAnchorEl={popoverAnchorEl}
palette={palette}
touchedFields={touchedFields}
setTouchedFields={setTouchedFields}
/>
</Grid>
<Grid item>
@ -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(
<strong>How to use Quota Controls:</strong>
<ul>
<li>Select the quota type from the dropdown.</li>
<li>Enter Resource IDs, separated by commas.</li>
<li>Select identifiers for the quota.</li>
<Box
component="li"
sx={{
textDecoration: 'underline',
fontWeight: 'bold',
}}
title="enter Resource IDs over which this quota will apply"
>
(Optional) Individual Resource Quotas .
</Box>
<Box
component="li"
sx={{
textDecoration: 'underline',
fontWeight: 'bold',
}}
title="this further qualifies the quota type to a more specific attribute"
>
Select identifiers for the quota.
</Box>
<li>Enter the value for the selected quota.</li>
<li>Click Apply to set the quotas.</li>
</ul>
@ -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}</${key.toUpperCase()}>`
}
const finalXml = `<TEMPLATE><${type}><ID>${resourceId}</ID>${innerXml}</${type}></TEMPLATE>`
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: [],
}

View File

@ -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<void>} 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}</${key.toUpperCase()}>`
}
const finalXml = `<TEMPLATE><${type}><ID>${resourceId}</ID>${innerXml}</${type}></TEMPLATE>`
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' }],
}

View File

@ -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,
}

View File

@ -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,
})

View File

@ -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
}
}

View File

@ -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]
}

View File

@ -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)

View File

@ -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 (
<>
<TextField
label="Value"
disabled={isDisabled()}
value={getValue()}
onChange={(e) => {
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]) && (
<InputAdornment position="end">
<IconButton
edge="end"
onClick={() => actions.setGlobalValue('')}
sx={{
marginRight: '-4px',
}}
>
<Cancel />
</IconButton>
</InputAdornment>
),
}}
/>
<Popover
open={Boolean(popoverAnchorEl)}
anchorEl={popoverAnchorEl}
onClose={() => 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)',
},
}}
>
<Paper
elevation={0}
sx={{
padding: 2,
backgroundColor: palette?.background?.default,
}}
>
<Grid container spacing={2}>
{state.globalIds.map((id, index) => (
<Grid item key={index} xs={6}>
<TextField
label={`Value for ${state.selectedIdentifier} ID ${id}`}
value={
state.markedForDeletion.includes(id)
? 'Delete'
: state.values[id] || ''
}
disabled={state.markedForDeletion.includes(id)}
onChange={(e) => {
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 }))
}
/>
</Grid>
))}
</Grid>
</Paper>
</Popover>
</>
)
}
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,
}

View File

@ -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 (
<Autocomplete
multiple
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
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 }) => (
<Box
key={option?.key}
display="flex"
alignItems="center"
justifyContent="space-between"
width="100%"
sx={{
cursor: 'pointer',
borderBottom: 1,
paddingX: '12px',
'&:hover': {
backgroundColor: palette.action.hover,
},
}}
onClick={() => {
if (!state.globalIds.includes(option?.key)) {
const newValue = [...state.globalIds, option?.key]
actions.setGlobalIds(newValue)
}
}}
>
<span>{option?.key}</span>
<IconButton
edge="end"
onClick={(event) => {
event.stopPropagation()
if (!state.globalIds.includes(option?.key)) {
const newValue = [...state.globalIds, option?.key]
actions.setGlobalIds(newValue)
}
actions.setMarkForDeletion(option?.key)
}}
>
<Trash />
</IconButton>
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
onBlur={(event) => {
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) => (
<Chip
key={index}
variant="outlined"
label={option}
{...getTagProps({ index })}
style={{
backgroundColor: state.markedForDeletion.includes(option)
? palette?.error?.main
: 'transparent',
}}
onDelete={() => {
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: {},
}

View File

@ -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 }

View File

@ -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 (
<Grid
container
@ -144,7 +202,7 @@ const QuotasInfoTab = ({ id }) => {
flexDirection: 'column',
height: '100%',
minWidth: '300px',
minHeight: '300px',
minHeight: '600px',
}}
>
<Grid
@ -173,8 +231,8 @@ const QuotasInfoTab = ({ id }) => {
height: '100%',
}}
>
<Card variant={'outlined'} sx={{ height: '100%' }}>
<CardContent sx={{ flex: 1 }}>
<Card variant={'outlined'} sx={{ height: '100%', overflow: 'auto' }}>
<CardContent sx={{ flex: 1, overflow: 'auto' }}>
<Typography
variant="h6"
gutterBottom
@ -183,12 +241,20 @@ const QuotasInfoTab = ({ id }) => {
>
Quota Controls
</Typography>
<QuotaControls
quotaTypes={quotaTypesConfig}
userId={id}
selectedType={selectedType}
setSelectedType={setSelectedType}
/>
<Box overflow={'auto'}>
<QuotaControls
quotaTypes={quotaTypesConfig}
userId={id}
selectedType={selectedType}
setSelectedType={setSelectedType}
existingData={processedDataset?.data}
existingResourceIDs={processedDataset?.data?.map(
({ ID }) => ID
)}
clickedElement={clickedElement}
nameMaps={nameMaps}
/>
</Box>
</CardContent>
</Card>
</Grid>
@ -206,8 +272,8 @@ const QuotasInfoTab = ({ id }) => {
>
<Box sx={{ flex: 1, position: 'relative', mt: 4 }}>
<MultiChart
datasets={[selectedDataset?.dataset]}
chartType={'stackedBar'}
datasets={[processedDataset]}
chartType={selectedType === 'VM' ? 'radialBar' : 'stackedBar'}
ItemsPerPage={10}
isLoading={queryInfo.isFetching}
error={
@ -215,9 +281,11 @@ const QuotasInfoTab = ({ id }) => {
? 'Error fetching data'
: ''
}
groupBy={'ID'}
disableExport={true}
coordinateType={selectedType === 'VM' ? 'POLAR' : 'CARTESIAN'}
disableNavController={selectedType === 'VM'}
metricNames={dynamicMetricNames}
onElementClick={handleChartElementClick}
/>
</Box>
</Grid>

View File

@ -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 }) => {