1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-08-14 05:49:26 +03:00

F OpenNebula/one#6796: Cloudview + Settings (#3534)

This commit is contained in:
Jorge Miguel Lobo Escalona
2025-04-03 17:08:17 +02:00
committed by GitHub
parent e5bd082047
commit a7781047ab
46 changed files with 2722 additions and 1003 deletions

View File

@ -0,0 +1,17 @@
# This file describes the information and actions available in the cloud view dashboard
# Resource
resource_name: "DASHBOARD"
actions:
settings: true
instantiate: true
graphs:
cpu: true
memory: true
disks: true
networks: true
host-cpu: true
host-memory: true

View File

@ -23,7 +23,7 @@ import loadable from '@loadable/component'
import { T } from '@ConstantsModule'
const Dashboard = loadable(
() => import('@ContainersModule').then((module) => module.SunstoneDashboard),
() => import('@ContainersModule').then((module) => module.Dashboard),
{ ssr: false }
)
const Settings = loadable(

View File

@ -16,21 +16,29 @@
import PropTypes from 'prop-types'
import { css } from '@emotion/css'
import { timeFromSeconds } from '@ModelsModule'
import { wheelZoomPlugin } from '@modules/components/Charts/Plugins'
import {
useTheme,
CircularProgress,
List,
ListItem,
Paper,
Stack,
Typography,
useTheme,
} from '@mui/material'
import { css } from '@emotion/css'
import { Component, useMemo, useState, useRef, useEffect } from 'react'
import {
Component,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import ResizeObserver from 'resize-observer-polyfill'
import UplotReact from 'uplot-react'
import 'uplot/dist/uPlot.min.css'
import { wheelZoomPlugin } from '@modules/components/Charts/Plugins'
const useStyles = ({ palette, typography }) => ({
graphContainer: css({
@ -39,10 +47,6 @@ const useStyles = ({ palette, typography }) => ({
position: 'relative',
boxSizing: 'border-box',
}),
chart: css({
height: '500px',
width: '100%',
}),
title: css({
fontWeight: typography.fontWeightBold,
borderBottom: `1px solid ${palette.divider}`,
@ -168,9 +172,10 @@ const createFill = (u, color) => {
* @param {number} props.zoomFactor - Grapg zooming factor
* @param {string} props.dateFormat - Labels timestamp format
* @param {string} props.dateFormatHover - Legend timestamp format
* @param {boolean} props.showLegends - show labels
* @returns {Component} Chartist component
*/
const Chartist = ({
export const Graph = ({
data = [],
name = '',
filter = [],
@ -190,11 +195,10 @@ const Chartist = ({
lineColors = [],
dateFormat = 'MM-dd HH:mm',
dateFormatHover = 'MMM dd HH:mm:ss',
showLegends = true,
}) => {
const theme = useTheme()
const classes = useMemo(() => useStyles(theme), [theme])
const chartRef = useRef(null)
const isResizingRef = useRef(false)
const [chartDimensions, setChartDimensions] = useState({
width: 0,
height: 0,
@ -203,23 +207,28 @@ const Chartist = ({
const [trendLineIdxs, setTrendLineIdxs] = useState([])
const [shouldPad, setShouldPad] = useState([])
useEffect(() => {
const observer = new ResizeObserver(() => {
if (chartRef.current) {
const { width, height } = chartRef.current.getBoundingClientRect()
setChartDimensions({
width: width - 50,
height: height - 150,
})
}
})
const resizeGraph = useCallback(() => {
if (!chartRef?.current || isResizingRef.current) return
if (chartRef.current) {
observer.observe(chartRef.current)
try {
isResizingRef.current = true
const { width, height } = chartRef.current.getBoundingClientRect()
setChartDimensions({
width: width,
height: height - (showLegends ? 150 : 0),
})
} finally {
isResizingRef.current = false
}
}, [])
return () => {
observer.disconnect()
useLayoutEffect(() => {
resizeGraph()
if (chartRef?.current) {
const resizeObserver = new ResizeObserver(resizeGraph)
resizeObserver.observe(chartRef.current)
return () => resizeObserver.disconnect()
}
}, [])
@ -307,11 +316,13 @@ const Chartist = ({
return [xValues, ...paddedArray]
}, [processedData, y, shouldPadY])
const chartOptions = useMemo(
() => ({
const chartOptions = useMemo(() => {
const options = {
...chartDimensions,
drag: false,
padding: [20, 40, 0, 40], // Pad top / left / right
legend: {
show: false,
},
plugins: [wheelZoomPlugin({ factor: zoomFactor })],
cursor: {
bind: {
@ -330,7 +341,6 @@ const Chartist = ({
},
y: { auto: true },
},
axes: [
{
grid: { show: true },
@ -372,7 +382,6 @@ const Chartist = ({
fill: (u) => createFill(u, lineColors?.[index]),
}
: {}),
focus: true,
}))
: [
@ -389,48 +398,45 @@ const Chartist = ({
},
]),
],
}),
[
trendLineIdxs,
chartData,
chartDimensions,
processedData,
name,
y,
legendNames,
lineColors,
interpolationY,
]
)
}
if (showLegends) {
options.legend = {
show: true,
}
options.padding = [20, 40, 0, 40] // Pad top / left / right
}
return options
}, [
trendLineIdxs,
chartData,
chartDimensions,
processedData,
name,
y,
legendNames,
lineColors,
interpolationY,
])
return (
<Paper variant="outlined" className={classes.graphContainer}>
<List className={classes.box} sx={{ width: '100%', height: '100%' }}>
<ListItem className={classes.title}>
<Typography noWrap>{name}</Typography>
</ListItem>
<ListItem ref={chartRef} className={classes.center}>
{!data?.length ? (
<Stack
direction="row"
justifyContent="center"
alignItems="center"
sx={{ width: '100%', height: '100%' }}
>
<CircularProgress />
</Stack>
) : (
<div className={classes.chart}>
<UplotReact options={chartOptions} data={chartData} />
</div>
)}
</ListItem>
</List>
</Paper>
<Stack
direction="row"
justifyContent="center"
alignItems="center"
sx={{ width: '100%', aspectRatio: '16/9', overflow: 'hidden' }}
ref={chartRef}
>
{!data?.length ? (
<CircularProgress color="secondary" />
) : (
<UplotReact options={chartOptions} data={chartData} />
)}
</Stack>
)
}
Chartist.propTypes = {
Graph.propTypes = {
name: PropTypes.string,
filter: PropTypes.arrayOf(PropTypes.string),
data: PropTypes.array,
@ -454,6 +460,40 @@ Chartist.propTypes = {
lineColors: PropTypes.arrayOf(PropTypes.string),
dateFormat: PropTypes.string,
dateFormatHover: PropTypes.string,
showLegends: PropTypes.bool,
}
Graph.displayName = 'Graph'
/**
* Represents a Chartist Graph Wrapper.
*
* @param {object} props - Props
* @param {object[]} props.data - Chart data
* @param {string} props.name - Chartist name
* @returns {Component} Chartist component
*/
const Chartist = ({ name = '', ...props }) => {
const theme = useTheme()
const classes = useMemo(() => useStyles(theme), [theme])
return (
<Paper variant="outlined" className={classes.graphContainer}>
<List className={classes.box} sx={{ width: '100%', height: '100%' }}>
<ListItem className={classes.title}>
<Typography noWrap>{name}</Typography>
</ListItem>
<ListItem className={classes.center}>
<Graph {...{ ...props, name }} />
</ListItem>
</List>
</Paper>
)
}
Chartist.propTypes = {
name: PropTypes.string,
...Graph.propTypes,
}
Chartist.displayName = 'Chartist'

View File

@ -13,13 +13,13 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import { memo, useCallback, useState } from 'react'
import { InputAdornment, IconButton } from '@mui/material'
import { InputAdornment } from '@mui/material'
import { EyeEmpty as Visibility, EyeOff as VisibilityOff } from 'iconoir-react'
import { TextController } from '@modules/components/FormControl'
import { SubmitButton, TextController } from '@modules/components/FormControl'
const PasswordController = memo(
({ fieldProps, ...props }) => {
@ -40,12 +40,11 @@ const PasswordController = memo(
InputProps: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
<SubmitButton
icon={showPassword ? <Visibility /> : <VisibilityOff />}
onClick={handleClickShowPassword}
>
{showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
aria-label="toggle password visibility"
/>
</InputAdornment>
),
},

View File

@ -0,0 +1,253 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { css } from '@emotion/css'
import { Tooltip } from '@modules/components/FormControl'
import { Tr, labelCanBeTranslated } from '@modules/components/HOC'
import {
Box,
FormControl,
FormHelperText,
Radio,
RadioGroup,
useTheme,
} from '@mui/material'
import { findClosestValue, generateKey } from '@UtilsModule'
import clsx from 'clsx'
import PropTypes from 'prop-types'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { useController, useWatch } from 'react-hook-form'
const styles = ({ palette, typography }) => ({
root: css({
display: 'inline-flex',
flexWrap: 'wrap',
gap: '1rem',
}),
rootItem: css({
display: 'inline-table',
border: `${typography.pxToRem(1)} solid ${palette.grey[400]}`,
backgroundColor: palette.background.paper,
borderRadius: typography.pxToRem(8),
cursor: 'pointer',
transition: 'border 0.3s ease',
maxWidth: `calc(33.333% - ${typography.pxToRem(16)})`,
minWidth: `calc(33.333% - ${typography.pxToRem(16)})`,
'@media (max-width: 600px)': {
maxWidth: `calc(50% - ${typography.pxToRem(16)})`,
},
}),
radio: css({
color: palette.grey[400],
'&.Mui-checked': {
color: palette.info.dark,
},
}),
itemSelected: css({
border: `${typography.pxToRem(1)} solid ${palette.info.dark} !important`,
}),
padding: css({
padding: typography.pxToRem(10),
}),
paddingSvg: css({
padding: `${typography.pxToRem(10)} ${typography.pxToRem(10)} 0`,
}),
row: css({
display: 'table-row',
alignItems: 'flex-start',
gap: `${typography.pxToRem(8)}`,
}),
cell: css({
display: 'table-cell',
verticalAlign: 'middle',
}),
cellFull: css({
width: '100%',
}),
svg: css({
width: '100%',
height: 'auto',
overflow: 'hidden',
'& > svg': {
width: '100%',
height: 'auto',
display: 'inline-block',
},
}),
})
const RadioController = memo(
({
control,
cy = `radio-${generateKey()}`,
name = '',
label = '',
values = [],
tooltip,
watcher,
dependencies,
defaultValueProp,
fieldProps = {},
readOnly = false,
onConditionChange,
}) => {
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const watch = useWatch({
name: dependencies,
disabled: dependencies == null,
defaultValue: Array.isArray(dependencies) ? [] : undefined,
})
const firstValue = defaultValueProp
? values.find((val) => val.value === defaultValueProp)?.value
: values?.[0]?.value ?? ''
const defaultValue =
defaultValueProp !== undefined ? defaultValueProp : firstValue
const {
field: { ref, value: optionSelected, onChange, onBlur, ...inputProps },
fieldState: { error },
} = useController({ name, control, defaultValue })
useEffect(() => {
if (!optionSelected) return
const exists = values.some((o) => `${o.value}` === `${optionSelected}`)
!exists && onChange(firstValue)
}, [])
useEffect(() => {
if (!watcher || !dependencies) return
if (!watch) return onChange(defaultValue)
const watcherValue = watcher(watch)
const optionValues = values.map((o) => o.value)
const ensuredWatcherValue = isNaN(watcherValue)
? optionValues.find((o) => `${o}` === `${watcherValue}`)
: findClosestValue(watcherValue, optionValues)
onChange(ensuredWatcherValue ?? defaultValue)
}, [watch, watcher, dependencies])
const handleChange = useCallback(
(evt) => {
onBlur()
onChange(evt.currentTarget.getAttribute('data-value'))
if (typeof onConditionChange === 'function') {
onConditionChange(evt.currentTarget.getAttribute('data-value'))
}
},
[onChange, onConditionChange]
)
return (
<FormControl
component="fieldset"
error={Boolean(error)}
{...fieldProps}
sx={{ width: '100%' }}
>
{label && (
<legend>{labelCanBeTranslated(label) ? Tr(label) : label}</legend>
)}
<RadioGroup
{...inputProps}
value={optionSelected}
row
className={classes.root}
>
{values.map(({ text, value, svg }) => (
<Box
key={`${name}-${value}`}
data-value={value}
onClick={handleChange}
className={
optionSelected === value
? clsx(classes.rootItem, classes.itemSelected)
: classes.rootItem
}
>
<Box className={svg ? classes.paddingSvg : classes.padding}>
<Box className={classes.row}>
<Box className={classes.cell}>
<Radio
inputRef={ref}
disabled={readOnly}
data-cy={cy}
checked={optionSelected === value}
className={classes.radio}
/>
</Box>
<Box className={clsx(classes.cell, classes.cellFull)}>
{Tr(text)}
</Box>
</Box>
{svg && (
<Box className={classes.row}>
<Box className={classes.cell} />
<Box className={clsx(classes.cell, classes.cellFull)}>
<div
className={classes.svg}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</Box>
</Box>
)}
</Box>
</Box>
))}
</RadioGroup>
{tooltip && <Tooltip title={tooltip} position="start" />}
{error && <FormHelperText>{error?.message}</FormHelperText>}
</FormControl>
)
},
(prev, next) =>
prev.error === next.error &&
prev.values.length === next.values.length &&
prev.label === next.label &&
prev.tooltip === next.tooltip &&
prev.readOnly === next.readOnly
)
RadioController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.any,
tooltip: PropTypes.any,
values: PropTypes.arrayOf(
PropTypes.shape({
text: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
svg: PropTypes.string,
})
).isRequired,
watcher: PropTypes.func,
dependencies: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
defaultValueProp: PropTypes.string,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
RadioController.displayName = 'RadioController'
export default RadioController

View File

@ -13,23 +13,23 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useMemo, forwardRef, memo } from 'react'
import {
useTheme,
CircularProgress,
Button,
CircularProgress,
IconButton,
Tooltip,
Typography,
useTheme,
} from '@mui/material'
import { forwardRef, memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { Tr, ConditionalWrap } from '@modules/components/HOC'
import { T } from '@ConstantsModule'
import { SubmitButtonStyles } from '@modules/components/FormControl/styles/SubmitButtonStyles'
import { ConditionalWrap, Tr } from '@modules/components/HOC'
const ButtonComponent = forwardRef(
(
@ -153,6 +153,7 @@ const SubmitButton = memo(
)
},
(prev, next) =>
prev.icon === next.icon &&
prev.isSubmitting === next.isSubmitting &&
prev.disabled === next.disabled &&
prev.label === next.label &&

View File

@ -13,15 +13,15 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useCallback, useEffect } from 'react'
import PropTypes from 'prop-types'
import { memo, useCallback, useEffect } from 'react'
import {
styled,
FormControl,
FormControlLabel,
FormHelperText,
Switch,
styled,
} from '@mui/material'
import { useController, useWatch } from 'react-hook-form'

View File

@ -18,6 +18,7 @@ import CheckboxController from '@modules/components/FormControl/CheckboxControll
import FileController from '@modules/components/FormControl/FileController'
import InformationUnitController from '@modules/components/FormControl/InformationUnitController'
import PasswordController from '@modules/components/FormControl/PasswordController'
import RadioController from '@modules/components/FormControl/RadioController'
import SelectController from '@modules/components/FormControl/SelectController'
import SliderController from '@modules/components/FormControl/SliderController'
import SwitchController from '@modules/components/FormControl/SwitchController'
@ -44,6 +45,7 @@ export {
InformationUnitController,
InputCode,
PasswordController,
RadioController,
SelectController,
SliderController,
SubmitButton,

View File

@ -63,6 +63,7 @@ const INPUT_CONTROLLER = {
[INPUT_TYPES.DOCKERFILE]: FC.DockerfileController,
[INPUT_TYPES.UNITS]: FC.InformationUnitController,
[INPUT_TYPES.TYPOGRAPHY]: FC.TypographyController,
[INPUT_TYPES.RADIO]: FC.RadioController,
}
/**
@ -272,6 +273,7 @@ const FormWithSchema = ({
<LegendWrapper>
{legend && !hiddenLegend && (
<Legend
className="form-legend"
data-cy={`legend-${cy}`}
title={legend}
tooltip={legendTooltip}

View File

@ -24,9 +24,11 @@ import * as BackupJob from '@modules/components/Forms/BackupJob'
import * as Cluster from '@modules/components/Forms/Cluster'
import * as Datastore from '@modules/components/Forms/Datastore'
import * as File from '@modules/components/Forms/File'
import FormWithSchema from '@modules/components/Forms/FormWithSchema'
import * as Group from '@modules/components/Forms/Group'
import * as Host from '@modules/components/Forms/Host'
import * as Image from '@modules/components/Forms/Image'
import Legend from '@modules/components/Forms/Legend'
import * as Marketplace from '@modules/components/Forms/Marketplace'
import * as MarketplaceApp from '@modules/components/Forms/MarketplaceApp'
import * as Provider from '@modules/components/Forms/Provider'
@ -34,25 +36,22 @@ import * as Provision from '@modules/components/Forms/Provision'
import * as SecurityGroup from '@modules/components/Forms/SecurityGroups'
import * as Service from '@modules/components/Forms/Service'
import * as ServiceTemplate from '@modules/components/Forms/ServiceTemplate'
import * as Settings from '@modules/components/Forms/Settings'
import * as Support from '@modules/components/Forms/Support'
import * as User from '@modules/components/Forms/User'
import * as VnTemplate from '@modules/components/Forms/VnTemplate'
import * as Vn from '@modules/components/Forms/VNetwork'
import * as VrTemplate from '@modules/components/Forms/VrTemplate'
import * as Vdc from '@modules/components/Forms/Vdc'
import * as Vm from '@modules/components/Forms/Vm'
import * as VmGroup from '@modules/components/Forms/VmGroup'
import * as VmTemplate from '@modules/components/Forms/VmTemplate'
import Legend from '@modules/components/Forms/Legend'
import FormWithSchema from '@modules/components/Forms/FormWithSchema'
import * as VnTemplate from '@modules/components/Forms/VnTemplate'
import * as VrTemplate from '@modules/components/Forms/VrTemplate'
buildMethods()
export {
ButtonToTriggerForm,
ButtonToTriggerFormPropTypes,
Legend,
FormWithSchema,
Legend,
}
export const Form = {
@ -72,7 +71,6 @@ export const Form = {
SecurityGroup,
Service,
ServiceTemplate,
Settings,
Support,
User,
VnTemplate,

View File

@ -14,16 +14,16 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useRef, useMemo } from 'react'
import { useTheme, Box, Container } from '@mui/material'
import { Box, Container, useTheme } from '@mui/material'
import PropTypes from 'prop-types'
import { useMemo, useRef } from 'react'
import { CSSTransition } from 'react-transition-group'
import { useGeneral } from '@FeaturesModule'
import Header from '@modules/components/Header'
import Footer from '@modules/components/Footer'
import Header from '@modules/components/Header'
import internalStyles from '@modules/components/HOC/InternalLayout/styles'
import { sidebar, footer } from '@ProvidersModule'
import { footer, sidebar } from '@ProvidersModule'
import { SunstoneBreadcrumb } from '@modules/components/Breadcrumb'

View File

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { css } from '@emotion/css'
import { toolbar, footer } from '@ProvidersModule'
import { footer, toolbar } from '@ProvidersModule'
export default (theme) => ({
root: css({
@ -45,7 +45,7 @@ export default (theme) => ({
scrollable: css({
paddingTop: theme.spacing(2),
height: '100%',
overflow: 'none',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
paddingLeft: '3.4375rem',

View File

@ -67,7 +67,7 @@ const labelCanBeTranslated = (val) =>
*/
const translateString = (word = '', values) => {
// Get the translation context so hash will be the map with the language that is using the user
const { hash = {} } = useContext(TranslateContext)
const { hash = {} } = useContext(TranslateContext) || {}
// Look for the key thas has the value equal to word in the T object
const key = findKey(T, (value) => value === word)

View File

@ -15,15 +15,14 @@
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import { MenuItem, MenuList, Link } from '@mui/material'
import { ProfileCircled as UserIcon } from 'iconoir-react'
import { Avatar, Link, MenuItem, MenuList } from '@mui/material'
import { APPS, APP_URL, T } from '@ConstantsModule'
import { useAuth, useAuthApi } from '@FeaturesModule'
import HeaderPopover from '@modules/components/Header/Popover'
import { DevTypography } from '@modules/components/Typography'
import { Translate } from '@modules/components/HOC'
import { DevTypography } from '@modules/components/Typography'
import { isDevelopment } from '@UtilsModule'
import { T, APPS, APP_URL } from '@ConstantsModule'
import { useHistory } from 'react-router-dom'
/**
@ -40,11 +39,14 @@ const User = () => {
<HeaderPopover
id="user-menu"
buttonLabel={user?.NAME}
icon={<UserIcon />}
buttonProps={{
'data-cy': 'header-user-button',
noborder: true,
}}
icon={
<Avatar
src={user?.TEMPLATE?.FIREEDGE?.IMAGE_PROFILE}
alt="User"
sx={{ width: 24, height: 24 }}
/>
}
buttonProps={{ 'data-cy': 'header-user-button', noborder: true }}
disablePadding
>
{() => (

View File

@ -23,156 +23,123 @@ export const EnhancedTableStyles = ({
readOnly,
disableGlobalActions,
disableGlobalSort,
}) => {
const backgroundColor = readOnly
? palette.action.hover
: palette.background.paper
return {
root: css({
height: '100%',
}) => ({
root: css({
height: '100%',
display: 'flex',
flexDirection: 'column',
}),
rootWithoutHeight: css({
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
maxHeight: '14rem',
marginTop: '1rem',
}),
toolbar: css({
...typography.body1,
marginBottom: '1em',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}),
actions: css({
gridArea: 'actions',
display: 'flex',
justifyContent: 'flex-start',
}),
pagination: css({
gridArea: 'pagination',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
}),
paginationArrow: css({
minWidth: '1.5rem',
height: '1.5rem',
padding: '0.5rem',
}),
paginationText: css({
fontSize: '0.875rem',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: '1.25rem',
}),
toolbarContainer: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'start',
gap: '0.5rem',
}),
filters: css({
gridArea: 'filters',
display: 'flex',
alignItems: 'center',
gap: '1rem',
}),
body: css({
overflowY: 'auto',
display: 'grid',
gap: '1em',
gridTemplateColumns: 'minmax(0, 1fr)',
gridAutoRows: 'max-content',
'& > [role=row]': {
padding: '0.8em',
cursor: 'pointer',
color: palette.text.primary,
backgroundColor: palette.tables.cards.normal.backgroundColor,
fontWeight: typography.fontWeightRegular,
fontSize: '1em',
border: `1px solid ${palette.divider}`,
borderRadius: '0.5em',
display: 'flex',
flexDirection: 'column',
}),
rootWithoutHeight: css({
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
maxHeight: '14rem',
marginTop: '1rem',
}),
toolbar: css({
...typography.body1,
marginBottom: '1em',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}),
actions: css({
gridArea: 'actions',
display: 'flex',
justifyContent: 'flex-start',
}),
pagination: css({
gridArea: 'pagination',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
}),
paginationArrow: css({
minWidth: '1.5rem',
height: '1.5rem',
padding: '0.5rem',
}),
paginationText: css({
fontSize: '0.875rem',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: '1.25rem',
}),
toolbarContainer: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'start',
gap: '0.5rem',
}),
filters: css({
gridArea: 'filters',
display: 'flex',
alignItems: 'center',
gap: '1rem',
}),
body: css({
overflowY: 'auto',
display: 'grid',
gap: '1em',
gridTemplateColumns: 'minmax(0, 1fr)',
gridAutoRows: 'max-content',
'& > [role=row]': {
padding: '0.8em',
cursor: 'pointer',
color: palette.text.primary,
backgroundColor: palette.tables.cards.normal.backgroundColor,
fontWeight: typography.fontWeightRegular,
fontSize: '1em',
border: `1px solid ${palette.divider}`,
borderRadius: '0.5em',
display: 'flex',
gap: '1em',
'&:hover': {
backgroundColor: palette.tables.cards.normal.hover.backgroundColor,
},
'&.selected': {
backgroundColor: palette.tables.cards.pressed.backgroundColor,
border: `.125rem solid ${palette.tables.cards.pressed.borderColor}`,
'&:hover': {
backgroundColor: palette.tables.cards.pressed.hover.backgroundColor,
},
},
},
}),
bodyWithoutGap: css({
overflow: 'auto',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
gridAutoRows: 'max-content',
'& > [role=row]': {
padding: '0.8em',
cursor: 'pointer',
marginBottom: '1rem',
color: palette.text.primary,
backgroundColor: backgroundColor,
fontWeight: typography.fontWeightRegular,
fontSize: '1em',
border: `1px solid ${palette.divider}`,
borderRadius: '0.5em',
display: 'flex',
'&:hover': {
backgroundColor: palette.action.hover,
},
'&.selected': {
border: `2px solid ${palette.primary.main}`,
},
},
'& > [role=row] p': {
margin: '0rem',
},
}),
noDataMessage: css({
...typography.h6,
color: palette.text.hint,
display: 'inline-flex',
alignItems: 'center',
gap: '0.8em',
padding: '1em',
}),
table: css({
width: '100%',
'& th:nth-of-type(1), & td:nth-of-type(1)': {
width: '5rem',
},
}),
cellHeaders: css({
fontWeight: 'bold',
padding: '0.5rem',
backgroundColor: 'transparent',
position: 'relative',
}),
row: css({
'&': {
cursor: 'pointer',
'&:hover': {
backgroundColor: palette.tables.cards.normal.hover.backgroundColor,
},
'&.selected': {
boxShadow: `inset 0px -0.5px 0px 2px ${palette.primary.main}`,
backgroundColor: palette.tables.cards.pressed.backgroundColor,
border: `.125rem solid ${palette.tables.cards.pressed.borderColor}`,
'&:hover': {
backgroundColor: palette.tables.cards.pressed.hover.backgroundColor,
},
},
}),
cell: css({
padding: '0.5rem',
}),
refreshIcon: css({
...palette.tables.refreshIcon,
}),
}
}
},
}),
noDataMessage: css({
...typography.h6,
color: palette.text.hint,
display: 'inline-flex',
alignItems: 'center',
gap: '0.8em',
padding: '1em',
}),
table: css({
width: '100%',
'& th:nth-of-type(1), & td:nth-of-type(1)': {
width: '5rem',
},
}),
cellHeaders: css({
fontWeight: 'bold',
padding: '0.5rem',
backgroundColor: 'transparent',
position: 'relative',
}),
row: css({
'&': {
cursor: 'pointer',
},
'&.selected': {
boxShadow: `inset 0px -0.5px 0px 2px ${palette.primary.main}`,
},
}),
cell: css({
padding: '0.5rem',
}),
refreshIcon: css({
...palette.tables.refreshIcon,
}),
})
export default EnhancedTableStyles

View File

@ -165,6 +165,7 @@ export const PATH = {
},
SUPPORT: `/${RESOURCE_NAMES.SUPPORT}`,
GUACAMOLE: `/${SOCKETS.GUACAMOLE}/:id/:type`,
SETTINGS: '/settings',
}
export default { PATH }

View File

@ -158,6 +158,7 @@ export const INPUT_TYPES = {
UNITS: 'units',
LABEL: 'label',
TYPOGRAPHY: 'typography',
RADIO: 'radio',
}
export const DEBUG_LEVEL = {
@ -205,6 +206,7 @@ export const RESOURCE_NAMES = {
ZONE: 'zone',
BACKUPJOBS: 'backupjobs',
SUPPORT: 'support',
DASHBOARD: 'dashboard',
}
export * as T from '@modules/constants/translates'

View File

@ -340,6 +340,10 @@ module.exports = {
/* dashboard */
InTotal: 'In Total',
Used: 'Used',
MyDashboard: 'My Dashboard',
Greetings: 'Hey, ',
CreateVM: 'Create VM',
LimitProfileImage: 'the image must be less than 2 MB',
/* login */
LogIn: 'Login in your account:',
@ -449,6 +453,11 @@ module.exports = {
/* sections - settings */
Settings: 'Settings',
Preferences: 'Preferences',
ThemeMode: 'Theme mode',
DataTablesStyles: 'DataTables Styles',
Animations: 'Animations',
Others: 'Others',
AppliesTo: 'Applies To',
AllowedOperations: 'Allowed operations',
AffectedResources: 'Affected resources',
@ -472,6 +481,7 @@ module.exports = {
ValidUntil: 'Valid until',
Authentication: 'Authentication',
AuthType: 'Authentication Type',
SshKey: 'SSH key',
SshPrivateKey: 'SSH private key',
AddUserSshPrivateKey: 'Add user SSH private key',
SshPassphraseKey: 'SSH private key passphrase',
@ -480,6 +490,7 @@ module.exports = {
NewLabelOrSearch: 'New label or search',
LabelAlreadyExists: 'Label already exists',
PressToCreateLabel: 'Press enter to create a new label',
CreateLabel: 'Create label',
SavesInTheUserTemplate: "Saved in the User's template",
NoLabelsOnList: 'You have not defined any labels, list is empty',
@ -876,6 +887,7 @@ module.exports = {
Instances: 'Instances',
VM: 'VM',
VMs: 'VMs',
UsedVMs: 'Used VMs',
VirtualMachines: 'Virtual Machines',
VmsTab: 'Vms',
VMsBackupJob: 'VMs in BackupJob',
@ -1039,6 +1051,7 @@ module.exports = {
Cores: 'Cores',
Sockets: 'Sockets',
Memory: 'Memory',
MemoryHost: 'Host Memory',
MemoryWithUnit: 'Memory %s',
MemoryUnit: 'Unit memory',
Cost: 'Cost',
@ -1052,6 +1065,7 @@ module.exports = {
Storage: 'Storage',
Disk: 'Disk',
Disks: 'Disks',
UsedSystemDisks: 'Used Disks',
Volatile: 'Volatile',
VolatileDisk: 'Volatile disk',
Snapshot: 'Snapshot',
@ -1838,6 +1852,8 @@ module.exports = {
RealMemory: 'Real Memory',
RealCpu: 'Real CPU',
Cpu: 'CPU',
CpuHost: 'Host CPU',
Overcommitment: 'Overcommitment',
/* Host schema - template */
ISOLCPUS: 'Isolated CPUS',

View File

@ -0,0 +1,164 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { STYLE_BUTTONS, T } from '@ConstantsModule'
import { css } from '@emotion/css'
import { SubmitButton } from '@modules/components/FormControl'
import ButtonToTriggerForm from '@modules/components/Forms/ButtonToTriggerForm'
import { Translate } from '@modules/components/HOC'
import VmTemplatesTable from '@modules/components/Tables/VmTemplates'
import { styled, useTheme } from '@mui/material'
import { Plus as AddIcon, Settings as SettingsIcon } from 'iconoir-react'
import PropTypes from 'prop-types'
import { memo, ReactElement, useMemo } from 'react'
const StyledIcon = styled('span')(({ theme }) => ({
marginRight: theme.spacing(1),
display: 'inline-flex',
}))
/**
* Content Button.
*
* @param {object} props - Props
* @param {string} props.text - Text
* @param {ReactElement} props.icon - Icon
* @returns {ReactElement} Dashboard Button Instantiate
*/
export const ContentButton = memo(({ icon: Icon, text = '' }) => (
<>
{Icon && (
<StyledIcon>
<Icon />
</StyledIcon>
)}
<Translate word={text} />
</>
))
ContentButton.propTypes = {
text: PropTypes.string,
icon: PropTypes.any,
}
ContentButton.displayName = 'ContentButton'
/**
* Dashboard Button.
*
* @param {object} props - Props
* @param {boolean} props.access - Access
* @param {Function} props.action - Action
* @param {string} props.text - Text
* @param {ReactElement} props.icon - Icon
* @returns {ReactElement} Dashboard Button Instantiate
*/
export const DashboardButton = memo(
({ access = false, text, action = () => undefined }) =>
access && (
<SubmitButton
onClick={action}
importance={STYLE_BUTTONS.IMPORTANCE.SECONDARY}
size={STYLE_BUTTONS.SIZE.LARGE}
type={STYLE_BUTTONS.TYPE.OUTLINED}
label={text}
startIcon={<SettingsIcon />}
/>
),
(prev, next) => prev.access === next.access
)
DashboardButton.propTypes = {
access: PropTypes.bool,
text: PropTypes.string,
action: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
}
DashboardButton.displayName = 'DashboardButton'
const useTableStyles = () => ({
body: css({
gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr)) !important',
}),
})
/**
* Dashboard Create VM.
*
* @param {object} props - Props
* @param {Function} props.action - Action
* @returns {ReactElement} Dashboard Button Instantiate
*/
const DashboardCreateVM = memo(({ action = () => undefined }) => {
const theme = useTheme()
const classes = useMemo(() => useTableStyles(theme), [theme])
return (
<VmTemplatesTable.Table
disableGlobalSort
disableRowSelect
onRowClick={action}
classes={classes}
/>
)
})
DashboardCreateVM.propTypes = {
action: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
}
DashboardCreateVM.displayName = 'DashboardCreateVM'
/**
* Dashboard Button Instantiate.
*
* @param {object} props - Props
* @param {boolean} props.access - Access
* @param {Function} props.action - Action
* @param {string} props.text - Text
* @returns {ReactElement} Dashboard Button Instantiate
*/
export const DashboardButtonInstantiate = memo(
({ access = false, action, text = '' }) =>
access && (
<ButtonToTriggerForm
buttonProps={{
importance: STYLE_BUTTONS.IMPORTANCE.MAIN,
size: STYLE_BUTTONS.SIZE.LARGE,
type: STYLE_BUTTONS.TYPE.FILLED,
label: <ContentButton icon={AddIcon} text={text} />,
}}
options={[
{
isConfirmDialog: true,
dialogProps: {
title: T.Instantiate,
children: <DashboardCreateVM action={action} />,
fixedWidth: true,
fixedHeight: true,
},
},
]}
/>
)
)
DashboardButtonInstantiate.propTypes = {
access: PropTypes.bool,
text: PropTypes.string,
action: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
}
DashboardButtonInstantiate.displayName = 'DashboardButtonInstantiate'

View File

@ -0,0 +1,299 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { SCHEMES, T } from '@ConstantsModule'
import { css } from '@emotion/css'
import { Graph } from '@modules/components/Charts/Chartist'
import { Tr, Translate } from '@modules/components/HOC'
import {
Box,
LinearProgress,
linearProgressClasses,
Tooltip,
Typography,
useTheme,
} from '@mui/material'
import {
interpolationBytes,
interpolationBytesSeg,
interpolationValue,
prettyBytes,
} from '@UtilsModule'
import clsx from 'clsx'
import PropTypes from 'prop-types'
import { memo, useMemo } from 'react'
const styles = ({ palette, typography }) => ({
root: css({
padding: typography.pxToRem(16),
borderRadius: typography.pxToRem(16),
backgroundColor: palette.background.paper,
}),
title: css({
marginBottom: typography.pxToRem(16),
}),
progressBarTitle: css({
color: palette.secondary.main,
}),
secondTitle: css({
marginBottom: typography.pxToRem(20),
}),
progressBar: css({
height: typography.pxToRem(4),
borderRadius: typography.pxToRem(4),
marginBottom: typography.pxToRem(16),
[`&.${linearProgressClasses.colorPrimary}`]: {
backgroundColor: palette.grey[palette.mode === SCHEMES.LIGHT ? 400 : 800],
},
[`& .${linearProgressClasses.bar}`]: {
borderRadius: typography.pxToRem(4),
backgroundColor: palette.secondary.main,
},
}),
graph: css({
width: '100%',
aspectRatio: '3 / 1',
}),
})
const dataTypes = {
cpu: {
title: T.CPU,
titleQuota: T.UsedCPU,
key: 'CPU',
graph: {
x: 'TIMESTAMP',
y: ['CPU'],
lineColors: '#40B3D9',
interpolation: interpolationValue,
},
},
memory: {
title: T.Memory,
titleQuota: T.UsedMemory,
key: 'MEMORY',
graph: {
x: 'TIMESTAMP',
y: ['MEMORY'],
lineColors: '#40B3D9',
interpolation: interpolationBytes,
},
},
disks: {
title: T.Disks,
key: 'SYSTEM_DISK_SIZE',
graph: {
x: 'TIMESTAMP',
y: ['DISKRDIOPS', 'DISKWRIOPS'],
lineColors: ['#40B3D9', '#2A2D3D'],
interpolation: interpolationBytes,
},
},
networks: {
title: T.Networks,
key: 'NETWORKS',
graph: {
x: 'TIMESTAMP',
y: ['NETRX', 'NETTX'],
lineColors: ['#40B3D9', '#2A2D3D'],
legendNames: [T.NetworkRx, T.NetworkTx],
interpolation: interpolationBytesSeg,
},
},
'host-cpu': {
title: T.CpuHost,
key: 'CPU',
graph: {
x: 'TIMESTAMP',
y: ['USED_CPU'],
lineColors: '#40B3D9',
interpolation: interpolationValue,
},
},
'host-memory': {
title: T.MemoryHost,
key: 'MEMORY',
graph: {
x: 'TIMESTAMP',
y: ['USED_MEMORY'],
lineColors: '#40B3D9',
interpolation: interpolationBytes,
},
},
}
export const DashboardCardVMInfo = memo(
({
access = false,
type = '',
quotaData = {},
vmpoolMonitoringData = {},
...props
}) => {
const resourceType = dataTypes?.[type]
if (!access || !resourceType) return ''
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
return (
<Box className={classes.root}>
<Typography variant="h6" className={classes.title}>
<Translate word={resourceType?.title} />
</Typography>
<QuotaBar
{...{ ...props, resourceType, data: quotaData?.VM_QUOTA, classes }}
/>
<MonitoringGraphs
{...{
...props,
data: vmpoolMonitoringData?.MONITORING_DATA?.MONITORING,
classes,
resourceType,
}}
/>
</Box>
)
}
)
DashboardCardVMInfo.propTypes = {
access: PropTypes.bool,
type: PropTypes.string,
quotaData: PropTypes.object,
vmpoolMonitoringData: PropTypes.object,
}
DashboardCardVMInfo.displayName = 'DashboardCardVMInfo'
export const DashboardCardHostInfo = memo(
({ access = false, type = '', hostpoolMonitoringData = {}, ...props }) => {
const monitoring = hostpoolMonitoringData?.MONITORING_DATA?.MONITORING
const resourceType = dataTypes?.[type]
if (!access || !resourceType || !monitoring?.length) {
return ''
}
const cpuMemoryData = useMemo(
() =>
(Array.isArray(monitoring) ? monitoring : [monitoring]).map(
({ TIMESTAMP, CAPACITY }) => ({
TIMESTAMP,
...CAPACITY,
})
),
[monitoring]
)
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
return (
<Box className={classes.root}>
<Typography variant="h6" className={classes.title}>
<Translate word={resourceType?.title} />
</Typography>
<MonitoringGraphs
{...{ ...props, data: cpuMemoryData, classes, resourceType }}
/>
</Box>
)
}
)
DashboardCardHostInfo.propTypes = {
access: PropTypes.bool,
type: PropTypes.string,
hostpoolMonitoringData: PropTypes.object,
}
DashboardCardHostInfo.displayName = 'DashboardCardHostInfo'
const QuotaBar = memo(
({ data, resourceType, classes, unitBytes, showQuota }) => {
const key = resourceType?.key
const quotaData = data?.[key]
if (!quotaData || !showQuota) return ''
if (quotaData === '-1') {
const dataUsed = unitBytes
? prettyBytes(+(data?.[`${key}_USED`] || '0'), 'MB', 2)
: data?.[`${key}_USED`]
return (
<Tooltip placement="top-start" title={`${resourceType?.titleQuota}`}>
<Typography
className={clsx(classes.progressBarTitle, classes.secondTitle)}
>
{dataUsed}
</Typography>
</Tooltip>
)
}
const percentage = useMemo(
() => (parseInt(data?.[`${key}_USED`]) / parseInt(quotaData)) * 100,
[data]
)
return (
<>
<Typography className={classes.progressBarTitle}>
{`${percentage.toFixed(0)}%`}
</Typography>
<Tooltip placement="top-start" title={`${resourceType?.titleQuota}`}>
<LinearProgress
variant="determinate"
value={percentage}
className={classes.progressBar}
/>
</Tooltip>
</>
)
}
)
QuotaBar.propTypes = {
data: PropTypes.object,
resourceType: PropTypes.object,
classes: PropTypes.object,
unitBytes: PropTypes.bool,
showQuota: PropTypes.bool,
}
QuotaBar.displayName = 'QuotaBar'
const MonitoringGraphs = memo(({ resourceType, data }) => {
if (!resourceType?.graph) return ''
const { x, y, lineColors, legendNames, interpolation } = resourceType?.graph
return (
<Graph
name={Tr(resourceType.title)}
filter={y}
data={data}
y={y}
x={x}
legendNames={legendNames || resourceType.title}
lineColors={lineColors}
interpolationY={interpolation}
showLegends={false}
/>
)
})
MonitoringGraphs.propTypes = {
data: PropTypes.object,
resourceType: PropTypes.object,
}
MonitoringGraphs.displayName = 'MonitoringGraphs'

View File

@ -0,0 +1,170 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { PATH } from '@ComponentsModule'
import { RESOURCE_NAMES, T } from '@ConstantsModule'
import { css } from '@emotion/css'
import { HostAPI, UserAPI, useViews, VmAPI } from '@FeaturesModule'
import { Translate } from '@modules/components/HOC'
import {
DashboardButton,
DashboardButtonInstantiate,
} from '@modules/containers/Dashboard/Sunstone/Cloud/Buttons'
import {
DashboardCardHostInfo,
DashboardCardVMInfo,
} from '@modules/containers/Dashboard/Sunstone/Cloud/Cards'
import { Box, Grid, Typography, useTheme } from '@mui/material'
import clsx from 'clsx'
import PropTypes from 'prop-types'
import { ReactElement, useMemo } from 'react'
import { useHistory } from 'react-router-dom'
const { DASHBOARD, VM_TEMPLATE } = RESOURCE_NAMES
const styles = ({ typography }) => ({
root: css({
'& > *': {
margin: '0',
},
'& > *:first-child': {
margin: `0 0 ${typography.pxToRem(16)}`,
alignItems: 'center',
},
}),
buttons: css({
textAlign: 'right',
'& > *': {
marginRight: `${typography.pxToRem(16)} `,
},
'& > *:last-child': {
marginRight: '0',
},
}),
sections: css({
padding: '0',
}),
cards: css({
gap: typography.pxToRem(16),
}),
})
/**
* @param {object} props - Props
* @param {object} props.view - View
* @returns {ReactElement} Cloud Dashboard container
*/
export default function CloudDashboard({ view }) {
const { hasAccessToResource, getResourceView } = useViews()
const theme = useTheme()
const classes = useMemo(() => styles(theme))
const { push: goTo } = useHistory()
const { data: quotaData = {} } = UserAPI.useGetUserQuery({})
const { data: vmpoolMonitoringData = {} } = VmAPI.useGetMonitoringPoolQuery(
{}
)
const { data: hostpoolMonitoringData = {}, isSuccess: isSuccessHost } =
HostAPI.useGetHostMonitoringPoolQuery({})
const { actions = {}, graphs = {} } = useMemo(
() => getResourceView(DASHBOARD) || {},
[view]
)
const templateAccess = useMemo(() => hasAccessToResource(VM_TEMPLATE), [view])
return (
<Box py={3} className={classes.root}>
<Grid container data-cy="dashboard-headers" className={classes.sections}>
<Grid item xs={12} md={6}>
<Typography variant="h6" zIndex={2} noWrap>
<Translate word={T.MyDashboard} />
</Typography>
</Grid>
<Grid item xs={12} md={6} className={classes.buttons}>
<DashboardButton
access={actions?.settings}
text={T.Settings}
action={() => goTo(PATH.SETTINGS)}
/>
<DashboardButtonInstantiate
access={actions?.instantiate && templateAccess}
text={T.CreateVM}
action={(template) => goTo(PATH.TEMPLATE.VMS.INSTANTIATE, template)}
/>
</Grid>
</Grid>
<Box
display="grid"
data-cy="dashboard-widget-total-cloud-graphs"
className={clsx(classes.sections, classes.cards)}
gridTemplateColumns={{
sm: '1fr',
md:
hostpoolMonitoringData?.MONITORING_DATA?.MONITORING?.length &&
isSuccessHost
? 'repeat(3, minmax(32%, 1fr))'
: 'repeat(2, minmax(49%, 1fr))',
}}
gridAutoRows="auto"
gap="1em"
>
<DashboardCardVMInfo
quotaData={quotaData}
vmpoolMonitoringData={vmpoolMonitoringData}
access={graphs.cpu}
type="cpu"
showQuota
/>
<DashboardCardVMInfo
quotaData={quotaData}
vmpoolMonitoringData={vmpoolMonitoringData}
access={graphs.memory}
type="memory"
unitBytes
showQuota
/>
<DashboardCardVMInfo
quotaData={quotaData}
vmpoolMonitoringData={vmpoolMonitoringData}
access={graphs.disks}
type="disks"
/>
<DashboardCardVMInfo
quotaData={quotaData}
vmpoolMonitoringData={vmpoolMonitoringData}
access={graphs.networks}
type="networks"
/>
<DashboardCardHostInfo
hostpoolMonitoringData={hostpoolMonitoringData}
access={graphs['host-cpu']}
type="host-cpu"
/>
<DashboardCardHostInfo
hostpoolMonitoringData={hostpoolMonitoringData}
access={graphs['host-memory']}
type="host-memory"
/>
</Box>
</Box>
)
}
CloudDashboard.displayName = 'CloudDashboard'
CloudDashboard.propTypes = {
view: PropTypes.string,
}

View File

@ -0,0 +1,167 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { Box, CircularProgress, Grid } from '@mui/material'
import {
BoxIso as ImageIcon,
NetworkAlt as NetworkIcon,
EmptyPage as TemplatesIcon,
ModernTv as VmsIcons,
} from 'iconoir-react'
import PropTypes from 'prop-types'
import { ReactElement, memo, useMemo } from 'react'
import { useHistory } from 'react-router-dom'
import {
ImageAPI,
VmAPI,
VmTemplateAPI,
VnAPI,
useAuth,
useViews,
} from '@FeaturesModule'
import {
NumberEasing,
PATH,
TranslateProvider,
WavesCard,
} from '@ComponentsModule'
import { RESOURCE_NAMES, T } from '@ConstantsModule'
import { stringToBoolean } from '@ModelsModule'
const { VM, VM_TEMPLATE, IMAGE, VNET } = RESOURCE_NAMES
/**
* @param {object} props - Props
* @param {object} props.view - View
* @returns {ReactElement} Sunstone dashboard container
*/
export default function SunstoneDashboard({ view }) {
const { settings: { FIREEDGE: fireedge = {} } = {} } = useAuth()
const { DISABLE_ANIMATIONS } = fireedge
const { hasAccessToResource } = useViews()
const { push: goTo } = useHistory()
const vmAccess = useMemo(() => hasAccessToResource(VM), [view])
const templateAccess = useMemo(() => hasAccessToResource(VM_TEMPLATE), [view])
const imageAccess = useMemo(() => hasAccessToResource(IMAGE), [view])
const vnetAccess = useMemo(() => hasAccessToResource(VNET), [view])
const styles = useMemo(() => {
if (stringToBoolean(DISABLE_ANIMATIONS))
return {
'& *, & *::before, & *::after': { animation: 'none !important' },
}
}, [DISABLE_ANIMATIONS])
return (
<TranslateProvider>
<Box py={3} sx={styles}>
<Grid
container
data-cy="dashboard-widget-total-sunstone-resources"
spacing={3}
>
<ResourceWidget
type="vms"
bgColor="#fa7892"
text={T.VMs}
icon={VmsIcons}
onClick={vmAccess && (() => goTo(PATH.INSTANCE.VMS.LIST))}
disableAnimations={DISABLE_ANIMATIONS}
/>
<ResourceWidget
type="vmtemples"
bgColor="#b25aff"
text={T.VMTemplates}
icon={TemplatesIcon}
onClick={templateAccess && (() => goTo(PATH.TEMPLATE.VMS.LIST))}
disableAnimations={DISABLE_ANIMATIONS}
/>
<ResourceWidget
type="images"
bgColor="#1fbbc6"
text={T.Images}
icon={ImageIcon}
onClick={imageAccess && (() => goTo(PATH.STORAGE.IMAGES.LIST))}
disableAnimations={DISABLE_ANIMATIONS}
/>
<ResourceWidget
type="vnets"
bgColor="#f09d42"
text={T.VirtualNetworks}
icon={NetworkIcon}
onClick={vnetAccess && (() => goTo(PATH.NETWORK.VNETS.LIST))}
disableAnimations={DISABLE_ANIMATIONS}
/>
</Grid>
</Box>
</TranslateProvider>
)
}
SunstoneDashboard.displayName = 'SunstoneDashboard'
SunstoneDashboard.propTypes = {
view: PropTypes.object,
}
const ResourceWidget = memo(
({ type = 'vms', onClick, text, bgColor, icon, disableAnimations }) => {
const options = {
vmtemples: VmTemplateAPI.useGetTemplatesQuery(undefined, {
skip: type !== 'vmtemples',
}),
images: ImageAPI.useGetImagesQuery(undefined, {
skip: type !== 'images',
}),
vnets: VnAPI.useGetVNetworksQuery(undefined, { skip: type !== 'vnets' }),
vms: VmAPI.useGetVmsQuery({ extended: false }, { skip: type !== 'vms' }),
}
const { data = [], isFetching } = options[type] || {}
const NumberElement = useMemo(() => {
if (stringToBoolean(disableAnimations)) return data?.length
return <NumberEasing value={data?.length} />
}, [disableAnimations, data?.length])
return (
<Grid item xs={12} sm={6} md={3}>
<WavesCard
bgColor={bgColor}
icon={icon}
text={text}
value={isFetching ? <CircularProgress size={20} /> : NumberElement}
onClick={onClick || undefined}
/>
</Grid>
)
}
)
ResourceWidget.displayName = 'ResourceWidget'
ResourceWidget.propTypes = {
type: PropTypes.string,
onClick: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
text: PropTypes.string,
bgColor: PropTypes.string,
icon: PropTypes.any,
disableAnimations: PropTypes.bool,
}

View File

@ -13,129 +13,24 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Box, CircularProgress, Grid } from '@mui/material'
import {
BoxIso as ImageIcon,
NetworkAlt as NetworkIcon,
EmptyPage as TemplatesIcon,
ModernTv as VmsIcons,
} from 'iconoir-react'
import PropTypes from 'prop-types'
import { ReactElement, memo, useMemo } from 'react'
import { useHistory } from 'react-router-dom'
import { TranslateProvider } from '@ComponentsModule'
import { useViews } from '@FeaturesModule'
import { ReactElement } from 'react'
import {
VmTemplateAPI,
VmAPI,
VnAPI,
ImageAPI,
useAuth,
useViews,
} from '@FeaturesModule'
import CloudDashboard from '@modules/containers/Dashboard/Sunstone/Cloud'
import SunstoneDashboard from '@modules/containers/Dashboard/Sunstone/General'
import {
WavesCard,
NumberEasing,
PATH,
TranslateProvider,
} from '@ComponentsModule'
import { RESOURCE_NAMES, T } from '@ConstantsModule'
import { stringToBoolean } from '@ModelsModule'
const { VM, VM_TEMPLATE, IMAGE, VNET } = RESOURCE_NAMES
/** @returns {ReactElement} Sunstone dashboard container */
export function SunstoneDashboard() {
const { settings: { FIREEDGE: fireedge = {} } = {} } = useAuth()
const { DISABLE_ANIMATIONS } = fireedge
const { view, hasAccessToResource } = useViews()
const { push: goTo } = useHistory()
const vmAccess = useMemo(() => hasAccessToResource(VM), [view])
const templateAccess = useMemo(() => hasAccessToResource(VM_TEMPLATE), [view])
const imageAccess = useMemo(() => hasAccessToResource(IMAGE), [view])
const vnetAccess = useMemo(() => hasAccessToResource(VNET), [view])
const styles = useMemo(() => {
if (stringToBoolean(DISABLE_ANIMATIONS))
return {
'& *, & *::before, & *::after': { animation: 'none !important' },
}
}, [DISABLE_ANIMATIONS])
/** @returns {ReactElement} Dashboard container */
export function Dashboard() {
const { view } = useViews()
return (
<TranslateProvider>
<Box py={3} sx={styles}>
<Grid
container
data-cy="dashboard-widget-total-sunstone-resources"
spacing={3}
>
<ResourceWidget
query={() => VmAPI.useGetVmsQuery({ extended: false })}
bgColor="#fa7892"
text={T.VMs}
icon={VmsIcons}
onClick={vmAccess && (() => goTo(PATH.INSTANCE.VMS.LIST))}
/>
<ResourceWidget
query={VmTemplateAPI.useGetTemplatesQuery}
bgColor="#b25aff"
text={T.VMTemplates}
icon={TemplatesIcon}
onClick={templateAccess && (() => goTo(PATH.TEMPLATE.VMS.LIST))}
/>
<ResourceWidget
query={ImageAPI.useGetImagesQuery}
bgColor="#1fbbc6"
text={T.Images}
icon={ImageIcon}
onClick={imageAccess && (() => goTo(PATH.STORAGE.IMAGES.LIST))}
/>
<ResourceWidget
query={VnAPI.useGetVNetworksQuery}
bgColor="#f09d42"
text={T.VirtualNetworks}
icon={NetworkIcon}
onClick={vnetAccess && (() => goTo(PATH.NETWORK.VNETS.LIST))}
/>
</Grid>
</Box>
{view === 'cloud' ? (
<CloudDashboard view={view} />
) : (
<SunstoneDashboard view={view} />
)}
</TranslateProvider>
)
}
const ResourceWidget = memo((props) => {
const { settings: { FIREEDGE: fireedge = {} } = {} } = useAuth()
const { DISABLE_ANIMATIONS } = fireedge
const { query, onClick, text, bgColor, icon } = props
const { data = [], isFetching } = query()
const NumberElement = useMemo(() => {
if (stringToBoolean(DISABLE_ANIMATIONS)) return data?.length
return <NumberEasing value={data?.length} />
}, [DISABLE_ANIMATIONS, data?.length])
return (
<Grid item xs={12} sm={6} md={3}>
<WavesCard
bgColor={bgColor}
icon={icon}
text={text}
value={isFetching ? <CircularProgress size={20} /> : NumberElement}
onClick={onClick || undefined}
/>
</Grid>
)
})
ResourceWidget.displayName = 'ResourceWidget'
ResourceWidget.propTypes = {
query: PropTypes.func,
text: PropTypes.string,
bgColor: PropTypes.string,
icon: PropTypes.any,
onClick: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
}

View File

@ -13,25 +13,52 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, memo, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { css } from '@emotion/css'
import { UserAPI, useAuth, useGeneralApi } from '@FeaturesModule'
import { SubmitButton } from '@modules/components/FormControl'
import { useSettingWrapper } from '@modules/containers/Settings/Wrapper'
import {
Paper,
Stack,
IconButton,
Typography,
Box,
Skeleton,
Stack,
TextField,
Typography,
useTheme,
} from '@mui/material'
import { Edit } from 'iconoir-react'
import { useForm, FormProvider, useFormContext } from 'react-hook-form'
import PropTypes from 'prop-types'
import { ReactElement, memo, useEffect, useMemo, useState } from 'react'
import { FormProvider, useForm, useFormContext } from 'react-hook-form'
import { useGeneralApi, UserAPI, useAuth } from '@FeaturesModule'
import { Translate, Legend, TranslateProvider } from '@ComponentsModule'
import { Translate } from '@ComponentsModule'
import { T } from '@ConstantsModule'
import { jsonToXml } from '@ModelsModule'
import { sanitize } from '@UtilsModule'
import { T } from '@ConstantsModule'
const styles = ({ typography }) => ({
field: css({
padding: 0,
margin: 0,
minInlineSize: 'auto',
}),
staticField: css({
display: 'inline-flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'start',
gap: typography.pxToRem(16),
width: '100%',
}),
textField: css({
flexGrow: 1,
'& > p': {
whiteSpace: 'normal',
wordBreak: 'break-all',
overflowWrap: 'break-word',
maxWidth: '100%',
},
}),
})
const FIELDS = [
{
@ -112,17 +139,14 @@ FieldComponent.displayName = 'FieldComponent'
export const StaticComponent = memo(
({ field, defaultValue, isEnabled, setIsEnabled }) => {
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const { formState } = useFormContext()
const { name, tooltip } = field
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
gap="1em"
paddingX={1}
>
<Stack className={classes.staticField}>
{formState.isSubmitting ? (
<>
<Skeleton variant="text" width="100%" height={36} />
@ -130,20 +154,19 @@ export const StaticComponent = memo(
</>
) : (
<>
<Typography
noWrap
title={sanitize`${defaultValue}`}
color="text.secondary"
>
{sanitize`${defaultValue}` || <Translate word={tooltip} />}
</Typography>
<IconButton
disabled={isEnabled && !isEnabled?.[name]}
<Box className={classes.textField}>
<Typography
title={sanitize`${defaultValue}`}
color="text.secondary"
>
{sanitize`${defaultValue}` || <Translate word={tooltip} />}
</Typography>
</Box>
<SubmitButton
icon={<Edit />}
onClick={() => setIsEnabled({ [name]: true })}
>
<Edit />
</IconButton>
disabled={isEnabled && !isEnabled?.[name]}
/>
</>
)}
</Stack>
@ -166,6 +189,9 @@ StaticComponent.displayName = 'StaticComponent'
* @returns {ReactElement} Settings authentication
*/
export const Settings = () => {
const { Legend, InternalWrapper } = useSettingWrapper()
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const [isEnabled, setIsEnabled] = useState(false)
const { user, settings } = useAuth()
const { enqueueError } = useGeneralApi()
@ -193,21 +219,18 @@ export const Settings = () => {
}
return (
<TranslateProvider>
<Paper
variant="outlined"
sx={{ overflow: 'auto', py: '1.5em', gridColumn: { md: 'span 1' } }}
>
<FormProvider {...methods}>
<Stack gap="1em">
{FIELDS.map((field) => (
<Stack
component="fieldset"
key={'settings-authentication-field-' + field.name}
sx={{ minInlineSize: 'auto' }}
data-cy={`settings-ui-${field.name}`}
>
<Legend title={field.label} />
<Box>
<Legend title={T.SshKey} />
<FormProvider {...methods}>
<Stack gap="1em">
{FIELDS.map((field) => (
<Stack
component="fieldset"
key={'settings-authentication-field-' + field.name}
className={classes.field}
data-cy={`settings-ui-${field.name}`}
>
<InternalWrapper title={field.label}>
{isEnabled[field.name] ? (
<FieldComponent
field={field}
@ -223,11 +246,11 @@ export const Settings = () => {
setIsEnabled={setIsEnabled}
/>
)}
</Stack>
))}
</Stack>
</FormProvider>
</Paper>
</TranslateProvider>
</InternalWrapper>
</Stack>
))}
</Stack>
</FormProvider>
</Box>
)
}

View File

@ -0,0 +1,101 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { FormWithSchema, Tr } from '@ComponentsModule'
import { STYLE_BUTTONS, T } from '@ConstantsModule'
import { css } from '@emotion/css'
import { UserAPI, useAuth, useGeneralApi } from '@FeaturesModule'
import { yupResolver } from '@hookform/resolvers/yup'
import { SubmitButton } from '@modules/components/FormControl'
import {
FIELDS,
SCHEMA,
} from '@modules/containers/Settings/ChangePassword/schema'
import { useSettingWrapper } from '@modules/containers/Settings/Wrapper'
import { Box } from '@mui/material'
import { ReactElement, useEffect, useMemo } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
const styles = () => ({
buttonPlace: css({
textAlign: 'center',
}),
})
/**
* Section to change password.
*
* @returns {ReactElement} change password
*/
const ChangePassword = () => {
const successMessageChangePassword = `${Tr(T.ChangePasswordSuccess)}`
const { Legend, InternalWrapper } = useSettingWrapper()
const classes = useMemo(() => styles())
const { enqueueSuccess } = useGeneralApi()
const { user = {} } = useAuth()
const [changePassword, { isSuccess: isSuccessChangePassword }] =
UserAPI.useChangePasswordMutation()
const { handleSubmit, reset, setValue, ...methods } = useForm({
reValidateMode: 'onSubmit',
defaultValues: SCHEMA.default(),
resolver: yupResolver(SCHEMA),
})
/**
* Change the user's password.
*
* @param {object} formData - form data
* @param {string} formData.password - Password
*/
const handleChangePassword = async ({ password }) => {
await changePassword({
id: user.ID,
password,
})
setValue('confirmPassword', '')
reset()
}
useEffect(
() =>
isSuccessChangePassword && enqueueSuccess(successMessageChangePassword),
[isSuccessChangePassword]
)
return (
<Box component="form" onSubmit={handleSubmit(handleChangePassword)}>
<Legend title={T.ChangePassword} />
<InternalWrapper>
<Box>
<FormProvider {...methods}>
<FormWithSchema cy="change-password-form" fields={FIELDS} />
</FormProvider>
<Box className={classes.buttonPlace}>
<SubmitButton
importance={STYLE_BUTTONS.IMPORTANCE.MAIN}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.FILLED}
data-cy={'change-password-button'}
label={T.ChangePassword}
/>
</Box>
</Box>
</InternalWrapper>
</Box>
)
}
export { ChangePassword }

View File

@ -13,12 +13,4 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { createForm } from '@UtilsModule'
import { CHANGE_PASSWORD_SCHEMA, CHANGE_PASSWORD_FIELDS } from './schema'
const ChangePasswordForm = createForm(
CHANGE_PASSWORD_SCHEMA,
CHANGE_PASSWORD_FIELDS
)
export default ChangePasswordForm
export { ChangePassword as ConfigurationChangePassword } from '@modules/containers/Settings/ChangePassword/ChangePassword'

View File

@ -13,9 +13,9 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ObjectSchema, string } from 'yup'
import { Field, getObjectSchemaFromFields } from '@UtilsModule'
import { T, INPUT_TYPES } from '@ConstantsModule'
import { INPUT_TYPES, T } from '@ConstantsModule'
import { Field, getValidationFromFields, ObjectSchema } from '@UtilsModule'
import { object, string } from 'yup'
/** @type {Field} Password field */
const PASSWORD_FIELD = {
@ -25,8 +25,8 @@ const PASSWORD_FIELD = {
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { md: 12 },
.default(() => ''),
grid: { xs: 12, md: 6 },
}
/** @type {Field} Confirm Password field */
@ -40,16 +40,14 @@ const CONFIRM_PASSWORD_FIELD = {
.test('passwords-match', T.PasswordsMustMatch, function (value) {
return this.parent.password === value
})
.default(() => undefined),
grid: { md: 12 },
.default(() => ''),
grid: { xs: 12, md: 6 },
}
/**
* @returns {Field[]} List of change password form inputs fields
*/
export const CHANGE_PASSWORD_FIELDS = [PASSWORD_FIELD, CONFIRM_PASSWORD_FIELD]
export const FIELDS = [PASSWORD_FIELD, CONFIRM_PASSWORD_FIELD]
/** @type {ObjectSchema} Change password form object schema */
export const CHANGE_PASSWORD_SCHEMA = getObjectSchemaFromFields(
CHANGE_PASSWORD_FIELDS
)
export const SCHEMA = object(getValidationFromFields(FIELDS))

View File

@ -13,44 +13,41 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Box, Link, Paper, debounce, useTheme } from '@mui/material'
import { ReactElement, useCallback, useEffect, useMemo } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { FormWithSchema, PATH, Translate } from '@ComponentsModule'
import { RESOURCE_NAMES, SERVER_CONFIG, T } from '@ConstantsModule'
import {
PATH,
ButtonToTriggerForm,
FormWithSchema,
Translate,
Tr,
Form,
} from '@ComponentsModule'
import {
useAuth,
useAuthApi,
useViews,
useGeneralApi,
UserAPI,
ZoneAPI,
SystemAPI,
useAuth,
useAuthApi,
useGeneralApi,
useViews,
} from '@FeaturesModule'
import { jsonToXml } from '@ModelsModule'
import { css } from '@emotion/css'
import {
T,
ONEADMIN_ID,
SERVERADMIN_ID,
AUTH_DRIVER,
SERVER_CONFIG,
} from '@ConstantsModule'
import {
FIELDS,
FIELDS_ANIMATIONS,
FIELDS_DATATABLE,
FIELDS_OTHERS,
FIELDS_THEME,
SCHEMA,
} from '@modules/containers/Settings/ConfigurationUI/schema'
import { jsonToXml } from '@ModelsModule'
import { useSettingWrapper } from '@modules/containers/Settings/Wrapper'
import { Box, Link, debounce, gridClasses } from '@mui/material'
import { ReactElement, useCallback, useEffect, useMemo } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { Link as RouterLink, generatePath } from 'react-router-dom'
import { generateDocLink } from '@UtilsModule'
import { css } from '@emotion/css'
const useStyles = (theme) => ({
const { USER } = RESOURCE_NAMES
const style = () => ({
content: css({
[`& .${gridClasses.item}`]: {
paddingLeft: 0,
paddingTop: 0,
},
}),
linkPlace: css({
display: 'flex',
justifyContent: 'space-between',
width: '100%',
@ -63,22 +60,19 @@ const useStyles = (theme) => ({
* @returns {ReactElement} Settings configuration UI
*/
const Settings = () => {
const { Legend, InternalWrapper } = useSettingWrapper()
const { user, settings: { FIREEDGE: fireedge = {} } = {} } = useAuth()
const { data: zones = [], isLoading } = ZoneAPI.useGetZonesQuery()
const { data: version } = SystemAPI.useGetOneVersionQuery()
const { changeAuthUser } = useAuthApi()
const { enqueueError, enqueueSuccess, setFullMode, setTableViewMode } =
useGeneralApi()
const { enqueueError, setTableViewMode, setFullMode } = useGeneralApi()
const [updateUser] = UserAPI.useUpdateUserMutation()
const [changePassword, { isSuccess: isSuccessChangePassword }] =
UserAPI.useChangePasswordMutation()
const { views, view: userView } = useViews()
const { views, view: userView, hasAccessToResource } = useViews()
const userAccess = useMemo(() => hasAccessToResource(USER), [userView])
const { rowStyle, fullViewMode } = SERVER_CONFIG
const theme = useTheme()
const classes = useMemo(() => useStyles(theme), [theme])
const classes = useMemo(() => style())
const { watch, handleSubmit, ...methods } = useForm({
reValidateMode: 'onChange',
defaultValues: useMemo(() => {
@ -98,38 +92,18 @@ const Settings = () => {
})
const handleUpdateUser = useCallback(
debounce(async (formData) => {
debounce(async (formData = {}) => {
try {
if (methods?.formState?.isSubmitting) return
const template = jsonToXml({ FIREEDGE: formData })
const template = jsonToXml({ FIREEDGE: { ...fireedge, ...formData } })
await updateUser({ id: user.ID, template, replace: 1 })
} catch {
enqueueError(T.SomethingWrong)
}
}, 1000),
[updateUser]
[updateUser, fireedge]
)
// Success messages
const successMessageChangePassword = `${Tr(T.ChangePasswordSuccess)}`
useEffect(
() =>
isSuccessChangePassword && enqueueSuccess(successMessageChangePassword),
[isSuccessChangePassword]
)
/**
* Change the user's password.
*
* @param {object} formData - Password data
*/
const handleChangePassword = async (formData) => {
await changePassword({
id: user.ID,
password: formData.password,
})
}
useEffect(() => {
const subscription = watch((formData) => {
// update user settings before submit
@ -144,77 +118,49 @@ const Settings = () => {
})
return () => subscription.unsubscribe()
}, [watch])
}, [watch, fireedge])
return (
<Paper
component="form"
onSubmit={handleSubmit(handleUpdateUser)}
variant="outlined"
sx={{
p: '1em',
overflow: 'auto',
}}
>
<Box component="form" onSubmit={handleSubmit(handleUpdateUser)}>
<Legend title={T.ConfigurationUI} />
{!isLoading && (
<FormProvider {...methods}>
<FormWithSchema
cy={'settings-ui'}
fields={FIELDS({ views, userView, zones })}
legend={T.ConfigurationUI}
/>
</FormProvider>
<Box className={classes.content}>
<FormProvider {...methods}>
<InternalWrapper title={T.ThemeMode}>
<FormWithSchema cy={'settings-ui'} fields={FIELDS_THEME} />
</InternalWrapper>
<InternalWrapper title={T.DataTablesStyles}>
<FormWithSchema cy={'settings-ui'} fields={FIELDS_DATATABLE} />
</InternalWrapper>
<InternalWrapper title={T.Animations}>
<FormWithSchema cy={'settings-ui'} fields={FIELDS_ANIMATIONS} />
</InternalWrapper>
<InternalWrapper title={T.Others}>
<FormWithSchema
cy={'settings-ui'}
fields={FIELDS_OTHERS({ views, userView, zones })}
/>
</InternalWrapper>
</FormProvider>
</Box>
)}
<Box className={classes.content}>
{/*
Oneadmin and serveradmin users cannot change their passwords -> management_and_operations/users_groups_management/manage_users.html#change-credentials-for-oneadmin-or-serveradmin
LDAP users cannot change their passwords (the password is stored on the LDAP server)
*/}
<ButtonToTriggerForm
buttonProps={{
'data-cy': `change-password-button`,
label: T.ChangePassword,
tooltip:
user.ID === ONEADMIN_ID || user.ID === SERVERADMIN_ID
? T.ChangePasswordAdminWarning
: user.AUTH_DRIVER === AUTH_DRIVER.LDAP
? T.ChangePasswordLdapWarning
: undefined,
tooltipLink:
user.ID === ONEADMIN_ID || user.ID === SERVERADMIN_ID
? {
text: T.ChangePasswordAdminWarningLink,
link: generateDocLink(
version,
'management_and_operations/users_groups_management/manage_users.html#change-credentials-for-oneadmin-or-serveradmin'
),
}
: {},
disabled:
user.ID === ONEADMIN_ID ||
user.ID === SERVERADMIN_ID ||
user.AUTH_DRIVER === AUTH_DRIVER.LDAP,
}}
options={[
{
dialogProps: {
title: T.ChangePassword,
dataCy: 'change-password-form',
},
form: () => Form.Settings.ChangePasswordForm(),
onSubmit: handleChangePassword,
},
]}
/>
<Link
component={RouterLink}
to={generatePath(PATH.SYSTEM.USERS.DETAIL, { id: user.ID })}
>
<Translate word={T.LinkOtherConfigurationsUser} values={user.ID} />
</Link>
</Box>
</Paper>
{userAccess && (
<InternalWrapper>
<Box className={classes.linkPlace}>
<Link
color="secondary"
component={RouterLink}
to={generatePath(PATH.SYSTEM.USERS.DETAIL, { id: user.ID })}
>
<Translate
word={T.LinkOtherConfigurationsUser}
values={user.ID}
/>
</Link>
</Box>
</InternalWrapper>
)}
</Box>
)
}

View File

@ -22,18 +22,22 @@ import {
SCHEMES,
T,
} from '@ConstantsModule'
import {
DARK_MODE,
LIGHT_MODE,
SYSTEM_MODE,
} from '@modules/containers/Settings/ConfigurationUI/svgs'
import { arrayToOptions, getObjectSchemaFromFields } from '@UtilsModule'
import { boolean, string } from 'yup'
const SCHEME_FIELD = {
name: 'SCHEME',
label: T.Schema,
type: INPUT_TYPES.AUTOCOMPLETE,
type: INPUT_TYPES.RADIO,
optionsOnly: true,
values: [
{ text: T.System, value: SCHEMES.SYSTEM },
{ text: T.Dark, value: SCHEMES.DARK },
{ text: T.Light, value: SCHEMES.LIGHT },
{ text: T.Dark, value: SCHEMES.DARK, svg: DARK_MODE },
{ text: T.Light, value: SCHEMES.LIGHT, svg: LIGHT_MODE },
{ text: T.System, value: SCHEMES.SYSTEM, svg: SYSTEM_MODE },
],
validation: string()
.trim()
@ -63,8 +67,8 @@ const LANG_FIELD = {
const DISABLE_ANIMATIONS_FIELD = {
name: 'DISABLE_ANIMATIONS',
label: T.DisableDashboardAnimations,
type: INPUT_TYPES.CHECKBOX,
validation: boolean(),
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
grid: { md: 12 },
}
@ -115,15 +119,14 @@ const ZONE_ENDPOINT_FIELD = ({ zones = [] }) => ({
const FULL_SCREEN_INFO_FIELD = {
name: 'FULL_SCREEN_INFO',
label: T.FullScreenInfo,
type: INPUT_TYPES.CHECKBOX,
validation: boolean(),
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
grid: { md: 12 },
}
const ROW_STYLE_FIELD = {
name: 'ROW_STYLE',
label: T.RowStyle,
type: INPUT_TYPES.AUTOCOMPLETE,
type: INPUT_TYPES.RADIO,
optionsOnly: true,
values: [
{ text: T.Card, value: 'card' },
@ -149,7 +152,34 @@ const ROW_SIZE_FIELD = {
grid: { md: 12 },
}
export const FIELDS_THEME = [SCHEME_FIELD]
export const FIELDS_DATATABLE = [
ROW_STYLE_FIELD,
ROW_SIZE_FIELD,
FULL_SCREEN_INFO_FIELD,
]
/**
* Fields of the other settings form.
*
* @param {object} props - Props
* @param {object} props.views - views.
* @param {string} props.userView - default user view.
* @param {object[]} props.zones - Redux store.
* @returns {object[]} fields
*/
export const FIELDS_OTHERS = (props) => [
LANG_FIELD,
ZONE_ENDPOINT_FIELD(props),
VIEW_FIELD(props),
]
export const FIELDS_ANIMATIONS = [DISABLE_ANIMATIONS_FIELD]
/**
* Field for validation.
*
* @param {object} props - Props
* @param {object} props.views - views.
* @param {string} props.userView - default user view.
@ -157,14 +187,10 @@ const ROW_SIZE_FIELD = {
* @returns {object[]} fields
*/
export const FIELDS = (props) => [
SCHEME_FIELD,
LANG_FIELD,
VIEW_FIELD(props),
ZONE_ENDPOINT_FIELD(props),
ROW_STYLE_FIELD,
ROW_SIZE_FIELD,
DISABLE_ANIMATIONS_FIELD,
FULL_SCREEN_INFO_FIELD,
...FIELDS_THEME,
...FIELDS_OTHERS(props),
...FIELDS_DATATABLE,
...FIELDS_ANIMATIONS,
]
/**

View File

@ -0,0 +1,124 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
export const LIGHT_MODE = `
<svg xmlns="http://www.w3.org/2000/svg" width="166" height="94" viewBox="0 0 166 94" fill="none">
<g filter="url(#filter0_d_55352_394)">
<path d="M153.359 94H12.8552V13.9473C12.8552 11.1234 15.1214 8.85571 17.9435 8.85571H148.27C151.05 8.85571 153.359 11.1234 153.359 13.9473V94Z" fill="#EFEFEF"/>
<path d="M36.0729 94H12.8552V12.8776C12.8552 10.6527 14.6511 8.85571 16.8745 8.85571H36.0729V94Z" fill="white"/>
<path d="M30.9418 26.6547C30.386 26.7831 29.8301 26.9114 29.2743 27.0398C25.8964 27.7672 22.433 28.2378 18.9695 28.409C18.7557 28.3662 19.0551 28.5373 19.0978 28.5801C20.0385 29.2647 21.2357 29.3502 22.3474 29.393C25.1267 29.393 27.906 29.393 30.6425 29.393C30.728 29.393 30.8136 29.3502 30.8563 29.3075C30.8991 29.2647 30.8991 29.0935 30.8991 29.0935V26.6547H30.9418Z" fill="#BFE6F2"/>
<path d="M30.9419 23.6597C29.1888 24.4726 27.3502 25.1572 25.5116 25.7134C23.0317 26.4408 20.4662 26.9542 17.8579 27.2537C17.9862 27.4676 18.1145 27.8099 18.371 27.8955C22.6041 27.6816 26.8371 27.1253 30.9419 26.0985V23.6597Z" fill="#80CDE6"/>
<path d="M30.9418 20.7075C27.8204 22.804 24.2287 24.2587 20.5515 25.2C19.5253 25.4567 18.4991 25.7134 17.4302 25.8846C17.4729 26.1841 17.5585 26.398 17.644 26.6975C22.2191 26.1413 26.7515 25.0289 30.9418 23.0179V20.7075Z" fill="#40B3D9"/>
<path d="M30.9418 16.5144C29.3597 18.397 27.3501 19.9373 25.2122 21.1353C22.946 22.4189 20.466 23.403 17.9861 24.1731C17.815 24.2587 17.473 24.2159 17.473 24.5154C17.4302 24.8149 17.3875 25.0716 17.3875 25.3711C21.4495 24.601 25.4688 23.3174 29.0604 21.2209C29.7018 20.8358 30.3432 20.4507 30.9418 20.0229V16.5144Z" fill="#0098C3"/>
<path d="M30.9418 12.3642C30.899 12.2358 30.728 12.3214 30.557 12.4069C29.8728 12.7064 27.0508 14.0756 26.6232 15.8726C26.4522 16.0865 26.1956 15.8298 26.0246 15.7443C24.4425 14.9741 22.4329 15.2308 21.0646 16.3433C19.6964 17.4557 18.9695 19.3811 19.5681 21.0925C19.6536 21.3064 19.7819 21.5632 19.5681 21.7343C19.3115 21.9055 19.055 22.0338 18.7984 22.2477C18.3709 22.6328 17.9433 23.1035 17.7295 23.6169C21.4495 22.5045 25.0839 20.8786 28.1197 18.4398C29.1459 17.5841 30.0866 16.6428 30.899 15.6159V12.3642H30.9418Z" fill="#0098C3"/>
<path d="M154 94H12V13.9045C12 10.6527 14.651 8 17.9006 8H148.099C151.349 8 154 10.6527 154 13.9045V94ZM13.7103 94H152.29V13.9045C152.29 11.594 150.408 9.71144 148.099 9.71144H17.9006C15.5917 9.71144 13.7103 11.594 13.7103 13.9045V94Z" fill="white"/>
<path d="M25.9392 48.2189H22.8606C19.9103 48.2189 17.5159 45.8229 17.5159 42.8707C17.5159 39.9184 19.9103 37.5224 22.8606 37.5224H25.9392C28.8896 37.5224 31.284 39.9184 31.284 42.8707C31.284 45.8229 28.8896 48.2189 25.9392 48.2189Z" fill="#EDEDED"/>
<path d="M25.9392 61.4826H22.8606C19.9103 61.4826 17.5159 59.0866 17.5159 56.1343C17.5159 53.1821 19.9103 50.7861 22.8606 50.7861H25.9392C28.8896 50.7861 31.284 53.1821 31.284 56.1343C31.284 59.0866 28.8896 61.4826 25.9392 61.4826Z" fill="#EDEDED"/>
<path d="M25.9392 74.7463H22.8606C19.9103 74.7463 17.5159 72.3502 17.5159 69.398C17.5159 66.4458 19.9103 64.0497 22.8606 64.0497H25.9392C28.8896 64.0497 31.284 66.4458 31.284 69.398C31.284 72.3502 28.8896 74.7463 25.9392 74.7463Z" fill="#EDEDED"/>
<path d="M25.9392 88.0099H22.8606C19.9103 88.0099 17.5159 85.6139 17.5159 82.6617C17.5159 79.7094 19.9103 77.3134 22.8606 77.3134H25.9392C28.8896 77.3134 31.284 79.7094 31.284 82.6617C31.284 85.6139 28.8896 88.0099 25.9392 88.0099Z" fill="#EDEDED"/>
<path d="M147.031 19.0816C147.031 21.2209 145.277 22.9751 143.14 22.9751C141.002 22.9751 139.249 21.2209 139.249 19.0816C139.249 16.9423 141.002 15.188 143.14 15.188C145.277 15.188 147.031 16.8995 147.031 19.0816Z" fill="#CCCCCC"/>
<path d="M131.253 17.6269H117.698V19.7662H131.253V17.6269Z" fill="#CCCCCC"/>
<path d="M107.992 17.6269H94.4377V19.7662H107.992V17.6269Z" fill="#CCCCCC"/>
<path d="M153 28.5801H36.0728V29.0801H153V28.5801Z" fill="white"/>
</g>
<defs>
<filter id="filter0_d_55352_394" x="0" y="0" width="166" height="110" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_55352_394"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_55352_394" result="shape"/>
</filter>
</defs>
</svg>`
export const DARK_MODE = `
<svg xmlns="http://www.w3.org/2000/svg" width="166" height="94" viewBox="0 0 166 94" fill="none">
<g opacity="0.8" filter="url(#filter0_d_55352_116)">
<path d="M153.359 94H12.8552V13.9473C12.8552 11.1234 15.1214 8.85571 17.9435 8.85571H148.27C151.05 8.85571 153.359 11.1234 153.359 13.9473V94Z" fill="#222431"/>
<path d="M153 28.5801H36.0728V29.0801H153V28.5801Z" fill="#4A4F6B"/>
<path d="M36.0729 94H12.8552V12.8776C12.8552 10.6527 14.6511 8.85571 16.8745 8.85571H36.0729V94Z" fill="#35384D"/>
<path d="M30.9418 26.6547C30.386 26.7831 29.8301 26.9114 29.2743 27.0398C25.8964 27.7672 22.433 28.2378 18.9695 28.409C18.7557 28.3662 19.0551 28.5373 19.0978 28.5801C20.0385 29.2647 21.2357 29.3502 22.3474 29.393C25.1267 29.393 27.906 29.393 30.6425 29.393C30.728 29.393 30.8136 29.3502 30.8563 29.3075C30.8991 29.2647 30.8991 29.0935 30.8991 29.0935V26.6547H30.9418Z" fill="#BFE6F2"/>
<path d="M30.9419 23.6597C29.1888 24.4726 27.3502 25.1572 25.5116 25.7134C23.0317 26.4408 20.4662 26.9542 17.8579 27.2537C17.9862 27.4676 18.1145 27.8099 18.371 27.8955C22.6041 27.6816 26.8371 27.1253 30.9419 26.0985V23.6597Z" fill="#80CDE6"/>
<path d="M30.9418 20.7075C27.8204 22.804 24.2287 24.2587 20.5515 25.2C19.5253 25.4567 18.4991 25.7134 17.4302 25.8846C17.4729 26.1841 17.5585 26.398 17.644 26.6975C22.2191 26.1413 26.7515 25.0289 30.9418 23.0179V20.7075Z" fill="#40B3D9"/>
<path d="M30.9418 16.5144C29.3597 18.397 27.3501 19.9373 25.2122 21.1353C22.946 22.4189 20.466 23.403 17.9861 24.1731C17.815 24.2587 17.473 24.2159 17.473 24.5154C17.4302 24.8149 17.3875 25.0716 17.3875 25.3711C21.4495 24.601 25.4688 23.3174 29.0604 21.2209C29.7018 20.8358 30.3432 20.4507 30.9418 20.0229V16.5144Z" fill="#0098C3"/>
<path d="M30.9418 12.3642C30.899 12.2358 30.728 12.3214 30.557 12.4069C29.8728 12.7064 27.0508 14.0756 26.6232 15.8726C26.4522 16.0865 26.1956 15.8298 26.0246 15.7443C24.4425 14.9741 22.4329 15.2308 21.0646 16.3433C19.6964 17.4557 18.9695 19.3811 19.5681 21.0925C19.6536 21.3064 19.7819 21.5632 19.5681 21.7343C19.3115 21.9055 19.055 22.0338 18.7984 22.2477C18.3709 22.6328 17.9433 23.1035 17.7295 23.6169C21.4495 22.5045 25.0839 20.8786 28.1197 18.4398C29.1459 17.5841 30.0866 16.6428 30.899 15.6159V12.3642H30.9418Z" fill="#0098C3"/>
<path d="M154 94H12V13.9045C12 10.6527 14.651 8 17.9006 8H148.099C151.349 8 154 10.6527 154 13.9045V94ZM13.7103 94H152.29V13.9045C152.29 11.594 150.408 9.71144 148.099 9.71144H17.9006C15.5917 9.71144 13.7103 11.594 13.7103 13.9045V94Z" fill="#35384D"/>
<path d="M25.9392 48.2189H22.8606C19.9103 48.2189 17.5159 45.8229 17.5159 42.8707C17.5159 39.9184 19.9103 37.5224 22.8606 37.5224H25.9392C28.8896 37.5224 31.284 39.9184 31.284 42.8707C31.284 45.8229 28.8896 48.2189 25.9392 48.2189Z" fill="#4A4F6B"/>
<path d="M25.9392 61.4826H22.8606C19.9103 61.4826 17.5159 59.0866 17.5159 56.1343C17.5159 53.1821 19.9103 50.7861 22.8606 50.7861H25.9392C28.8896 50.7861 31.284 53.1821 31.284 56.1343C31.284 59.0866 28.8896 61.4826 25.9392 61.4826Z" fill="#4A4F6B"/>
<path d="M25.9392 74.7463H22.8606C19.9103 74.7463 17.5159 72.3502 17.5159 69.398C17.5159 66.4458 19.9103 64.0497 22.8606 64.0497H25.9392C28.8896 64.0497 31.284 66.4458 31.284 69.398C31.284 72.3502 28.8896 74.7463 25.9392 74.7463Z" fill="#4A4F6B"/>
<path d="M25.9392 88.0099H22.8606C19.9103 88.0099 17.5159 85.6139 17.5159 82.6617C17.5159 79.7094 19.9103 77.3134 22.8606 77.3134H25.9392C28.8896 77.3134 31.284 79.7094 31.284 82.6617C31.284 85.6139 28.8896 88.0099 25.9392 88.0099Z" fill="#4A4F6B"/>
<path d="M147.031 19.0816C147.031 21.2209 145.277 22.9751 143.14 22.9751C141.002 22.9751 139.249 21.2209 139.249 19.0816C139.249 16.9423 141.002 15.188 143.14 15.188C145.277 15.188 147.031 16.8995 147.031 19.0816Z" fill="#4A4F6B"/>
<path d="M131.253 17.6269H117.698V19.7662H131.253V17.6269Z" fill="#4A4F6B"/>
<path d="M107.992 17.6269H94.4377V19.7662H107.992V17.6269Z" fill="#4A4F6B"/>
</g>
<defs>
<filter id="filter0_d_55352_116" x="0" y="0" width="166" height="110" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_55352_116"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_55352_116" result="shape"/>
</filter>
</defs>
</svg>`
export const SYSTEM_MODE = `
<svg xmlns="http://www.w3.org/2000/svg" width="167" height="99" viewBox="0 0 167 99" fill="none">
<g opacity="0.8" filter="url(#filter0_d_55352_57)">
<path d="M108.055 22.6269H94.4917V24.7662H108.055V22.6269Z" fill="#EFEFEF"/>
<path d="M131.331 22.6269H117.767V24.7662H131.331V22.6269Z" fill="#EFEFEF"/>
<path d="M139.289 24.0816C139.289 21.9423 141.043 20.188 143.182 20.188C145.321 20.188 147.076 21.9423 147.076 24.0816C147.076 26.2209 145.321 27.9751 143.182 27.9751C141.043 27.9751 139.289 26.2209 139.289 24.0816Z" fill="#EFEFEF"/>
<path d="M152.381 33.5801V35.2915H153.237V33.5801H152.381ZM17.9473 13.8557C15.1234 13.8557 12.8557 16.1234 12.8557 18.9473V99H83.0462V13.8557L17.9473 13.8557Z" fill="#EFEFEF"/>
<path d="M83.0462 33.5801H36.0884V34.0801H83.0462V33.5801Z" fill="white"/>
<path d="M36.0886 99H12.8557V17.8776C12.8557 15.6527 14.6527 13.8557 16.8776 13.8557H36.0886V99Z" fill="white"/>
<path d="M30.9543 31.6547C30.3981 31.7831 29.8419 31.9114 29.2857 32.0398C25.9056 32.7672 22.4399 33.2378 18.9742 33.409C18.7603 33.3662 19.0598 33.5373 19.1026 33.5801C20.0439 34.2647 21.2419 34.3502 22.3543 34.393C25.1354 34.393 27.9165 34.393 30.6548 34.393C30.7404 34.393 30.826 34.3502 30.8688 34.3075C30.9115 34.2647 30.9115 34.0935 30.9115 34.0935V31.6547H30.9543Z" fill="#BFE6F2"/>
<path d="M30.9544 28.6597C29.2001 29.4726 27.3603 30.1572 25.5205 30.7134C23.0389 31.4408 20.4718 31.9542 17.8618 32.2537C17.9902 32.4676 18.1185 32.8099 18.3752 32.8955C22.6111 32.6816 26.8469 32.1253 30.9544 31.0985V28.6597Z" fill="#80CDE6"/>
<path d="M30.9542 25.7075C27.8309 27.8468 24.2368 29.2587 20.5572 30.2C19.5304 30.4567 18.5035 30.7134 17.4338 30.8846C17.4766 31.1841 17.5622 31.398 17.6478 31.6975C22.2259 31.1413 26.7612 30.0289 30.9542 28.0179V25.7075Z" fill="#40B3D9"/>
<path d="M30.9543 21.5144C29.3712 23.397 27.3603 24.9373 25.221 26.1353C22.9533 27.4189 20.4717 28.403 17.9901 29.1731C17.819 29.2587 17.4767 29.2159 17.4767 29.5154C17.4339 29.8149 17.3911 30.0716 17.3911 30.3711C21.4558 29.601 25.4777 28.3174 29.0717 26.2209C29.7135 25.8358 30.3553 25.4507 30.9543 25.0229V21.5144Z" fill="#0098C3"/>
<path d="M30.9541 17.3642C30.9113 17.2358 30.7401 17.3214 30.569 17.4069C29.8844 17.7064 27.0605 19.0756 26.6327 20.8726C26.4615 21.0865 26.2048 20.8298 26.0337 20.7443C24.4506 19.9741 22.4396 20.2308 21.0705 21.3433C19.7013 22.4557 18.974 24.3811 19.573 26.0925C19.6585 26.3064 19.7869 26.5632 19.573 26.7343C19.3162 26.9055 19.0595 27.0338 18.8028 27.2477C18.3749 27.6328 17.9471 28.1035 17.7332 28.6169C21.4555 27.5045 25.0924 25.8786 28.1302 23.4398C29.157 22.5841 30.0983 21.6428 30.9113 20.6159V17.3642H30.9541Z" fill="#0098C3"/>
<path d="M147.088 13H83.0464V99H154.092V19.5C154.136 17.1426 149.456 13 147.088 13Z" fill="#222431"/>
<path d="M17.9259 13C14.6741 13 12.0214 15.6527 12.0214 18.9045V99H13.7328V18.9045C13.7328 16.594 15.6154 14.7114 17.9259 14.7114H83.0463V13H17.9259Z" fill="white"/>
<path d="M154.093 18.9045V99H152.381V18.9045C152.381 16.594 150.499 14.7114 148.188 14.7114H83.0464V13H148.145C151.44 13 154.093 15.6527 154.093 18.9045Z" fill="#35384D"/>
<path d="M25.9484 53.2189H22.8678C19.9156 53.2189 17.5195 50.8229 17.5195 47.8707C17.5195 44.9184 19.9156 42.5224 22.8678 42.5224H25.9484C28.9006 42.5224 31.2966 44.9184 31.2966 47.8707C31.2966 50.8229 28.9006 53.2189 25.9484 53.2189Z" fill="#EDEDED"/>
<path d="M25.9484 66.4826H22.8678C19.9156 66.4826 17.5195 64.0866 17.5195 61.1343C17.5195 58.1821 19.9156 55.7861 22.8678 55.7861H25.9484C28.9006 55.7861 31.2966 58.1821 31.2966 61.1343C31.2966 64.0866 28.9006 66.4826 25.9484 66.4826Z" fill="#EDEDED"/>
<path d="M25.9484 79.7463H22.8678C19.9156 79.7463 17.5195 77.3502 17.5195 74.398C17.5195 71.4458 19.9156 69.0497 22.8678 69.0497H25.9484C28.9006 69.0497 31.2966 71.4458 31.2966 74.398C31.2966 77.3502 28.9006 79.7463 25.9484 79.7463Z" fill="#EDEDED"/>
<path d="M25.9484 93.0099H22.8678C19.9156 93.0099 17.5195 90.6139 17.5195 87.6617C17.5195 84.7094 19.9156 82.3134 22.8678 82.3134H25.9484C28.9006 82.3134 31.2966 84.7094 31.2966 87.6617C31.2966 90.6139 28.9006 93.0099 25.9484 93.0099Z" fill="#EDEDED"/>
<path d="M147.119 24.0816C147.119 26.2209 145.364 27.9751 143.225 27.9751C141.086 27.9751 139.332 26.2209 139.332 24.0816C139.332 21.9423 141.086 20.188 143.225 20.188C145.364 20.188 147.119 21.8995 147.119 24.0816Z" fill="#4A4F6B"/>
<path d="M131.331 22.6269H117.767V24.7662H131.331V22.6269Z" fill="#4A4F6B"/>
<path d="M108.055 22.6269H94.4917V24.7662H108.055V22.6269Z" fill="#4A4F6B"/>
<path d="M153.092 33.5801H83.0464V34.0801H153.092V33.5801Z" fill="#35384D"/>
<path d="M83.0464 8V104" stroke="#575C5B" stroke-dasharray="2 2"/>
</g>
<defs>
<filter id="filter0_d_55352_57" x="0.0214844" y="0" width="166.071" height="120" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_55352_57"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_55352_57" result="shape"/>
</filter>
</defs>
</svg>`

View File

@ -13,32 +13,25 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Box, Paper, Stack, TextField, Typography, styled } from '@mui/material'
import CircularProgress from '@mui/material/CircularProgress'
import { StatusChip, SubmitButton, Tr, Translate } from '@ComponentsModule'
import { STYLE_BUTTONS, T } from '@ConstantsModule'
import { AuthAPI, useAuth, useGeneralApi } from '@FeaturesModule'
import { useSearch } from '@HooksModule'
import { getColorFromString } from '@ModelsModule'
import { useSettingWrapper } from '@modules/containers/Settings/Wrapper'
import { Box, Stack, TextField, Typography, styled } from '@mui/material'
import TrashIcon from 'iconoir-react/dist/Trash'
import { ReactElement, useCallback, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { AuthAPI, useGeneralApi, useAuth } from '@FeaturesModule'
import { useSearch } from '@HooksModule'
import {
Tr,
Translate,
StatusChip,
SubmitButton,
TranslateProvider,
} from '@ComponentsModule'
import { T } from '@ConstantsModule'
import { getColorFromString } from '@ModelsModule'
const NEW_LABEL_ID = 'new-label'
const LabelWrapper = styled(Box)(({ theme, ownerState }) => ({
display: 'flex',
direction: 'row',
alignItems: 'center',
paddingInline: '0.5rem',
borderRadius: theme.shape.borderRadius * 2,
padding: `${theme.typography.pxToRem(8)} 0`,
borderBottom: `1px solid ${theme.palette.grey[300]}`,
animation: ownerState.highlight ? 'highlight 2s ease-in-out' : undefined,
'@keyframes highlight': {
from: { backgroundColor: 'yellow' },
@ -52,6 +45,7 @@ const LabelWrapper = styled(Box)(({ theme, ownerState }) => ({
* @returns {ReactElement} Settings configuration UI
*/
export const Settings = () => {
const { Legend, InternalWrapper } = useSettingWrapper()
const { labels } = useAuth()
const { enqueueError } = useGeneralApi()
const [removeLabel, { isLoading: removeLoading }] =
@ -82,7 +76,10 @@ export const Settings = () => {
}, [isSuccess])
const handleAddLabel = useCallback(
async (formData) => {
async (formData, event) => {
event?.preventDefault()
event?.stopPropagation()
try {
await addLabel({ newLabel: formData[NEW_LABEL_ID] }).unwrap()
} catch (error) {
@ -108,25 +105,32 @@ export const Settings = () => {
[removeLabel, handleChange]
)
const handleKeyDown = useCallback(
(evt) => evt.key === 'Enter' && handleSubmit(handleAddLabel)(evt),
[handleAddLabel, handleSubmit]
)
return (
<TranslateProvider>
<Paper
variant="outlined"
sx={{ display: 'flex', flexDirection: 'column' }}
>
<Box mt="0.5rem" p="1rem">
<Typography variant="underline">
<Translate word={T.Labels} />
</Typography>
<Box>
<Legend title={T.Labels} />
<InternalWrapper title={T.Labels}>
<Box display="flex" gap="0.5rem" alignItems="center">
<TextField
sx={{ flexGrow: 1, p: '0.5rem 1rem' }}
disabled={isLoading}
placeholder={Tr(T.NewLabelOrSearch)}
inputProps={{ 'data-cy': NEW_LABEL_ID }}
{...register(NEW_LABEL_ID, { onChange: handleChange })}
/>
<SubmitButton
disabled={isLoading}
onClick={handleSubmit(handleAddLabel)}
importance={STYLE_BUTTONS.IMPORTANCE.MAIN}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.FILLED}
data-cy="create-label"
label={T.CreateLabel}
/>
</Box>
<Stack gap="0.5rem" p="0.5rem" overflow="auto">
{labels.length === 0 && (
<Typography variant="subtitle2">
<Typography variant="subtitle2" align="center">
<Translate word={T.NoLabelsOnList} />
</Typography>
)}
@ -154,21 +158,7 @@ export const Settings = () => {
</LabelWrapper>
))}
</Stack>
<TextField
sx={{ flexGrow: 1, p: '0.5rem 1rem' }}
onKeyDown={handleKeyDown}
disabled={isLoading}
placeholder={Tr(T.NewLabelOrSearch)}
inputProps={{ 'data-cy': NEW_LABEL_ID }}
InputProps={{
endAdornment: isLoading ? (
<CircularProgress size={14} />
) : undefined,
}}
{...register(NEW_LABEL_ID, { onChange: handleChange })}
helperText={Tr(T.PressToCreateLabel)}
/>
</Paper>
</TranslateProvider>
</InternalWrapper>
</Box>
)
}

View File

@ -13,40 +13,36 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Button, Grid, Paper, Typography, useTheme } from '@mui/material'
import { FormWithSchema, Tr, Translate } from '@ComponentsModule'
import { STYLE_BUTTONS, T } from '@ConstantsModule'
import { css } from '@emotion/css'
import {
Tr,
EnhancedTableStyles,
Translate,
FormWithSchema,
TranslateProvider,
} from '@ComponentsModule'
import { T } from '@ConstantsModule'
import { FIELDS, SCHEMA } from '@modules/containers/Settings/LoginToken/schema'
import {
UserAPI,
GroupAPI,
useGeneralApi,
useAuth,
useAuthApi,
useGeneralApi,
UserAPI,
useViews,
} from '@FeaturesModule'
import { useClipboard } from '@HooksModule'
import { timeToString } from '@ModelsModule'
import { SubmitButton } from '@modules/components/FormControl'
import { FIELDS, SCHEMA } from '@modules/containers/Settings/LoginToken/schema'
import { useSettingWrapper } from '@modules/containers/Settings/Wrapper'
import { Box, Grid, Typography, useTheme } from '@mui/material'
import { Cancel, Check as CopiedIcon, Copy as CopyIcon } from 'iconoir-react'
import PropTypes from 'prop-types'
import { ReactElement, useCallback, useMemo } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { v4 as uuidv4 } from 'uuid'
import { css } from '@emotion/css'
const useStyles = () => ({
buttonSubmit: css({
width: '100%',
const styles = ({ typography, palette }) => ({
buttonPlace: css({
textAlign: 'center',
}),
buttonAction: css({
width: '100%',
marginBottom: '.5rem',
marginBottom: typography.pxToRem(8),
}),
token: css({
textOverflow: 'ellipsis',
@ -54,14 +50,20 @@ const useStyles = () => ({
overflow: 'hidden',
}),
message: css({
margin: '2rem 0 0',
margin: `${typography.pxToRem(32)} 0 0`,
}),
tokenContainer: css({
border: `1px solid ${palette.grey[300]}`,
padding: typography.pxToRem(8),
borderRadius: typography.pxToRem(8),
marginBottom: typography.pxToRem(8),
}),
})
const Row = ({ data = {}, groups = [], edit = () => undefined } = {}) => {
const { copy, isCopied } = useClipboard()
const theme = useTheme()
const classes = useMemo(() => useStyles(theme), [theme])
const classes = useMemo(() => styles(theme), [theme])
const { TOKEN = '', EXPIRATION_TIME = 0, EGID = '' } = data
const groupToken =
groups.find((group) => group?.ID === EGID)?.NAME || Tr(T.None)
@ -76,57 +78,49 @@ const Row = ({ data = {}, groups = [], edit = () => undefined } = {}) => {
return (
<>
<TranslateProvider>
{TOKEN && EXPIRATION_TIME && EGID && (
<Grid container role="row">
<Grid item xs={10}>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="body2" gutterBottom sx={{ m: '1rem' }}>
<b>{`${Tr(T.ValidUntil)}: `}</b>
{timeToString(EXPIRATION_TIME)}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="body2" gutterBottom sx={{ m: '1rem' }}>
<b>{`${Tr(T.Group)}: `}</b>
{groupToken}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography
variant="body2"
gutterBottom
sx={{ m: '1rem' }}
className={classes.token}
>
<b>{`${Tr(T.Token)}: `}</b>
{TOKEN}
</Typography>
</Grid>
{TOKEN && EXPIRATION_TIME && EGID && (
<Grid container role="row" className={classes.tokenContainer}>
<Grid item sx={{ flexGrow: 1 }}>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="body2" gutterBottom sx={{ m: '1rem' }}>
<b>{`${Tr(T.ValidUntil)}: `}</b>
{timeToString(EXPIRATION_TIME)}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="body2" gutterBottom sx={{ m: '1rem' }}>
<b>{`${Tr(T.Group)}: `}</b>
{groupToken}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography
variant="body2"
gutterBottom
sx={{ m: '1rem' }}
className={classes.token}
>
<b>{`${Tr(T.Token)}: `}</b>
{TOKEN}
</Typography>
</Grid>
</Grid>
<Grid item xs={2}>
<Button
variant="contained"
size="small"
onClick={handleCopy}
className={classes.buttonAction}
>
{isCopied ? <CopiedIcon /> : <CopyIcon className="icon" />}
</Button>
<Button
variant="contained"
size="small"
onClick={() => edit(TOKEN)}
className={classes.buttonAction}
>
<Cancel className="icon" />
</Button>
</Grid>
</Grid>
)}
</TranslateProvider>
<Grid item>
<SubmitButton
icon={isCopied ? <CopiedIcon /> : <CopyIcon className="icon" />}
onClick={handleCopy}
aria-label="toggle password visibility"
/>
<SubmitButton
icon={<Cancel />}
onClick={() => edit(TOKEN)}
aria-label="delete"
/>
</Grid>
</Grid>
)}
</>
)
}
@ -149,6 +143,7 @@ Row.displayName = 'LoginTokenRow'
* @returns {ReactElement} Settings configuration UI
*/
const LoginToken = () => {
const { Legend, InternalWrapper } = useSettingWrapper()
const { user } = useAuth()
const { LOGIN_TOKEN = [], ID, NAME } = user
const { data: groups = [], isLoading } = GroupAPI.useGetGroupsQuery()
@ -172,8 +167,7 @@ const LoginToken = () => {
const [addLoginToken] = UserAPI.useLoginUserMutation()
const { views, view: userView } = useViews()
const theme = useTheme()
const classes = useMemo(() => useStyles(theme), [theme])
const classesTable = useMemo(() => EnhancedTableStyles(theme), [theme])
const classes = useMemo(() => styles(theme), [theme])
const { handleSubmit, ...methods } = useForm({
reValidateMode: 'onChange',
@ -230,54 +224,58 @@ const LoginToken = () => {
)
return (
<Paper
component="form"
onSubmit={handleSubmit(handleCreateLoginToken)}
variant="outlined"
sx={{ p: '1em' }}
>
<Typography variant="underline">
<Translate word={T.LoginToken} />
</Typography>
{!isLoading && (
<>
{arrayLoginToken.length > 0 && (
<div className={classesTable.rootWithoutHeight}>
<div className={classesTable.bodyWithoutGap}>
{arrayLoginToken.map((itemLoginToken, i) => (
<Row
data={itemLoginToken}
key={i}
groups={groups}
edit={handleDeleteLoginToken}
/>
))}
<Box>
<Legend title={T.LoginToken} />
<InternalWrapper>
{!isLoading && (
<>
{arrayLoginToken.length > 0 && (
<div>
<div>
{arrayLoginToken.map((itemLoginToken, i) => (
<Row
data={itemLoginToken}
key={i}
groups={groups}
edit={handleDeleteLoginToken}
/>
))}
</div>
</div>
</div>
)}
<FormProvider {...methods}>
<FormWithSchema cy={'logintoken-ui'} fields={FIELDS(userGroups)} />
</FormProvider>
<Button
variant="contained"
size="small"
onClick={handleSubmit(handleCreateLoginToken)}
className={classes.buttonSubmit}
data-cy="addLoginToken"
>
{Tr(T.GetNewToken)}
</Button>
</>
)}
<Typography
variant="body2"
gutterBottom
sx={{ m: '1rem' }}
className={classes.message}
>
<Translate word={T.MessageLoginToken} />
</Typography>
</Paper>
)}
<Box
component="form"
onSubmit={handleSubmit(handleCreateLoginToken)}
>
<FormProvider {...methods}>
<FormWithSchema
cy={'logintoken-ui'}
fields={FIELDS(userGroups)}
/>
</FormProvider>
<Box className={classes.buttonPlace}>
<SubmitButton
onClick={handleSubmit(handleCreateLoginToken)}
importance={STYLE_BUTTONS.IMPORTANCE.MAIN}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.FILLED}
data-cy="addLoginToken"
label={T.GetNewToken}
/>
</Box>
</Box>
</>
)}
<Typography
variant="body2"
gutterBottom
sx={{ m: '1rem' }}
className={classes.message}
>
<Translate word={T.MessageLoginToken} />
</Typography>
</InternalWrapper>
</Box>
)
}

View File

@ -0,0 +1,56 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { STYLE_BUTTONS, T } from '@ConstantsModule'
import { css } from '@emotion/css'
import { useAuthApi } from '@FeaturesModule'
import { SubmitButton } from '@modules/components/FormControl'
import { Box, useTheme } from '@mui/material'
import { LogOut as LogOutIcon } from 'iconoir-react'
import { memo, useMemo } from 'react'
const styles = ({ typography }) => ({
root: css({
padding: typography.pxToRem(16),
display: 'inline-flex',
'& > button': {
width: '100%',
},
}),
})
const LogOut = memo(() => {
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const { logout } = useAuthApi()
return (
<Box className={classes.root}>
<SubmitButton
onClick={logout}
importance={STYLE_BUTTONS.IMPORTANCE.SECONDARY}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.OUTLINED}
label={T.SignOut}
data-cy="logout-button"
icon={<LogOutIcon />}
/>
</Box>
)
})
LogOut.displayName = 'LogOut'
export default LogOut

View File

@ -0,0 +1,135 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { css } from '@emotion/css'
import { useSystemData } from '@FeaturesModule'
import LogOut from '@modules/containers/Settings/Menu/LogOut'
import ProfileImage from '@modules/containers/Settings/Menu/ProfileImage'
import {
Box,
List,
ListItem,
ListItemIcon,
ListItemText,
Paper,
useTheme,
} from '@mui/material'
import clsx from 'clsx'
import PropTypes from 'prop-types'
import { ReactElement, useMemo } from 'react'
const styles = ({ typography, palette }) => ({
root: css({
borderRadius: `${typography.pxToRem(24)}`,
display: 'flex',
flexDirection: 'column',
height: '100%',
}),
menu: css({
flexGrow: 1,
overflow: 'auto',
}),
listItem: css({
padding: `0 0 0 ${typography.pxToRem(16)}`,
cursor: 'pointer',
'&:hover': {
backgroundColor: 'transparent',
},
}),
listItemContainer: css({
display: 'flex',
alignItems: 'center',
width: '100%',
padding: typography.pxToRem(12),
}),
listItemSelected: css({
background: palette.sidebar.backgroundColorHover,
borderRadius: `3rem 0 0 ${typography.pxToRem(48)}`,
borderRight: `${typography.pxToRem(3)} solid ${palette.info.dark}`,
}),
icon: css({ minWidth: typography.pxToRem(32) }),
})
/**
* Setting Menu.
*
* @param {object} props - Props
* @param {object} props.options - Options
* @param {string} props.selectedOption - Selected option
* @param {Function} props.setSelectedOption - Set selected option
* @param {any[]} props.optionsRestrincted - Options restricted
* @returns {ReactElement} Settings menu
*/
const Menu = ({
options = {},
selectedOption = '',
setSelectedOption = () => undefined,
optionsRestrincted = [],
}) => {
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const { adminGroup } = useSystemData()
const entriesArray = useMemo(
() =>
Object.entries(options)
.map(([key, value]) => [key, value])
.filter(([key]) => adminGroup || !optionsRestrincted.includes(key)),
[options]
)
return (
<Paper className={classes.root}>
<ProfileImage />
<List className={classes.menu}>
{entriesArray.map(([key, value], index) => (
<ListItem
className={classes.listItem}
key={index}
onClick={() => setSelectedOption(key)}
>
<Box
className={
selectedOption === key
? clsx(classes.listItemContainer, classes.listItemSelected)
: classes.listItemContainer
}
>
{value.icon && (
<ListItemIcon className={classes.icon}>
<value.icon />
</ListItemIcon>
)}
<ListItemText primary={value?.title} />
</Box>
</ListItem>
))}
</List>
<LogOut />
</Paper>
)
}
Menu.propTypes = {
options: PropTypes.object,
selectedOption: PropTypes.string,
setSelectedOption: PropTypes.func,
optionsRestrincted: PropTypes.array,
}
Menu.displayName = 'SettingsMenu'
export { Menu }

View File

@ -0,0 +1,138 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { STYLE_BUTTONS, T } from '@ConstantsModule'
import { css } from '@emotion/css'
import { useAuth, useGeneralApi, UserAPI } from '@FeaturesModule'
import { jsonToXml } from '@ModelsModule'
import SubmitButton from '@modules/components/FormControl/SubmitButton'
import { Translate } from '@modules/components/HOC'
import { Avatar, Box, Typography, useTheme } from '@mui/material'
import { Edit as EditIcon } from 'iconoir-react'
import { ReactElement, useCallback, useMemo } from 'react'
const styles = ({ palette, typography }) => ({
root: css({
textAlign: 'center',
padding: `${typography.pxToRem(32)} 0 ${typography.pxToRem(16)}`,
}),
imagePlace: css({
width: typography.pxToRem(96),
height: typography.pxToRem(96),
position: 'relative',
display: 'inline-block',
}),
image: css({
width: typography.pxToRem(96),
height: typography.pxToRem(96),
border: `${typography.pxToRem(2)} solid ${palette.info.dark}`,
}),
uploadIcon: css({
position: 'absolute',
bottom: '0px',
right: '0px',
backgroundColor: palette.secondary.contrastText,
border: `${typography.pxToRem(2)} solid ${palette.info.dark}`,
borderRadius: '50%',
'& > button, & > button:hover, & > button:active': {
color: `${palette.info.dark} !important`,
},
}),
userName: css({
textAlign: 'center',
paddingBottom: `${typography.pxToRem(32)}`,
'& > *': {
color: palette.info.main,
display: 'inline-block',
},
}),
})
/**
* Profile Image Component.
*
* @returns {ReactElement} ProfileImage component
*/
const ProfileImage = () => {
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const { user } = useAuth()
const [updateUser] = UserAPI.useUpdateUserMutation()
const { enqueueError } = useGeneralApi()
const handleImageChange = useCallback(
(event) => {
const file = event.target.files[0]
if (!file) return
if (file.size > 2 * 1024 ** 2) {
enqueueError(T.LimitProfileImage)
return
}
const reader = new FileReader()
reader.onloadend = async () => {
const userData = {
...(user?.TEMPLATE?.FIREEDGE || {}),
IMAGE_PROFILE: reader.result,
}
const template = jsonToXml({ FIREEDGE: userData })
await updateUser({ id: user.ID, template, replace: 1 })
event.target.value = null
}
reader.readAsDataURL(file)
},
[updateUser, user]
)
return (
<>
<Box className={classes.root}>
<Box className={classes.imagePlace}>
<Avatar
src={user?.TEMPLATE?.FIREEDGE?.IMAGE_PROFILE}
className={classes.image}
/>
<Box className={classes.uploadIcon}>
<SubmitButton
icon={<EditIcon />}
importance={STYLE_BUTTONS.IMPORTANCE.SECONDARY}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.OUTLINED}
onClick={() =>
document.getElementById('file-upload-input').click()
}
/>
<input
id="file-upload-input"
type="file"
hidden
accept="image/*"
onChange={handleImageChange}
/>
</Box>
</Box>
</Box>
<Typography variant="h6" zIndex={2} noWrap className={classes.userName}>
<Translate word={T.Greetings} />
<div> {`${user?.NAME || ''}!`} </div>
</Typography>
</>
)
}
export default ProfileImage

View File

@ -13,15 +13,4 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import { AsyncLoadForm, ConfigurationProps } from '@modules/components/HOC'
import { CreateStepsCallback } from '@UtilsModule'
/**
* @param {ConfigurationProps} configProps - Configuration
* @returns {ReactElement|CreateStepsCallback} Asynchronous loaded form
*/
const ChangePasswordForm = (configProps) =>
AsyncLoadForm({ formPath: 'Settings/ChangePasswordForm' }, configProps)
export { ChangePasswordForm }
export { Menu as SettingsMenu } from '@modules/containers/Settings/Menu/Menu'

View File

@ -13,49 +13,125 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Box, Divider, Typography } from '@mui/material'
import { ReactElement } from 'react'
import { Translate, TranslateProvider } from '@ComponentsModule'
import { T } from '@ConstantsModule'
import { Box, Paper, useTheme } from '@mui/material'
import { ReactElement, useMemo, useState } from 'react'
import { TranslateProvider } from '@ComponentsModule'
import { css } from '@emotion/css'
import { AuthenticationSettings } from '@modules/containers/Settings/Authentication'
import { ConfigurationChangePassword } from '@modules/containers/Settings/ChangePassword'
import { ConfigurationSettings } from '@modules/containers/Settings/ConfigurationUI'
import { LabelSettings } from '@modules/containers/Settings/LabelsSection'
import { LoginSettings } from '@modules/containers/Settings/LoginToken'
import { SettingsMenu } from '@modules/containers/Settings/Menu'
import { ShowbackSettings } from '@modules/containers/Settings/Showback'
import { TfaSettings } from '@modules/containers/Settings/Tfa'
import { Wrapper } from '@modules/containers/Settings/Wrapper'
import {
LabelOutline as LabelsIcon,
Shield as SecurityIcon,
Settings as SettingsIcon,
ReloadWindow as ShowbackIcon,
} from 'iconoir-react'
import { useSystemData } from '@FeaturesModule'
const styles = ({ typography }) => ({
content: css({
borderRadius: `${typography.pxToRem(24)}`,
}),
})
const preferences = 'preferences'
const security = 'security'
const showback = 'showback'
const labels = 'labels'
const optionsRestrincted = [showback]
const optionsSettings = {
[preferences]: {
icon: SettingsIcon,
title: T.Preferences,
component: (
<Wrapper>
<ConfigurationSettings />
</Wrapper>
),
},
[security]: {
icon: SecurityIcon,
title: T.Security,
component: (
<Wrapper>
<ConfigurationChangePassword />
<AuthenticationSettings />
<LoginSettings />
<TfaSettings />
</Wrapper>
),
},
[showback]: {
icon: ShowbackIcon,
title: T['showback.title'],
component: (
<Wrapper>
<ShowbackSettings />
</Wrapper>
),
},
[labels]: {
icon: LabelsIcon,
title: T.Labels,
component: (
<Wrapper>
<LabelSettings />
</Wrapper>
),
},
}
/** @returns {ReactElement} Settings container */
export const Settings = () => {
const { adminGroup } = useSystemData()
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const [selectedOption, setSelectedOption] = useState(preferences)
const setting = optionsSettings[selectedOption]
return (
<Box sx={{ overflow: 'auto' }}>
<TranslateProvider>
<Typography variant="h5">
<Translate word={T.Settings} />
</Typography>
<Divider sx={{ my: '1em' }} />
<Box
display="grid"
gridTemplateColumns={{ sm: '1fr', md: 'repeat(2, minmax(49%, 1fr))' }}
gridAutoRows="auto"
gap="1em"
<TranslateProvider>
<Box
display="grid"
gridTemplateColumns={{ sm: '1fr', md: '250px 1fr' }}
gridAutoRows="auto"
gap="1em"
sx={{
height: {
sm: 'auto',
md: '80%',
},
flexGrow: 1,
marginBottom: '1rem',
}}
>
<SettingsMenu
options={optionsSettings}
selectedOption={selectedOption}
setSelectedOption={setSelectedOption}
optionsRestrincted={optionsRestrincted}
/>
<Paper
className={classes.content}
sx={{
overflow: {
md: 'scroll',
},
}}
>
<ConfigurationSettings />
<LabelSettings />
<AuthenticationSettings />
{adminGroup ? <ShowbackSettings /> : null}
<LoginSettings />
<TfaSettings />
</Box>
</TranslateProvider>
</Box>
{setting.component}
</Paper>
</Box>
</TranslateProvider>
)
}

View File

@ -13,19 +13,20 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useState } from 'react'
import { Paper, Box, Typography, Stack, Button } from '@mui/material'
import { T } from '@ConstantsModule'
import {
Translate,
Tr,
DateRangeFilter,
TranslateProvider,
} from '@ComponentsModule'
import { DateTime } from 'luxon'
import { DateRangeFilter, Tr } from '@ComponentsModule'
import { STYLE_BUTTONS, T } from '@ConstantsModule'
import { css } from '@emotion/css'
import { VmAPI, useGeneralApi } from '@FeaturesModule'
import { SubmitButton } from '@modules/components/FormControl'
import { useSettingWrapper } from '@modules/containers/Settings/Wrapper'
import { Box, Stack, Typography, useTheme } from '@mui/material'
import { DateTime } from 'luxon'
import { ReactElement, useMemo, useState } from 'react'
const styles = ({ typography, palette }) => ({
formContainer: css({
alignItems: 'center',
}),
})
/**
* Section to calculate showback data.
@ -33,6 +34,10 @@ import { VmAPI, useGeneralApi } from '@FeaturesModule'
* @returns {ReactElement} Settings showback
*/
export const Settings = () => {
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const { Legend, InternalWrapper } = useSettingWrapper()
// Get functions to success and error
const { enqueueError, enqueueSuccess } = useGeneralApi()
@ -67,35 +72,34 @@ export const Settings = () => {
}
return (
<TranslateProvider>
<Paper component="form" variant="outlined">
<Box mt="0.5rem" p="1rem">
<Typography variant="underline">
<Translate word={T['showback.title']} />
</Typography>
</Box>
<Stack height={1} gap="0.5rem" p="0.5rem" overflow="auto">
<Box component="form">
<Legend title={T['showback.title']} />
<InternalWrapper>
<Typography variant="body2" gutterBottom sx={{ m: '1rem' }}>
{Tr(T['showback.button.help.paragraph.1'])}
</Typography>
<Stack
height={1}
gap="0.5rem"
p="0.5rem"
overflow="auto"
className={classes.formContainer}
>
<DateRangeFilter
initialStartDate={dateRange.startDate}
initialEndDate={dateRange.endDate}
onDateChange={handleDateChange}
views={['month', 'year']}
/>
<Button
variant="contained"
color="primary"
size="large"
<SubmitButton
onClick={handleCalculateClick}
sx={{ m: '1rem' }}
>
{Tr(T['showback.button.calculateShowback'])}
</Button>
<Typography variant="body2" gutterBottom sx={{ m: '1rem' }}>
{Tr(T['showback.button.help.paragraph.1'])}
</Typography>
importance={STYLE_BUTTONS.IMPORTANCE.MAIN}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.FILLED}
label={T['showback.button.calculateShowback']}
/>
</Stack>
</Paper>
</TranslateProvider>
</InternalWrapper>
</Box>
)
}

View File

@ -14,14 +14,8 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import {
EnhancedTableStyles,
FormWithSchema,
Tr,
Translate,
TranslateProvider,
} from '@ComponentsModule'
import { AUTH_APPS, T } from '@ConstantsModule'
import { FormWithSchema, Tr, Translate } from '@ComponentsModule'
import { AUTH_APPS, STYLE_BUTTONS, T } from '@ConstantsModule'
import {
TfaAPI,
UserAPI,
@ -30,16 +24,17 @@ import {
useGeneralApi,
} from '@FeaturesModule'
import { css } from '@emotion/css'
import { SubmitButton } from '@modules/components/FormControl'
import { FIELDS } from '@modules/containers/Settings/Tfa/schema'
import { useSettingWrapper } from '@modules/containers/Settings/Wrapper'
import {
Box,
Button,
Grid,
Link,
List,
ListItem,
ListItemText,
Paper,
Typography,
useTheme,
} from '@mui/material'
import { Cancel, Trash } from 'iconoir-react'
@ -48,39 +43,45 @@ import { Fragment, ReactElement, useCallback, useMemo, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { v4 as uuidv4 } from 'uuid'
const useStyles = () => ({
buttonSubmit: css({
width: '100%',
marginTop: '1rem',
}),
const styles = ({ typography, palette }) => ({
buttonClose: css({
width: '100%',
marginBottom: '.5rem',
marginBottom: typography.pxToRem(8),
}),
buttonPlace: css({
width: '100%',
textAlign: 'center',
}),
buttonAction: css({
width: '100%',
marginTop: '.5rem',
marginTop: typography.pxToRem(8),
}),
qr: css({
width: '100%',
height: 'auto',
}),
qrContainer: css({
border: `1px solid ${palette.grey[300]}`,
padding: typography.pxToRem(8),
borderRadius: typography.pxToRem(8),
marginBottom: typography.pxToRem(8),
}),
bold: css({
fontWeight: 'bold',
}),
enabled: css({
border: '1px solid rgba(255, 255, 255, 0.12)',
marginTop: '1rem',
borderRadius: '.5rem',
marginTop: typography.pxToRem(16),
borderRadius: typography.pxToRem(8),
}),
verificationCodeForm: css({
paddingRight: '.5rem',
paddingRight: typography.pxToRem(8),
'& > fieldset': {
margin: '0px',
margin: 0,
'& .MuiTextField-root': {
margin: '0px',
margin: 0,
},
},
}),
@ -92,8 +93,7 @@ const Qr = ({
}) => {
const { data = '', isSuccess } = TfaAPI.useGetQrQuery()
const theme = useTheme()
const classes = useMemo(() => useStyles(theme), [theme])
const classesTable = useMemo(() => EnhancedTableStyles(theme), [theme])
const classes = useMemo(() => styles(theme), [theme])
const { enqueueError } = useGeneralApi()
const [enableTfa] = TfaAPI.useEnableTfaMutation()
@ -115,76 +115,69 @@ const Qr = ({
)
return (
<TranslateProvider>
<div className={classesTable.rootWithoutHeight}>
<div className={classesTable.bodyWithoutGap}>
<Grid container role="row">
<Grid item xs={10}>
<Grid container spacing={2}>
<Grid item xs={6}>
{data && isSuccess && (
<img
className={classes.qr}
src={data}
alt={Tr(T.ScanThisQr)}
data-cy="qrTfa"
/>
)}
</Grid>
<Grid item xs={6} className={classes.verificationCodeForm}>
<FormProvider {...methods}>
<FormWithSchema cy={'2fa-ui'} fields={FIELDS} />
</FormProvider>
<Button
variant="contained"
size="small"
onClick={handleSubmit(handleEnableTfa)}
className={classes.buttonAction}
data-cy="addTfa"
>
<Translate word={T.Add} />
</Button>
</Grid>
</Grid>
<div className={classes.qrContainer}>
<Grid container role="row">
<Grid item sx={{ flexGrow: 1 }}>
<Grid container spacing={2}>
<Grid item xs={6}>
{data && isSuccess && (
<img
className={classes.qr}
src={data}
alt={Tr(T.ScanThisQr)}
data-cy="qrTfa"
/>
)}
</Grid>
<Grid item xs={2}>
<Button
variant="contained"
size="small"
onClick={cancelFn}
className={classes.buttonClose}
>
<Cancel className="icon" />
</Button>
</Grid>
<Grid item xs={12}>
<List>
<ListItemText>
<Translate word={T.GetAuthenticatorApp} />
{AUTH_APPS.map(({ text, url }) => (
<Fragment key={text}>
<Link
href={url}
color="info.main"
className={classes.bold}
>
{text}
</Link>{' '}
</Fragment>
))}
</ListItemText>
<ListItemText>
<Translate word={T.ScanThisQr} />
</ListItemText>
<ListItemText>
<Translate word={T.EnterVerificationCode} />
</ListItemText>
</List>
<Grid item xs={6} className={classes.verificationCodeForm}>
<FormProvider {...methods}>
<FormWithSchema cy={'2fa-ui'} fields={FIELDS} />
</FormProvider>
<Box className={classes.buttonAction}>
<SubmitButton
onClick={handleSubmit(handleEnableTfa)}
importance={STYLE_BUTTONS.IMPORTANCE.MAIN}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.FILLED}
data-cy="addTfa"
label={T.Add}
/>
</Box>
</Grid>
</Grid>
</div>
</div>
</TranslateProvider>
</Grid>
<Grid item>
<SubmitButton
icon={<Cancel />}
onClick={cancelFn}
aria-label="delete"
importance={STYLE_BUTTONS.IMPORTANCE.SECONDARY}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.OUTLINED_ICON}
/>
</Grid>
<Grid item xs={12}>
<List>
<ListItemText>
<Translate word={T.GetAuthenticatorApp} />
{AUTH_APPS.map(({ text, url }) => (
<Fragment key={text}>
<Link href={url} color="info.main" className={classes.bold}>
{text}
</Link>{' '}
</Fragment>
))}
</ListItemText>
<ListItemText>
<Translate word={T.ScanThisQr} />
</ListItemText>
<ListItemText>
<Translate word={T.EnterVerificationCode} />
</ListItemText>
</List>
</Grid>
</Grid>
</div>
)
}
Qr.propTypes = {
@ -198,9 +191,10 @@ Qr.propTypes = {
* @returns {ReactElement} Settings configuration UI
*/
const Tfa = () => {
const { Legend, InternalWrapper } = useSettingWrapper()
const { enqueueError } = useGeneralApi()
const theme = useTheme()
const classes = useMemo(() => useStyles(theme), [theme])
const classes = useMemo(() => styles(theme), [theme])
const [displayQr, setDisplayQr] = useState(false)
const {
user,
@ -232,65 +226,66 @@ const Tfa = () => {
}, [removeTfa, fireedge])
return (
<Paper component="form" variant="outlined" sx={{ p: '1em' }}>
<Typography variant="underline">
<Translate word={T.TwoFactorAuthentication} />
</Typography>
{displayQr && (
<Qr
cancelFn={() => setDisplayQr(false)}
refreshUserData={refreshUserData}
/>
)}
{(sunstone?.TWO_FACTOR_AUTH_SECRET ||
fireedge?.TWO_FACTOR_AUTH_SECRET) && (
<List className={classes.enabled}>
{sunstone?.TWO_FACTOR_AUTH_SECRET && (
<ListItem
secondaryAction={
<Button
variant="contained"
size="small"
onClick={handleRemoveTfa}
data-cy="removeTfa"
>
<Trash className="icon" />
</Button>
}
>
<Translate word={T.AuthenticatorAppSunstone} />
</ListItem>
)}
{fireedge?.TWO_FACTOR_AUTH_SECRET && (
<ListItem
secondaryAction={
<Button
variant="contained"
size="small"
onClick={handleRemoveTfa}
data-cy="removeTfa"
>
<Trash className="icon" />
</Button>
}
>
<Translate word={T.AuthenticatorApp} />
</ListItem>
)}
</List>
)}
{!displayQr && !fireedge?.TWO_FACTOR_AUTH_SECRET && (
<Button
variant="contained"
size="small"
onClick={() => setDisplayQr(true)}
className={classes.buttonSubmit}
data-cy="addTfa"
>
<Translate word={T.RegisterAuthenticationApp} />
</Button>
)}
</Paper>
<Box component="form">
<Legend title={T.TwoFactorAuthentication} />
<InternalWrapper>
{displayQr && (
<Qr
cancelFn={() => setDisplayQr(false)}
refreshUserData={refreshUserData}
/>
)}
{(sunstone?.TWO_FACTOR_AUTH_SECRET ||
fireedge?.TWO_FACTOR_AUTH_SECRET) && (
<List className={classes.enabled}>
{sunstone?.TWO_FACTOR_AUTH_SECRET && (
<ListItem
secondaryAction={
<Button
variant="contained"
color="secondary"
onClick={handleRemoveTfa}
data-cy="removeTfa"
>
<Trash className="icon" />
</Button>
}
>
<Translate word={T.AuthenticatorAppSunstone} />
</ListItem>
)}
{fireedge?.TWO_FACTOR_AUTH_SECRET && (
<ListItem
secondaryAction={
<Button
variant="contained"
color="secondary"
onClick={handleRemoveTfa}
data-cy="removeTfa"
>
<Trash className="icon" />
</Button>
}
>
<Translate word={T.AuthenticatorApp} />
</ListItem>
)}
</List>
)}
{!displayQr && !fireedge?.TWO_FACTOR_AUTH_SECRET && (
<Box className={classes.buttonPlace}>
<SubmitButton
onClick={() => setDisplayQr(true)}
importance={STYLE_BUTTONS.IMPORTANCE.MAIN}
size={STYLE_BUTTONS.SIZE.MEDIUM}
type={STYLE_BUTTONS.TYPE.FILLED}
data-cy="addTfa"
label={T.RegisterAuthenticationApp}
/>
</Box>
)}
</InternalWrapper>
</Box>
)
}

View File

@ -0,0 +1,110 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2025, 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 { Translate, TranslateProvider } from '@ComponentsModule'
import { css } from '@emotion/css'
import { Box, Typography, useTheme } from '@mui/material'
import PropTypes from 'prop-types'
import { ReactElement, createContext, useContext, useMemo } from 'react'
const SettingWrapperContext = createContext(null)
const styles = ({ typography }) => ({
content: css({
padding: typography.pxToRem(16),
'& > *': {
marginBottom: typography.pxToRem(48),
},
}),
legend: css({
marginLeft: typography.pxToRem(-16),
padding: `${typography.pxToRem(8)} ${typography.pxToRem(16)}`,
marginBottom: typography.pxToRem(24),
display: 'inline-table',
}),
internalLegend: css({
borderBottom: 0,
}),
internalWrapper: css({
display: 'grid',
gridTemplateColumns: '140px 1fr',
gridAutoRows: 'auto',
gap: '1em',
}),
})
/**
* Wrapper for settings.
*
* @param {object} props - props
* @param {any} props.children - Children
* @returns {ReactElement} React element
*/
const Wrapper = ({ children }) => {
const theme = useTheme()
const classes = useMemo(() => styles(theme), [theme])
const Legend = ({ title = '' }) => (
<Typography
variant="underline"
component="legend"
className={classes.legend}
>
<Translate word={title} />
</Typography>
)
Legend.propTypes = {
title: PropTypes.string,
}
const InternalWrapper = ({ children, title = '' }) => (
<Box
className={classes.internalWrapper}
gridTemplateColumns={{ sm: '1fr' }}
>
<Typography className={classes.internalLegend} component="legend">
<Translate word={title} />
</Typography>
<Box>{children}</Box>
</Box>
)
InternalWrapper.propTypes = {
children: PropTypes.node,
title: PropTypes.string,
}
return (
<TranslateProvider>
<SettingWrapperContext.Provider value={{ Legend, InternalWrapper }}>
<Box className={classes.content}>{children}</Box>
</SettingWrapperContext.Provider>
</TranslateProvider>
)
}
Wrapper.propTypes = {
children: PropTypes.any,
}
Wrapper.displayName = 'Wrapper'
/**
* Legend hook.
*
* @returns {Function} Setting Context
*/
const useSettingWrapper = () => useContext(SettingWrapperContext)
export { Wrapper, useSettingWrapper }

View File

@ -13,10 +13,11 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
export * from '@modules/containers/Settings/Tfa'
export * from '@modules/containers/Settings/Showback'
export * from '@modules/containers/Settings/LoginToken'
export * from '@modules/containers/Settings/LabelsSection'
export * from '@modules/containers/Settings/ConfigurationUI'
export * from '@modules/containers/Settings/Authentication'
export * from '@modules/containers/Settings/ConfigurationUI'
export * from '@modules/containers/Settings/LabelsSection'
export * from '@modules/containers/Settings/LoginToken'
export * from '@modules/containers/Settings/Menu'
export * from '@modules/containers/Settings/Settings'
export * from '@modules/containers/Settings/Showback'
export * from '@modules/containers/Settings/Tfa'

View File

@ -115,6 +115,10 @@ module.exports = {
},
include: path.resolve(__dirname, '../../'),
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpe?g|gif)$/i,
use: [

View File

@ -13,8 +13,8 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useState, useEffect, useMemo } from 'react'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useState } from 'react'
/**
* @typedef {object} useSearchHook

View File

@ -14,20 +14,20 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import _ from 'lodash'
import { isMergeableObject } from '@modules/utils/merge'
import { Field } from '@modules/utils/schema'
import DOMPurify from 'dompurify'
import { v4 as uuidv4 } from 'uuid'
import { BaseSchema, ObjectSchema, object, reach } from 'yup'
import { sentenceCase } from '@modules/utils/string'
import {
DOCS_BASE_PATH,
ERROR_LOOKUP_TABLE,
HYPERVISORS,
UNITS,
VN_DRIVERS,
DOCS_BASE_PATH,
} from '@ConstantsModule'
import { isMergeableObject } from '@modules/utils/merge'
import { Field } from '@modules/utils/schema'
import { sentenceCase } from '@modules/utils/string'
import DOMPurify from 'dompurify'
import _ from 'lodash'
import { v4 as uuidv4 } from 'uuid'
import { BaseSchema, ObjectSchema, object, reach } from 'yup'
/**
* Simulate a delay in a function.
@ -735,3 +735,24 @@ export const deepClean = (obj) => {
return obj
}
/**
* @param {string} value - value for interpolation
* @returns {string} - interpolation value
*/
export const interpolationBytesSeg = (value) =>
value ? `${prettyBytes(value)}/s` : value
/**
* @param {string} value - value for interpolation
* @returns {string} - interpolation value
*/
export const interpolationBytes = (value) =>
value ? prettyBytes(value) : value
/**
* @param {string} value - value for interpolation
* @returns {string} - interpolation value
*/
export const interpolationValue = (value) =>
value ? +value?.toFixed(2) : +value

View File

@ -163,6 +163,9 @@ const getViews = (
* 3 -> Group template has not TEMPLATE.FIREEDGE.VIEWS and TEMPLATE.FIREEDGE.GROUP_ADMIN_VIEWS
*/
// Create info views
const views = {}
if (
isAdminGroup &&
groupAdminViews &&
@ -170,9 +173,6 @@ const getViews = (
) {
// First case: Group template has TEMPLATE.FIREEDGE.GROUP_ADMIN_VIEWS and the user is admin of the group
// Create info views
const views = {}
// Fill info of each view reading the files on global.paths.SUNSTONE_PATH/{view name}
fillViewsInfo(groupAdminViews, views)
@ -194,9 +194,6 @@ const getViews = (
else if (groupViews && groupViews.length > 0) {
// Second case: Group template has TEMPLATE.FIREEDGE.VIEWS
// Create info views
const views = {}
// Fill info of each view reading the files on global.paths.SUNSTONE_PATH/{view name}
fillViewsInfo(groupViews, views)
@ -232,9 +229,6 @@ const getViews = (
jsonFileData.groups[vmgroupData.GROUP.NAME] ||
jsonFileData.default
// Create info views
const views = {}
// Fill info of each view reading the files on global.paths.SUNSTONE_PATH/{view name}
fillViewsInfo(groupViewsFile, views)

View File

@ -547,6 +547,9 @@ const genPathResources = () => {
: resolve(`${ONE_LOCATION}`, `${defaultSourceSystemPath}`))
const VAR_LOCATION = !ONE_LOCATION ? defaultVarPath : `${ONE_LOCATION}/var`
const ETC_LOCATION = !ONE_LOCATION ? defaultEtcPath : `${ONE_LOCATION}/etc`
const ETC_VIEWS_LOCATION =
(devMode && resolve(__dirname, '..', '..', '..', 'etc', 'sunstone')) ||
`${ETC_LOCATION}/${defaultSunstonePath}`
const MODULES_LOCATION =
(devMode && resolve(__dirname, '..', '..', '..', 'etc', 'sunstone')) ||
`${ETC_LOCATION}/${defaultSunstonePath}`
@ -575,7 +578,7 @@ const genPathResources = () => {
global.paths.SUNSTONE_AUTH_PATH = `${VAR_LOCATION}/.one/${defaultSunstoneAuth}`
}
if (!global.paths.SUNSTONE_PATH) {
global.paths.SUNSTONE_PATH = `${ETC_LOCATION}/${defaultSunstonePath}/`
global.paths.SUNSTONE_PATH = `${ETC_VIEWS_LOCATION}/`
}
if (!global.paths.SUNSTONE_CONFIG) {
global.paths.SUNSTONE_CONFIG = `${ETC_LOCATION}/${defaultSunstonePath}/${defaultSunstoneConfig}`
@ -584,7 +587,7 @@ const genPathResources = () => {
global.paths.SUNSTONE_IMAGES = `${SYSTEM_LOCATION}/assets/images/logos`
}
if (!global.paths.SUNSTONE_VIEWS) {
global.paths.SUNSTONE_VIEWS = `${ETC_LOCATION}/${defaultSunstonePath}/${defaultSunstoneViews}`
global.paths.SUNSTONE_VIEWS = `${ETC_VIEWS_LOCATION}/${defaultSunstoneViews}`
}
if (!global.paths.VMM_EXEC_CONFIG) {
global.paths.VMM_EXEC_CONFIG = `${ETC_LOCATION}/vmm_exec`