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}`}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<LockKey />
|
||||
<Typography variant="caption" ml={1}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
ml={1}
|
||||
data-cy={`auth-driver-${ID}`}
|
||||
>
|
||||
{AUTH_DRIVER}
|
||||
</Typography>
|
||||
</Box>
|
||||
@ -166,10 +170,22 @@ UserCard.propTypes = {
|
||||
ENABLED: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||
.isRequired,
|
||||
AUTH_DRIVER: PropTypes.string.isRequired,
|
||||
VM_QUOTA: PropTypes.string.isRequired,
|
||||
DATASTORE_QUOTA: PropTypes.string.isRequired,
|
||||
NETWORK_QUOTA: PropTypes.string.isRequired,
|
||||
IMAGE_QUOTA: PropTypes.string.isRequired,
|
||||
VM_QUOTA: PropTypes.oneOfType([
|
||||
PropTypes.any,
|
||||
PropTypes.arrayOf(PropTypes.any),
|
||||
]),
|
||||
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,
|
||||
rootProps: PropTypes.shape({
|
||||
className: PropTypes.string,
|
||||
|
@ -39,10 +39,12 @@ const CHART_TYPES = {
|
||||
LINE: 'line',
|
||||
AREA: 'area',
|
||||
TABLE: 'table',
|
||||
STACKED_BAR: 'stackedBar',
|
||||
}
|
||||
|
||||
const ChartComponents = {
|
||||
[CHART_TYPES.BAR]: BarChart,
|
||||
[CHART_TYPES.STACKED_BAR]: BarChart,
|
||||
[CHART_TYPES.LINE]: LineChart,
|
||||
[CHART_TYPES.AREA]: AreaChart,
|
||||
[CHART_TYPES.TABLE]: DataGridTable,
|
||||
@ -50,6 +52,7 @@ const ChartComponents = {
|
||||
|
||||
const ChartElements = {
|
||||
[CHART_TYPES.BAR]: Bar,
|
||||
[CHART_TYPES.STACKED_BAR]: Bar,
|
||||
[CHART_TYPES.LINE]: Line,
|
||||
[CHART_TYPES.AREA]: Area,
|
||||
}
|
||||
@ -67,6 +70,7 @@ const ChartElements = {
|
||||
* @param {Function} props.humanReadableMetric - Function to convert metric keys to human-readable format.
|
||||
* @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.
|
||||
* @returns {React.Component} The rendered chart component.
|
||||
*/
|
||||
export const ChartRenderer = ({
|
||||
@ -79,6 +83,7 @@ export const ChartRenderer = ({
|
||||
humanReadableMetric,
|
||||
groupBy,
|
||||
metricHues,
|
||||
disableLegend,
|
||||
}) => {
|
||||
const ChartComponent = ChartComponents[chartType]
|
||||
const ChartElement = ChartElements[chartType]
|
||||
@ -118,30 +123,32 @@ export const ChartRenderer = ({
|
||||
}
|
||||
cursor="pointer"
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => {
|
||||
const [metric, datasetId] = value.split('-')
|
||||
const currentDataset = datasets.find(
|
||||
(ds) => ds.id === parseInt(datasetId, 10)
|
||||
)
|
||||
{!disableLegend && (
|
||||
<Legend
|
||||
formatter={(value) => {
|
||||
const [metric, datasetId] = value.split('-')
|
||||
const currentDataset = datasets.find(
|
||||
(ds) => ds.id === parseInt(datasetId, 10)
|
||||
)
|
||||
|
||||
const datasetLabel = currentDataset.label
|
||||
const datasetLabel = currentDataset.label
|
||||
|
||||
const lastSelectedMetric = [...currentDataset.metrics]
|
||||
.reverse()
|
||||
.find((m) => selectedMetrics[m.key])
|
||||
const lastSelectedMetric = [...currentDataset.metrics]
|
||||
.reverse()
|
||||
.find((m) => selectedMetrics[m.key])
|
||||
|
||||
if (lastSelectedMetric && metric === lastSelectedMetric.key) {
|
||||
return `${humanReadableMetric(metric)} (${datasetLabel})`
|
||||
}
|
||||
if (lastSelectedMetric && metric === lastSelectedMetric.key) {
|
||||
return `${humanReadableMetric(metric)} (${datasetLabel})`
|
||||
}
|
||||
|
||||
return humanReadableMetric(metric)
|
||||
}}
|
||||
wrapperStyle={{
|
||||
wordWrap: 'break-word',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
return humanReadableMetric(metric)
|
||||
}}
|
||||
wrapperStyle={{
|
||||
wordWrap: 'break-word',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{datasets.map((dataset) =>
|
||||
dataset.metrics.map((metric) =>
|
||||
@ -153,6 +160,9 @@ export const ChartRenderer = ({
|
||||
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',
|
||||
@ -178,7 +188,8 @@ export const ChartRenderer = ({
|
||||
}
|
||||
|
||||
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,
|
||||
selectedMetrics: PropTypes.object.isRequired,
|
||||
customChartDefs: PropTypes.func.isRequired,
|
||||
@ -187,8 +198,10 @@ ChartRenderer.propTypes = {
|
||||
humanReadableMetric: PropTypes.func.isRequired,
|
||||
groupBy: PropTypes.string.isRequired,
|
||||
metricHues: PropTypes.objectOf(PropTypes.number).isRequired,
|
||||
disableLegend: PropTypes.bool,
|
||||
}
|
||||
|
||||
ChartRenderer.defaultProps = {
|
||||
groupBy: 'NAME',
|
||||
disableLegend: false,
|
||||
}
|
||||
|
@ -43,6 +43,8 @@ import {
|
||||
* @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.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.
|
||||
*/
|
||||
const MultiChart = ({
|
||||
@ -58,6 +60,8 @@ const MultiChart = ({
|
||||
customChartDefs,
|
||||
metricNames,
|
||||
groupBy,
|
||||
disableExport,
|
||||
disableLegend,
|
||||
metricHues: passedMetricHues,
|
||||
}) => {
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
@ -206,9 +210,11 @@ const MultiChart = ({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1} display="flex" justifyContent="flex-end">
|
||||
<ExportButton data={datasets} />
|
||||
</Box>
|
||||
{!disableExport && (
|
||||
<Box flex={1} display="flex" justifyContent="flex-end">
|
||||
<ExportButton data={datasets} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flex={1} mt={-1}>
|
||||
@ -225,6 +231,7 @@ const MultiChart = ({
|
||||
humanReadableMetric={humanReadableMetric}
|
||||
groupBy={groupBy}
|
||||
metricHues={metricHues}
|
||||
disableLegend={disableLegend}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@ -249,11 +256,13 @@ MultiChart.propTypes = {
|
||||
).isRequired,
|
||||
visibleDatasets: PropTypes.arrayOf(PropTypes.number),
|
||||
xAxisLabels: PropTypes.arrayOf(PropTypes.string),
|
||||
chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table']),
|
||||
chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table', 'stackedBar']),
|
||||
selectedMetrics: PropTypes.object,
|
||||
error: PropTypes.string,
|
||||
isLoading: PropTypes.bool,
|
||||
groupBy: PropTypes.string,
|
||||
disableExport: PropTypes.bool,
|
||||
disableLegend: PropTypes.bool,
|
||||
ItemsPerPage: PropTypes.number.isRequired,
|
||||
tableColumns: PropTypes.arrayOf(PropTypes.object),
|
||||
customChartDefs: PropTypes.func.isRequired,
|
||||
@ -268,6 +277,8 @@ MultiChart.defaultProps = {
|
||||
customChartDefs: GetChartDefs,
|
||||
metricNames: {},
|
||||
metricHues: {},
|
||||
disableExport: false,
|
||||
disableLegend: false,
|
||||
}
|
||||
|
||||
export default MultiChart
|
||||
|
@ -89,6 +89,7 @@ const GroupsTable = (props) => {
|
||||
{primaryGroupName && (
|
||||
<Grid item>
|
||||
<Chip
|
||||
data-cy="primary-group"
|
||||
label={
|
||||
<Typography variant="subtitle2" component="span">
|
||||
{primaryGroupName}
|
||||
@ -109,6 +110,7 @@ const GroupsTable = (props) => {
|
||||
secondaryGroupNames.map((name, index) => (
|
||||
<Grid item key={index}>
|
||||
<Chip
|
||||
data-cy={`secondary-group-${+index}`}
|
||||
label={
|
||||
<Typography variant="body2" component="span">
|
||||
{name}
|
||||
|
@ -202,6 +202,7 @@ const AuthenticationInfo = ({ id }) => {
|
||||
SelectProps={{
|
||||
native: false,
|
||||
}}
|
||||
data-cy={'auth-driver-selector'}
|
||||
>
|
||||
{[
|
||||
'core',
|
||||
@ -213,7 +214,14 @@ const AuthenticationInfo = ({ id }) => {
|
||||
'server_x509',
|
||||
'custom',
|
||||
].map((option) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
<MenuItem
|
||||
key={option}
|
||||
value={option}
|
||||
data-cy={`auth-driver-selector-${option
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.join('')}`}
|
||||
>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
@ -240,6 +248,11 @@ const AuthenticationInfo = ({ id }) => {
|
||||
onChange={(e) => handleFieldChange('password', e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
inputProps: {
|
||||
'data-cy': 'auth-password-input',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Not implemented yet */}
|
||||
@ -281,6 +294,7 @@ const AuthenticationInfo = ({ id }) => {
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleUpdateAuthDriver}
|
||||
data-cy={'auth-save'}
|
||||
>
|
||||
Save Changes
|
||||
</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',
|
||||
Last: 'Last',
|
||||
ApplyLabels: 'Apply labels',
|
||||
Apply: 'Apply',
|
||||
Label: 'Label',
|
||||
NoLabels: 'NoLabels',
|
||||
All: 'All',
|
||||
|
Loading…
x
Reference in New Issue
Block a user