1
0
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:
vichansson 2023-09-21 20:27:57 +03:00 committed by GitHub
parent bdf6441245
commit da33a89513
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 712 additions and 332 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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