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:
parent
97c683f9f6
commit
afea9c2fea
@ -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,
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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),
|
||||
}
|
@ -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,
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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: [],
|
||||
}
|
||||
|
@ -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' }],
|
||||
}
|
@ -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,
|
||||
}
|
@ -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,
|
||||
})
|
@ -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
|
||||
}
|
||||
}
|
@ -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]
|
||||
}
|
@ -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)
|
@ -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,
|
||||
}
|
@ -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: {},
|
||||
}
|
@ -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 }
|
@ -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>
|
||||
|
@ -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 }) => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user