mirror of
https://github.com/OpenNebula/one.git
synced 2025-01-22 22:03:39 +03:00
M OpenNebula/one#6121: Rework quota sub-tab (#2748)
This commit is contained in:
parent
bdf6441245
commit
da33a89513
@ -114,7 +114,11 @@ const UserCard = ({ user, rootProps }) => {
|
|||||||
<Tooltip title={`Auth Driver: ${AUTH_DRIVER}`}>
|
<Tooltip title={`Auth Driver: ${AUTH_DRIVER}`}>
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
<LockKey />
|
<LockKey />
|
||||||
<Typography variant="caption" ml={1}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
ml={1}
|
||||||
|
data-cy={`auth-driver-${ID}`}
|
||||||
|
>
|
||||||
{AUTH_DRIVER}
|
{AUTH_DRIVER}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@ -166,10 +170,22 @@ UserCard.propTypes = {
|
|||||||
ENABLED: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
ENABLED: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||||
.isRequired,
|
.isRequired,
|
||||||
AUTH_DRIVER: PropTypes.string.isRequired,
|
AUTH_DRIVER: PropTypes.string.isRequired,
|
||||||
VM_QUOTA: PropTypes.string.isRequired,
|
VM_QUOTA: PropTypes.oneOfType([
|
||||||
DATASTORE_QUOTA: PropTypes.string.isRequired,
|
PropTypes.any,
|
||||||
NETWORK_QUOTA: PropTypes.string.isRequired,
|
PropTypes.arrayOf(PropTypes.any),
|
||||||
IMAGE_QUOTA: PropTypes.string.isRequired,
|
]),
|
||||||
|
DATASTORE_QUOTA: PropTypes.oneOfType([
|
||||||
|
PropTypes.any,
|
||||||
|
PropTypes.arrayOf(PropTypes.any),
|
||||||
|
]),
|
||||||
|
NETWORK_QUOTA: PropTypes.oneOfType([
|
||||||
|
PropTypes.any,
|
||||||
|
PropTypes.arrayOf(PropTypes.any),
|
||||||
|
]),
|
||||||
|
IMAGE_QUOTA: PropTypes.oneOfType([
|
||||||
|
PropTypes.any,
|
||||||
|
PropTypes.arrayOf(PropTypes.any),
|
||||||
|
]),
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
rootProps: PropTypes.shape({
|
rootProps: PropTypes.shape({
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
|
@ -39,10 +39,12 @@ const CHART_TYPES = {
|
|||||||
LINE: 'line',
|
LINE: 'line',
|
||||||
AREA: 'area',
|
AREA: 'area',
|
||||||
TABLE: 'table',
|
TABLE: 'table',
|
||||||
|
STACKED_BAR: 'stackedBar',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartComponents = {
|
const ChartComponents = {
|
||||||
[CHART_TYPES.BAR]: BarChart,
|
[CHART_TYPES.BAR]: BarChart,
|
||||||
|
[CHART_TYPES.STACKED_BAR]: BarChart,
|
||||||
[CHART_TYPES.LINE]: LineChart,
|
[CHART_TYPES.LINE]: LineChart,
|
||||||
[CHART_TYPES.AREA]: AreaChart,
|
[CHART_TYPES.AREA]: AreaChart,
|
||||||
[CHART_TYPES.TABLE]: DataGridTable,
|
[CHART_TYPES.TABLE]: DataGridTable,
|
||||||
@ -50,6 +52,7 @@ const ChartComponents = {
|
|||||||
|
|
||||||
const ChartElements = {
|
const ChartElements = {
|
||||||
[CHART_TYPES.BAR]: Bar,
|
[CHART_TYPES.BAR]: Bar,
|
||||||
|
[CHART_TYPES.STACKED_BAR]: Bar,
|
||||||
[CHART_TYPES.LINE]: Line,
|
[CHART_TYPES.LINE]: Line,
|
||||||
[CHART_TYPES.AREA]: Area,
|
[CHART_TYPES.AREA]: Area,
|
||||||
}
|
}
|
||||||
@ -67,6 +70,7 @@ const ChartElements = {
|
|||||||
* @param {Function} props.humanReadableMetric - Function to convert metric keys to human-readable format.
|
* @param {Function} props.humanReadableMetric - Function to convert metric keys to human-readable format.
|
||||||
* @param {string} props.groupBy - The variable to group data under.
|
* @param {string} props.groupBy - The variable to group data under.
|
||||||
* @param {object} props.metricHues - Object containing hue values for different metrics.
|
* @param {object} props.metricHues - Object containing hue values for different metrics.
|
||||||
|
* @param {boolean} props.disableLegend - Disables the legend underneath the charts.
|
||||||
* @returns {React.Component} The rendered chart component.
|
* @returns {React.Component} The rendered chart component.
|
||||||
*/
|
*/
|
||||||
export const ChartRenderer = ({
|
export const ChartRenderer = ({
|
||||||
@ -79,6 +83,7 @@ export const ChartRenderer = ({
|
|||||||
humanReadableMetric,
|
humanReadableMetric,
|
||||||
groupBy,
|
groupBy,
|
||||||
metricHues,
|
metricHues,
|
||||||
|
disableLegend,
|
||||||
}) => {
|
}) => {
|
||||||
const ChartComponent = ChartComponents[chartType]
|
const ChartComponent = ChartComponents[chartType]
|
||||||
const ChartElement = ChartElements[chartType]
|
const ChartElement = ChartElements[chartType]
|
||||||
@ -118,30 +123,32 @@ export const ChartRenderer = ({
|
|||||||
}
|
}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
/>
|
/>
|
||||||
<Legend
|
{!disableLegend && (
|
||||||
formatter={(value) => {
|
<Legend
|
||||||
const [metric, datasetId] = value.split('-')
|
formatter={(value) => {
|
||||||
const currentDataset = datasets.find(
|
const [metric, datasetId] = value.split('-')
|
||||||
(ds) => ds.id === parseInt(datasetId, 10)
|
const currentDataset = datasets.find(
|
||||||
)
|
(ds) => ds.id === parseInt(datasetId, 10)
|
||||||
|
)
|
||||||
|
|
||||||
const datasetLabel = currentDataset.label
|
const datasetLabel = currentDataset.label
|
||||||
|
|
||||||
const lastSelectedMetric = [...currentDataset.metrics]
|
const lastSelectedMetric = [...currentDataset.metrics]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((m) => selectedMetrics[m.key])
|
.find((m) => selectedMetrics[m.key])
|
||||||
|
|
||||||
if (lastSelectedMetric && metric === lastSelectedMetric.key) {
|
if (lastSelectedMetric && metric === lastSelectedMetric.key) {
|
||||||
return `${humanReadableMetric(metric)} (${datasetLabel})`
|
return `${humanReadableMetric(metric)} (${datasetLabel})`
|
||||||
}
|
}
|
||||||
|
|
||||||
return humanReadableMetric(metric)
|
return humanReadableMetric(metric)
|
||||||
}}
|
}}
|
||||||
wrapperStyle={{
|
wrapperStyle={{
|
||||||
wordWrap: 'break-word',
|
wordWrap: 'break-word',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{datasets.map((dataset) =>
|
{datasets.map((dataset) =>
|
||||||
dataset.metrics.map((metric) =>
|
dataset.metrics.map((metric) =>
|
||||||
@ -153,6 +160,9 @@ export const ChartRenderer = ({
|
|||||||
fill={`url(#color${metric.key}-${dataset.id})`}
|
fill={`url(#color${metric.key}-${dataset.id})`}
|
||||||
name={metric.name}
|
name={metric.name}
|
||||||
animationDuration={500}
|
animationDuration={500}
|
||||||
|
stackId={
|
||||||
|
chartType === CHART_TYPES.STACKED_BAR ? 'a' : undefined
|
||||||
|
}
|
||||||
{...(chartType === 'area' && {
|
{...(chartType === 'area' && {
|
||||||
fillOpacity: 0.5,
|
fillOpacity: 0.5,
|
||||||
stroke: 'transparent',
|
stroke: 'transparent',
|
||||||
@ -178,7 +188,8 @@ export const ChartRenderer = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChartRenderer.propTypes = {
|
ChartRenderer.propTypes = {
|
||||||
chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table']).isRequired,
|
chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table', 'stackedBar'])
|
||||||
|
.isRequired,
|
||||||
datasets: PropTypes.arrayOf(PropTypes.object).isRequired,
|
datasets: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
selectedMetrics: PropTypes.object.isRequired,
|
selectedMetrics: PropTypes.object.isRequired,
|
||||||
customChartDefs: PropTypes.func.isRequired,
|
customChartDefs: PropTypes.func.isRequired,
|
||||||
@ -187,8 +198,10 @@ ChartRenderer.propTypes = {
|
|||||||
humanReadableMetric: PropTypes.func.isRequired,
|
humanReadableMetric: PropTypes.func.isRequired,
|
||||||
groupBy: PropTypes.string.isRequired,
|
groupBy: PropTypes.string.isRequired,
|
||||||
metricHues: PropTypes.objectOf(PropTypes.number).isRequired,
|
metricHues: PropTypes.objectOf(PropTypes.number).isRequired,
|
||||||
|
disableLegend: PropTypes.bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
ChartRenderer.defaultProps = {
|
ChartRenderer.defaultProps = {
|
||||||
groupBy: 'NAME',
|
groupBy: 'NAME',
|
||||||
|
disableLegend: false,
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,8 @@ import {
|
|||||||
* @param {Function} props.customChartDefs - Function to generate custom chart definitions.
|
* @param {Function} props.customChartDefs - Function to generate custom chart definitions.
|
||||||
* @param {object} [props.metricNames={}] - Object mapping metric keys to human-readable names.
|
* @param {object} [props.metricNames={}] - Object mapping metric keys to human-readable names.
|
||||||
* @param {object} props.metricHues - Object containing hue values for different metrics.
|
* @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.
|
||||||
* @returns {React.Component} MultiChart component.
|
* @returns {React.Component} MultiChart component.
|
||||||
*/
|
*/
|
||||||
const MultiChart = ({
|
const MultiChart = ({
|
||||||
@ -58,6 +60,8 @@ const MultiChart = ({
|
|||||||
customChartDefs,
|
customChartDefs,
|
||||||
metricNames,
|
metricNames,
|
||||||
groupBy,
|
groupBy,
|
||||||
|
disableExport,
|
||||||
|
disableLegend,
|
||||||
metricHues: passedMetricHues,
|
metricHues: passedMetricHues,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentPage, setCurrentPage] = useState(0)
|
const [currentPage, setCurrentPage] = useState(0)
|
||||||
@ -206,9 +210,11 @@ const MultiChart = ({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box flex={1} display="flex" justifyContent="flex-end">
|
{!disableExport && (
|
||||||
<ExportButton data={datasets} />
|
<Box flex={1} display="flex" justifyContent="flex-end">
|
||||||
</Box>
|
<ExportButton data={datasets} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box flex={1} mt={-1}>
|
<Box flex={1} mt={-1}>
|
||||||
@ -225,6 +231,7 @@ const MultiChart = ({
|
|||||||
humanReadableMetric={humanReadableMetric}
|
humanReadableMetric={humanReadableMetric}
|
||||||
groupBy={groupBy}
|
groupBy={groupBy}
|
||||||
metricHues={metricHues}
|
metricHues={metricHues}
|
||||||
|
disableLegend={disableLegend}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@ -249,11 +256,13 @@ MultiChart.propTypes = {
|
|||||||
).isRequired,
|
).isRequired,
|
||||||
visibleDatasets: PropTypes.arrayOf(PropTypes.number),
|
visibleDatasets: PropTypes.arrayOf(PropTypes.number),
|
||||||
xAxisLabels: PropTypes.arrayOf(PropTypes.string),
|
xAxisLabels: PropTypes.arrayOf(PropTypes.string),
|
||||||
chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table']),
|
chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table', 'stackedBar']),
|
||||||
selectedMetrics: PropTypes.object,
|
selectedMetrics: PropTypes.object,
|
||||||
error: PropTypes.string,
|
error: PropTypes.string,
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
groupBy: PropTypes.string,
|
groupBy: PropTypes.string,
|
||||||
|
disableExport: PropTypes.bool,
|
||||||
|
disableLegend: PropTypes.bool,
|
||||||
ItemsPerPage: PropTypes.number.isRequired,
|
ItemsPerPage: PropTypes.number.isRequired,
|
||||||
tableColumns: PropTypes.arrayOf(PropTypes.object),
|
tableColumns: PropTypes.arrayOf(PropTypes.object),
|
||||||
customChartDefs: PropTypes.func.isRequired,
|
customChartDefs: PropTypes.func.isRequired,
|
||||||
@ -268,6 +277,8 @@ MultiChart.defaultProps = {
|
|||||||
customChartDefs: GetChartDefs,
|
customChartDefs: GetChartDefs,
|
||||||
metricNames: {},
|
metricNames: {},
|
||||||
metricHues: {},
|
metricHues: {},
|
||||||
|
disableExport: false,
|
||||||
|
disableLegend: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MultiChart
|
export default MultiChart
|
||||||
|
@ -89,6 +89,7 @@ const GroupsTable = (props) => {
|
|||||||
{primaryGroupName && (
|
{primaryGroupName && (
|
||||||
<Grid item>
|
<Grid item>
|
||||||
<Chip
|
<Chip
|
||||||
|
data-cy="primary-group"
|
||||||
label={
|
label={
|
||||||
<Typography variant="subtitle2" component="span">
|
<Typography variant="subtitle2" component="span">
|
||||||
{primaryGroupName}
|
{primaryGroupName}
|
||||||
@ -109,6 +110,7 @@ const GroupsTable = (props) => {
|
|||||||
secondaryGroupNames.map((name, index) => (
|
secondaryGroupNames.map((name, index) => (
|
||||||
<Grid item key={index}>
|
<Grid item key={index}>
|
||||||
<Chip
|
<Chip
|
||||||
|
data-cy={`secondary-group-${+index}`}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body2" component="span">
|
<Typography variant="body2" component="span">
|
||||||
{name}
|
{name}
|
||||||
|
@ -202,6 +202,7 @@ const AuthenticationInfo = ({ id }) => {
|
|||||||
SelectProps={{
|
SelectProps={{
|
||||||
native: false,
|
native: false,
|
||||||
}}
|
}}
|
||||||
|
data-cy={'auth-driver-selector'}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
'core',
|
'core',
|
||||||
@ -213,7 +214,14 @@ const AuthenticationInfo = ({ id }) => {
|
|||||||
'server_x509',
|
'server_x509',
|
||||||
'custom',
|
'custom',
|
||||||
].map((option) => (
|
].map((option) => (
|
||||||
<MenuItem key={option} value={option}>
|
<MenuItem
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
data-cy={`auth-driver-selector-${option
|
||||||
|
.toLowerCase()
|
||||||
|
.split(' ')
|
||||||
|
.join('')}`}
|
||||||
|
>
|
||||||
{option}
|
{option}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
@ -240,6 +248,11 @@ const AuthenticationInfo = ({ id }) => {
|
|||||||
onChange={(e) => handleFieldChange('password', e.target.value)}
|
onChange={(e) => handleFieldChange('password', e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
inputProps: {
|
||||||
|
'data-cy': 'auth-password-input',
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
{/* Not implemented yet */}
|
{/* Not implemented yet */}
|
||||||
@ -281,6 +294,7 @@ const AuthenticationInfo = ({ id }) => {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleUpdateAuthDriver}
|
onClick={handleUpdateAuthDriver}
|
||||||
|
data-cy={'auth-save'}
|
||||||
>
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,301 +0,0 @@
|
|||||||
/* ------------------------------------------------------------------------- *
|
|
||||||
* 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, useMemo, useState } from 'react'
|
|
||||||
import { LinearProgressWithLabel } from 'client/components/Status'
|
|
||||||
import {
|
|
||||||
useGetUserQuery,
|
|
||||||
useUpdateUserQuotaMutation,
|
|
||||||
} from 'client/features/OneApi/user'
|
|
||||||
import { getQuotaUsage } from 'client/models/User'
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
Grid,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
IconButton,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
} from '@mui/material'
|
|
||||||
import { T } from 'client/constants'
|
|
||||||
import { EditPencil } from 'iconoir-react'
|
|
||||||
import { useGeneralApi } from 'client/features/General'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* QuotasInfoTab component displays quota information for a user.
|
|
||||||
*
|
|
||||||
* @param {object} props - Component properties.
|
|
||||||
* @param {string} props.id - User ID.
|
|
||||||
* @returns {Component} Rendered component.
|
|
||||||
*/
|
|
||||||
const QuotasInfoTab = ({ id }) => {
|
|
||||||
const { enqueueSuccess, enqueueError } = useGeneralApi()
|
|
||||||
const [updateUserQuota] = useUpdateUserQuotaMutation()
|
|
||||||
const { VM_QUOTA, DATASTORE_QUOTA, IMAGE_QUOTA, NETWORK_QUOTA } =
|
|
||||||
useGetUserQuery({ id })?.data
|
|
||||||
|
|
||||||
const handleQuotaUpdate = async (type, localQuota) => {
|
|
||||||
const quotaXml = quotasToXml(type, localQuota)
|
|
||||||
|
|
||||||
let result
|
|
||||||
try {
|
|
||||||
result = await updateUserQuota({ id, template: quotaXml })
|
|
||||||
} catch (error) {
|
|
||||||
result = { error: error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result && result.error) {
|
|
||||||
enqueueError(`Error updating quota: ${result.error}`)
|
|
||||||
} else {
|
|
||||||
enqueueSuccess('Quota updated successfully!')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<QuotaDisplay
|
|
||||||
id={id}
|
|
||||||
title={T.VmQuota}
|
|
||||||
quota={VM_QUOTA}
|
|
||||||
type="VM"
|
|
||||||
onQuotaUpdate={handleQuotaUpdate}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<QuotaDisplay
|
|
||||||
id={id}
|
|
||||||
title={T.DatastoreQuota}
|
|
||||||
quota={DATASTORE_QUOTA}
|
|
||||||
type="DATASTORE"
|
|
||||||
onQuotaUpdate={handleQuotaUpdate}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<QuotaDisplay
|
|
||||||
id={id}
|
|
||||||
title={T.NetworkQuota}
|
|
||||||
quota={NETWORK_QUOTA}
|
|
||||||
type="NETWORK"
|
|
||||||
onQuotaUpdate={handleQuotaUpdate}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<QuotaDisplay
|
|
||||||
id={id}
|
|
||||||
title={T.ImageQuota}
|
|
||||||
quota={IMAGE_QUOTA}
|
|
||||||
type="IMAGE"
|
|
||||||
onQuotaUpdate={handleQuotaUpdate}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* QuotaDisplay component displays a specific quota type with its details.
|
|
||||||
*
|
|
||||||
* @param {object} props - Component properties.
|
|
||||||
* @param {string} props.title - Quota title.
|
|
||||||
* @param {object} props.quota - Quota data.
|
|
||||||
* @param {string} props.type - Quota type.
|
|
||||||
* @param {Function} props.onQuotaUpdate - Callback function to handle quota updates.
|
|
||||||
* @returns {Component} Rendered component.
|
|
||||||
*/
|
|
||||||
const QuotaDisplay = ({ title, quota, type, onQuotaUpdate }) => {
|
|
||||||
const [isEditingQuota, setIsEditingQuota] = useState({})
|
|
||||||
const [localQuota, setLocalQuota] = useState(quota)
|
|
||||||
|
|
||||||
const quotaUsage = useMemo(
|
|
||||||
() => getQuotaUsage(type, localQuota),
|
|
||||||
[type, localQuota]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleEditClick = (key) => {
|
|
||||||
setIsEditingQuota((prev) => ({ ...prev, [key]: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveClick = async (key) => {
|
|
||||||
setIsEditingQuota((prev) => ({ ...prev, [key]: false }))
|
|
||||||
|
|
||||||
onQuotaUpdate(type, { [key]: localQuota[key] })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleQuotaValueChange = (key, event) => {
|
|
||||||
setLocalQuota((prev) => ({ ...prev, [key]: event.target.value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderProgress = (caption, value, label, key) => (
|
|
||||||
<Box mb={2} display="flex" alignItems="center">
|
|
||||||
<Box width={100} flexShrink={0}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
{caption}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box flexGrow={1} marginX={2}>
|
|
||||||
<LinearProgressWithLabel
|
|
||||||
value={value}
|
|
||||||
label={label}
|
|
||||||
high={66}
|
|
||||||
low={33}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{!isEditingQuota[key] ? (
|
|
||||||
<IconButton onClick={() => handleEditClick(key)}>
|
|
||||||
<EditPencil />
|
|
||||||
</IconButton>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="Quota Value"
|
|
||||||
value={localQuota[key] || ''}
|
|
||||||
onChange={(e) => handleQuotaValueChange(key, e)}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
style={{ marginLeft: '10px' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
size="small"
|
|
||||||
style={{ marginLeft: '10px' }}
|
|
||||||
onClick={() => handleSaveClick(key)}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card variant="outlined">
|
|
||||||
<CardContent>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
{type === 'VM' && (
|
|
||||||
<>
|
|
||||||
{renderProgress(
|
|
||||||
'VMs',
|
|
||||||
quotaUsage.vms.percentOfUsed,
|
|
||||||
quotaUsage.vms.percentLabel,
|
|
||||||
'vms'
|
|
||||||
)}
|
|
||||||
{renderProgress(
|
|
||||||
'Running VMs',
|
|
||||||
quotaUsage.runningVms.percentOfUsed,
|
|
||||||
quotaUsage.runningVms.percentLabel,
|
|
||||||
'runningVms'
|
|
||||||
)}
|
|
||||||
{renderProgress(
|
|
||||||
'CPU',
|
|
||||||
quotaUsage.cpu.percentOfUsed,
|
|
||||||
quotaUsage.cpu.percentLabel,
|
|
||||||
'cpu'
|
|
||||||
)}
|
|
||||||
{renderProgress(
|
|
||||||
'Running CPU',
|
|
||||||
quotaUsage.runningCpu.percentOfUsed,
|
|
||||||
quotaUsage.runningCpu.percentLabel,
|
|
||||||
'runningCpu'
|
|
||||||
)}
|
|
||||||
{renderProgress(
|
|
||||||
'System disks',
|
|
||||||
quotaUsage.systemDiskSize.percentOfUsed,
|
|
||||||
quotaUsage.systemDiskSize.percentLabel,
|
|
||||||
'systemDiskSize'
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{type === 'DATASTORE' && (
|
|
||||||
<>
|
|
||||||
{renderProgress(
|
|
||||||
'Memory',
|
|
||||||
quotaUsage.size.percentOfUsed,
|
|
||||||
quotaUsage.size.percentLabel,
|
|
||||||
'size'
|
|
||||||
)}
|
|
||||||
{renderProgress(
|
|
||||||
'Running Memory',
|
|
||||||
quotaUsage.images.percentOfUsed,
|
|
||||||
quotaUsage.images.percentLabel,
|
|
||||||
'images'
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{type === 'IMAGE' && (
|
|
||||||
<>
|
|
||||||
{renderProgress(
|
|
||||||
'RVMS',
|
|
||||||
quotaUsage.rvms.percentOfUsed,
|
|
||||||
quotaUsage.rvms.percentLabel,
|
|
||||||
'rvms'
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{type === 'NETWORK' && (
|
|
||||||
<>
|
|
||||||
{renderProgress(
|
|
||||||
'Leases',
|
|
||||||
quotaUsage.leases.percentOfUsed,
|
|
||||||
quotaUsage.leases.percentLabel,
|
|
||||||
'leases'
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert quota data to XML format.
|
|
||||||
*
|
|
||||||
* @param {string} type - Quota type.
|
|
||||||
* @param {object} quota - Quota data.
|
|
||||||
* @returns {string} XML representation of the quota.
|
|
||||||
*/
|
|
||||||
const quotasToXml = (type, quota) => {
|
|
||||||
let innerXml = ''
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(quota)) {
|
|
||||||
innerXml += `<${key.toUpperCase()}>${value}</${key.toUpperCase()}>`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<TEMPLATE><${type}>${innerXml}</${type}></TEMPLATE>`
|
|
||||||
}
|
|
||||||
|
|
||||||
QuotasInfoTab.propTypes = {
|
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
QuotaDisplay.propTypes = {
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
quota: PropTypes.string.isRequired,
|
|
||||||
type: PropTypes.oneOf(['VM', 'DATASTORE', 'IMAGE', 'NETWORK']).isRequired,
|
|
||||||
onQuotaUpdate: PropTypes.func,
|
|
||||||
}
|
|
||||||
|
|
||||||
QuotasInfoTab.displayName = 'QuotasInfoTab'
|
|
||||||
QuotaDisplay.displayName = 'QuotaDisplay'
|
|
||||||
|
|
||||||
export default QuotasInfoTab
|
|
@ -0,0 +1,375 @@
|
|||||||
|
/* ------------------------------------------------------------------------- *
|
||||||
|
* 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 { memo, useMemo, useEffect, useReducer } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import {
|
||||||
|
Grid,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Cancel } from 'iconoir-react'
|
||||||
|
|
||||||
|
import { useUpdateUserQuotaMutation } from 'client/features/OneApi/user'
|
||||||
|
import { useGeneralApi } from 'client/features/General'
|
||||||
|
import { T } from 'client/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuotaControls Component
|
||||||
|
*
|
||||||
|
* @param {object} props - Props for the component
|
||||||
|
* @param {Array} props.quotaTypes - Available quota types
|
||||||
|
* @param {string} props.userId - User ID
|
||||||
|
* @param {string} props.selectedType - Selected quota type
|
||||||
|
* @param {Function} props.setSelectedType - Function to set selected quota type
|
||||||
|
*/
|
||||||
|
export const QuotaControls = memo(
|
||||||
|
({ quotaTypes, userId, selectedType, setSelectedType }) => {
|
||||||
|
const initialState = {
|
||||||
|
globalIds: '',
|
||||||
|
selectedIdentifiers: [],
|
||||||
|
globalValue: '',
|
||||||
|
isValid: true,
|
||||||
|
isApplyDisabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 { enqueueError, enqueueSuccess } = useGeneralApi()
|
||||||
|
const [updateUserQuota] = useUpdateUserQuotaMutation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch({ type: 'SET_SELECTED_IDENTIFIERS', payload: [] })
|
||||||
|
}, [selectedType])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isValueNumeric =
|
||||||
|
!isNaN(state.globalValue) && state.globalValue !== ''
|
||||||
|
const isApplyValid =
|
||||||
|
state.isValid &&
|
||||||
|
selectedType &&
|
||||||
|
state.selectedIdentifiers.length > 0 &&
|
||||||
|
isValueNumeric
|
||||||
|
dispatch({ type: 'SET_IS_APPLY_DISABLED', payload: !isApplyValid })
|
||||||
|
}, [
|
||||||
|
state.isValid,
|
||||||
|
selectedType,
|
||||||
|
state.selectedIdentifiers,
|
||||||
|
state.globalValue,
|
||||||
|
])
|
||||||
|
|
||||||
|
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' }],
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
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 }}>
|
||||||
|
<Grid item>
|
||||||
|
<FormControl
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
data-cy="qc-type-selector"
|
||||||
|
>
|
||||||
|
<InputLabel>Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) => setSelectedType(e.target.value)}
|
||||||
|
label="Type"
|
||||||
|
inputProps={{ 'data-cy': 'qc-type-selector-input' }}
|
||||||
|
>
|
||||||
|
{quotaTypes.map((type) => (
|
||||||
|
<MenuItem
|
||||||
|
key={type.type}
|
||||||
|
value={type.type}
|
||||||
|
data-cy={`qc-type-selector-${type.type.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{type.title}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</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>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item>
|
||||||
|
<FormControl
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
style={{ height: 'auto', maxHeight: '100px', overflow: 'auto' }}
|
||||||
|
data-cy="qc-identifier-selector"
|
||||||
|
>
|
||||||
|
<InputLabel>Identifiers</InputLabel>
|
||||||
|
<Select
|
||||||
|
multiple
|
||||||
|
value={state.selectedIdentifiers}
|
||||||
|
inputProps={{ 'data-cy': 'qc-identifier-selector-input' }}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_SELECTED_IDENTIFIERS',
|
||||||
|
payload: 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
|
||||||
|
key={id}
|
||||||
|
value={id}
|
||||||
|
data-cy={`qc-identifier-selector-${displayName
|
||||||
|
.toLowerCase()
|
||||||
|
.split(' ')
|
||||||
|
.join('')}`}
|
||||||
|
style={{
|
||||||
|
opacity: state.selectedIdentifiers.includes(id) ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</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>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleApplyGlobalQuotas}
|
||||||
|
disabled={state.isApplyDisabled}
|
||||||
|
size={'large'}
|
||||||
|
data-cy={'qc-apply-button'}
|
||||||
|
>
|
||||||
|
{T.Apply}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid item sx={{ mt: 2 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
color="textSecondary"
|
||||||
|
sx={{ opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<li>Enter the value for the selected quota.</li>
|
||||||
|
<li>Click Apply to set the quotas.</li>
|
||||||
|
</ul>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {
|
||||||
|
quotaTypes: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
type: PropTypes.string.isRequired,
|
||||||
|
})
|
||||||
|
).isRequired,
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
selectedType: PropTypes.string,
|
||||||
|
setSelectedType: PropTypes.func,
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
/* ------------------------------------------------------------------------- *
|
||||||
|
* 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 { QuotaControls } from 'client/components/Tabs/User/Quota/Components/QuotaControls'
|
233
src/fireedge/src/client/components/Tabs/User/Quota/index.js
Normal file
233
src/fireedge/src/client/components/Tabs/User/Quota/index.js
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
/* ------------------------------------------------------------------------- *
|
||||||
|
* 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 { Box, Grid, Card, CardContent, 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 { useGetUserQuery } from 'client/features/OneApi/user'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuotasInfoTab component.
|
||||||
|
*
|
||||||
|
* @param {object} props - Component properties.
|
||||||
|
* @param {string} props.id - User ID.
|
||||||
|
* @returns {Component} Rendered component.
|
||||||
|
*/
|
||||||
|
const QuotasInfoTab = ({ id }) => {
|
||||||
|
const [selectedType, setSelectedType] = useState('VM')
|
||||||
|
const queryInfo = useGetUserQuery({ id })
|
||||||
|
const apiData = queryInfo?.data || {}
|
||||||
|
|
||||||
|
const generateKeyMap = (data) => {
|
||||||
|
const keyMap = {}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
Object.keys(data[0] || {}).forEach((key) => {
|
||||||
|
keyMap[key] = key
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Object.keys(data || {}).forEach((key) => {
|
||||||
|
keyMap[key] = key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateMetricKeys = (quotaTypes) => {
|
||||||
|
const metricKeys = {}
|
||||||
|
quotaTypes.forEach((config) => {
|
||||||
|
metricKeys[config.type] = Object.values(config.keyMap).filter(
|
||||||
|
(key) => key !== 'ID'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return metricKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateMetricNames = (quotaTypes) => {
|
||||||
|
const metricNames = {}
|
||||||
|
|
||||||
|
quotaTypes.forEach((config) => {
|
||||||
|
Object.keys(config.keyMap).forEach((key) => {
|
||||||
|
const transformedKey = key
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map(
|
||||||
|
(word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
|
)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
metricNames[key] = transformedKey
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return metricNames
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaTypesConfig = [
|
||||||
|
{
|
||||||
|
title: 'VM Quota',
|
||||||
|
quota: Array.isArray(apiData.VM_QUOTA)
|
||||||
|
? apiData.VM_QUOTA
|
||||||
|
: [apiData.VM_QUOTA],
|
||||||
|
type: 'VM',
|
||||||
|
keyMap: generateKeyMap(apiData.VM_QUOTA),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Datastore Quota',
|
||||||
|
quota: Array.isArray(apiData.DATASTORE_QUOTA)
|
||||||
|
? apiData.DATASTORE_QUOTA
|
||||||
|
: [apiData.DATASTORE_QUOTA],
|
||||||
|
type: 'DATASTORE',
|
||||||
|
keyMap: generateKeyMap(apiData.DATASTORE_QUOTA),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Network Quota',
|
||||||
|
quota: Array.isArray(apiData.NETWORK_QUOTA)
|
||||||
|
? apiData.NETWORK_QUOTA
|
||||||
|
: [apiData.NETWORK_QUOTA],
|
||||||
|
type: 'NETWORK',
|
||||||
|
keyMap: generateKeyMap(apiData.NETWORK_QUOTA),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Image Quota',
|
||||||
|
quota: Array.isArray(apiData.IMAGE_QUOTA)
|
||||||
|
? apiData.IMAGE_QUOTA
|
||||||
|
: [apiData.IMAGE_QUOTA],
|
||||||
|
type: 'IMAGE',
|
||||||
|
keyMap: generateKeyMap(apiData.IMAGE_QUOTA),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const dynamicMetricKeys = generateMetricKeys(quotaTypesConfig)
|
||||||
|
const dynamicMetricNames = generateMetricNames(quotaTypesConfig)
|
||||||
|
|
||||||
|
const allDatasets = quotaTypesConfig.map((quotaType, index) => {
|
||||||
|
const nestedQuotaData = { nestedData: quotaType.quota }
|
||||||
|
|
||||||
|
const { dataset, error, isEmpty } = transformApiResponseToDataset(
|
||||||
|
nestedQuotaData,
|
||||||
|
quotaType.keyMap,
|
||||||
|
dynamicMetricKeys[quotaTypesConfig[index].type],
|
||||||
|
() => quotaType.type
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataset: { ...dataset, error, isEmpty },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDataset = allDatasets.find(
|
||||||
|
(_datasetObj, index) => quotaTypesConfig[index].type === selectedType
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
minWidth: '300px',
|
||||||
|
minHeight: '300px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
item
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
height: '100%',
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid
|
||||||
|
item
|
||||||
|
xs={12}
|
||||||
|
md={4}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card variant={'outlined'} sx={{ height: '100%' }}>
|
||||||
|
<CardContent sx={{ flex: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
gutterBottom
|
||||||
|
textAlign={'center'}
|
||||||
|
sx={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
Quota Controls
|
||||||
|
</Typography>
|
||||||
|
<QuotaControls
|
||||||
|
quotaTypes={quotaTypesConfig}
|
||||||
|
userId={id}
|
||||||
|
selectedType={selectedType}
|
||||||
|
setSelectedType={setSelectedType}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
item
|
||||||
|
xs={12}
|
||||||
|
md={8}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, position: 'relative', mt: 4 }}>
|
||||||
|
<MultiChart
|
||||||
|
datasets={[selectedDataset?.dataset]}
|
||||||
|
chartType={'stackedBar'}
|
||||||
|
ItemsPerPage={10}
|
||||||
|
isLoading={queryInfo.isFetching}
|
||||||
|
error={
|
||||||
|
queryInfo.isError || !selectedDataset?.dataset
|
||||||
|
? 'Error fetching data'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
groupBy={'ID'}
|
||||||
|
disableExport={true}
|
||||||
|
metricNames={dynamicMetricNames}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
QuotasInfoTab.propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuotasInfoTab
|
@ -28,6 +28,7 @@ module.exports = {
|
|||||||
First: 'First',
|
First: 'First',
|
||||||
Last: 'Last',
|
Last: 'Last',
|
||||||
ApplyLabels: 'Apply labels',
|
ApplyLabels: 'Apply labels',
|
||||||
|
Apply: 'Apply',
|
||||||
Label: 'Label',
|
Label: 'Label',
|
||||||
NoLabels: 'NoLabels',
|
NoLabels: 'NoLabels',
|
||||||
All: 'All',
|
All: 'All',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user