1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-01-26 10:03:37 +03:00

F OpenNebula/one#6119: Add Cluster tab (#2885)

This commit is contained in:
David 2024-01-05 11:43:23 +01:00 committed by GitHub
parent 592a2a64f5
commit 863ed452f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1532 additions and 49 deletions

View File

@ -2986,11 +2986,12 @@ FIREEDGE_SUNSTONE_ETC_VIEW_ADMIN="src/fireedge/etc/sunstone/admin/vm-tab.yaml \
src/fireedge/etc/sunstone/admin/backup-tab.yaml \
src/fireedge/etc/sunstone/admin/datastore-tab.yaml \
src/fireedge/etc/sunstone/admin/vdc-tab.yaml \
src/fireedge/etc/sunstone/admin/user-tab.yaml \
src/fireedge/etc/sunstone/admin/user-tab.yaml \
src/fireedge/etc/sunstone/admin/backupjobs-tab.yaml \
src/fireedge/etc/sunstone/admin/host-tab.yaml \
src/fireedge/etc/sunstone/admin/group-tab.yaml \
src/fireedge/etc/sunstone/admin/acl-tab.yaml"
src/fireedge/etc/sunstone/admin/acl-tab.yaml \
src/fireedge/etc/sunstone/admin/cluster-tab.yaml"
FIREEDGE_SUNSTONE_ETC_VIEW_USER="src/fireedge/etc/sunstone/user/vm-tab.yaml \
src/fireedge/etc/sunstone/user/vm-template-tab.yaml \

View File

@ -207,6 +207,12 @@ const ClusterDetail = loadable(
() => import('client/containers/Clusters/Detail'),
{ ssr: false }
)
const CreateCluster = loadable(
() => import('client/containers/Clusters/Create'),
{
ssr: false,
}
)
const Hosts = loadable(() => import('client/containers/Hosts'), { ssr: false })
const HostDetail = loadable(() => import('client/containers/Hosts/Detail'), {
ssr: false,
@ -358,6 +364,7 @@ export const PATH = {
CLUSTERS: {
LIST: `/${RESOURCE_NAMES.CLUSTER}`,
DETAIL: `/${RESOURCE_NAMES.CLUSTER}/:id`,
CREATE: `/${RESOURCE_NAMES.CLUSTER}/create`,
},
HOSTS: {
LIST: `/${RESOURCE_NAMES.HOST}`,
@ -687,6 +694,11 @@ const ENDPOINTS = [
icon: ClusterIcon,
Component: Clusters,
},
{
title: T.CreateCluster,
path: PATH.INFRASTRUCTURE.CLUSTERS.CREATE,
Component: CreateCluster,
},
{
title: T.Cluster,
description: (params) => `#${params?.id}`,

View File

@ -0,0 +1,104 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { T } from 'client/constants'
import { SCHEMA, FIELDS } from './schema'
import { Grid, Card, CardContent, Typography } from '@mui/material'
import { Tr } from 'client/components/HOC'
import { generateDocLink } from 'client/utils'
export const STEP_ID = 'datastores'
const Content = (version) => (
<Grid mt={2} container>
<Grid item xs={8}>
<FormWithSchema id={STEP_ID} cy={`${STEP_ID}`} fields={FIELDS} />
</Grid>
<Grid item xs={4}>
<Card
elevation={2}
sx={{
height: '100%',
minHeight: '630px',
maxHeight: '630px',
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
marginLeft: '1em',
marginTop: '1rem',
}}
>
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: '1em',
}}
>
<Typography variant="h6" component="div" gutterBottom>
{Tr(T['cluster.form.create.datastores.help.title'])}
</Typography>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.datastores.help.paragraph.1'])}
</Typography>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.datastores.help.paragraph.2'])}
</Typography>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.datastores.help.paragraph.3'])}
</Typography>
<Typography variant="body2" gutterBottom>
{' '}
<a
target="_blank"
href={generateDocLink(
version,
'management_and_operations/host_cluster_management/cluster_guide.html'
)}
rel="noreferrer"
>
{Tr(T['cluster.form.create.help.link'])}
</a>
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
)
/**
* Datastores Cluster configuration.
*
* @param {object} props - Step properties
* @param {string} props.version - OpeNebula version
* @returns {object} Datastores configuration step
*/
const Datastores = ({ version }) => ({
id: STEP_ID,
label: T.SelectDatastores,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: () => Content(version),
})
Datastores.propTypes = {
data: PropTypes.object,
setFormData: PropTypes.func,
}
export default Datastores

View File

@ -0,0 +1,39 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { INPUT_TYPES, T } from 'client/constants'
import { Field, getObjectSchemaFromFields } from 'client/utils'
import { string, array } from 'yup'
import { DatastoresTable } from 'client/components/Tables'
/** @type {Field} Datastores field */
const DATASTORES = {
name: 'ID',
label: T.SelectDatastores,
type: INPUT_TYPES.TABLE,
Table: () => DatastoresTable,
singleSelect: false,
validation: array(string().trim()).default(() => undefined),
grid: { md: 12 },
fieldProps: {
preserveState: true,
},
}
const FIELDS = [DATASTORES]
const SCHEMA = getObjectSchemaFromFields(FIELDS)
export { SCHEMA, FIELDS }

View File

@ -0,0 +1,118 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { T } from 'client/constants'
import { SCHEMA, FIELDS } from './schema'
import { Grid, Card, CardContent, Typography } from '@mui/material'
import { Tr } from 'client/components/HOC'
import { generateDocLink } from 'client/utils'
export const STEP_ID = 'general'
const Content = (version) => (
<Grid mt={2} container>
<Grid item xs={8}>
<FormWithSchema id={STEP_ID} cy={`${STEP_ID}`} fields={FIELDS} />
</Grid>
<Grid item xs={4}>
<Card
elevation={2}
sx={{
height: '100%',
minHeight: '630px',
maxHeight: '630px',
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
marginLeft: '1em',
marginTop: '1rem',
}}
>
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: '1em',
}}
>
<Typography variant="h6" component="div" gutterBottom>
{' '}
{Tr(T['cluster.form.create.general.help.title'])}{' '}
</Typography>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.general.help.paragraph.1.1'])}{' '}
</Typography>
<ul>
<li>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.general.help.paragraph.1.2'])}{' '}
</Typography>
</li>
<li>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.general.help.paragraph.1.3'])}{' '}
</Typography>
</li>
</ul>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.general.help.paragraph.2'])}{' '}
</Typography>
<Typography variant="body2" gutterBottom>
{' '}
<a
target="_blank"
href={generateDocLink(
version,
'management_and_operations/host_cluster_management/cluster_guide.html'
)}
rel="noreferrer"
>
{Tr(T['cluster.form.create.help.link'])}
</a>
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
)
/**
* General Cluster configuration.
*
* @param {object} props - Step properties
* @param {string} props.version - OpeNebula version
* @returns {object} General configuration step
*/
const General = ({ version }) => ({
id: STEP_ID,
label: T.General,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: () => Content(version),
})
General.propTypes = {
data: PropTypes.object,
setFormData: PropTypes.func,
}
export default General

View File

@ -0,0 +1,36 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { INPUT_TYPES, T } from 'client/constants'
import { Field, getObjectSchemaFromFields } from 'client/utils'
import { string } from 'yup'
/** @type {Field} Name field */
const NAME = {
name: 'NAME',
label: T['cluster.create.name'],
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { md: 12 },
}
const FIELDS = [NAME]
const SCHEMA = getObjectSchemaFromFields(FIELDS)
export { SCHEMA, FIELDS }

View File

@ -0,0 +1,104 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { T } from 'client/constants'
import { SCHEMA, FIELDS } from './schema'
import { Grid, Card, CardContent, Typography } from '@mui/material'
import { Tr } from 'client/components/HOC'
import { generateDocLink } from 'client/utils'
export const STEP_ID = 'hosts'
const Content = (version) => (
<Grid mt={2} container>
<Grid item xs={8}>
<FormWithSchema id={STEP_ID} cy={`${STEP_ID}`} fields={FIELDS} />
</Grid>
<Grid item xs={4}>
<Card
elevation={2}
sx={{
height: '100%',
minHeight: '630px',
maxHeight: '630px',
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
marginLeft: '1em',
marginTop: '1rem',
}}
>
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: '1em',
}}
>
<Typography variant="h6" component="div" gutterBottom>
{' '}
{Tr(T['cluster.form.create.hosts.help.title'])}{' '}
</Typography>
<Typography variant="body2" gutterBottom>
{' '}
{Tr(T['cluster.form.create.hosts.help.paragraph.1'])}{' '}
</Typography>
<Typography variant="body2" gutterBottom>
{' '}
{Tr(T['cluster.form.create.hosts.help.paragraph.2'])}{' '}
</Typography>
<Typography variant="body2" gutterBottom>
{' '}
<a
target="_blank"
href={generateDocLink(
version,
'management_and_operations/host_cluster_management/cluster_guide.html'
)}
rel="noreferrer"
>
{Tr(T['cluster.form.create.help.link'])}
</a>
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
)
/**
* Hosts Cluster configuration.
*
* @param {object} props - Step properties
* @param {string} props.version - OpeNebula version
* @returns {object} Hosts configuration step
*/
const Hosts = ({ version }) => ({
id: STEP_ID,
label: T.SelectHosts,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: () => Content(version),
})
Hosts.propTypes = {
data: PropTypes.object,
setFormData: PropTypes.func,
}
export default Hosts

View File

@ -0,0 +1,39 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { INPUT_TYPES, T } from 'client/constants'
import { Field, getObjectSchemaFromFields } from 'client/utils'
import { string, array } from 'yup'
import { HostsTable } from 'client/components/Tables'
/** @type {Field} HostsTable field */
const HOSTS = {
name: 'ID',
label: T.SelectHosts,
type: INPUT_TYPES.TABLE,
Table: () => HostsTable,
singleSelect: false,
validation: array(string().trim()).default(() => undefined),
grid: { md: 12 },
fieldProps: {
preserveState: true,
},
}
const FIELDS = [HOSTS]
const SCHEMA = getObjectSchemaFromFields(FIELDS)
export { SCHEMA, FIELDS }

View File

@ -0,0 +1,101 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { T } from 'client/constants'
import { SCHEMA, FIELDS } from './schema'
import { Grid, Card, CardContent, Typography } from '@mui/material'
import { Tr } from 'client/components/HOC'
import { generateDocLink } from 'client/utils'
export const STEP_ID = 'vnets'
const Content = (version) => (
<Grid mt={2} container>
<Grid item xs={8}>
<FormWithSchema id={STEP_ID} cy={`${STEP_ID}`} fields={FIELDS} />
</Grid>
<Grid item xs={4}>
<Card
elevation={2}
sx={{
height: '100%',
minHeight: '630px',
maxHeight: '630px',
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
marginLeft: '1em',
marginTop: '1rem',
}}
>
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: '1em',
}}
>
<Typography variant="h6" component="div" gutterBottom>
{Tr(T['cluster.form.create.vnets.help.title'])}
</Typography>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.vnets.help.paragraph.1'])}
</Typography>
<Typography variant="body2" gutterBottom>
{Tr(T['cluster.form.create.vnets.help.paragraph.2'])}
</Typography>
<Typography variant="body2" gutterBottom>
{' '}
<a
target="_blank"
href={generateDocLink(
version,
'management_and_operations/host_cluster_management/cluster_guide.html'
)}
rel="noreferrer"
>
{Tr(T['cluster.form.create.help.link'])}
</a>
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
)
/**
* Vnets Cluster configuration.
*
* @param {object} props - Step properties
* @param {string} props.version - OpeNebula version
* @returns {object} Vnets configuration step
*/
const Vnets = ({ version }) => ({
id: STEP_ID,
label: T.SelectVirtualNetworks,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: () => Content(version),
})
Vnets.propTypes = {
data: PropTypes.object,
setFormData: PropTypes.func,
}
export default Vnets

View File

@ -0,0 +1,39 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { INPUT_TYPES, T } from 'client/constants'
import { Field, getObjectSchemaFromFields } from 'client/utils'
import { string, array } from 'yup'
import { VNetworksTable } from 'client/components/Tables'
/** @type {Field} Vnets field */
const VNETS = {
name: 'ID',
label: T.SelectVirtualNetworks,
type: INPUT_TYPES.TABLE,
Table: () => VNetworksTable,
singleSelect: false,
validation: array(string().trim()).default(() => undefined),
grid: { md: 12 },
fieldProps: {
preserveState: true,
},
}
const FIELDS = [VNETS]
const SCHEMA = getObjectSchemaFromFields(FIELDS)
export { SCHEMA, FIELDS }

View File

@ -0,0 +1,135 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import General, {
STEP_ID as GENERAL_ID,
} from 'client/components/Forms/Cluster/CreateForm/Steps/General'
import Hosts, {
STEP_ID as HOSTS_ID,
} from 'client/components/Forms/Cluster/CreateForm/Steps/Hosts'
import Vnets, {
STEP_ID as VNETS_ID,
} from 'client/components/Forms/Cluster/CreateForm/Steps/Vnets'
import Datastores, {
STEP_ID as DATASTORES_ID,
} from 'client/components/Forms/Cluster/CreateForm/Steps/Datastores'
import { createSteps } from 'client/utils'
const _ = require('lodash')
/**
* Create steps for Cluster Create Form:
* 1. General: Name of the cluster
* 2. Hosts: Select hosts
* 3. Vnets: Select virtual networks
* 4. Datastores: Select datastores
*/
const Steps = createSteps([General, Hosts, Vnets, Datastores], {
transformInitialValue: (cluster, schema) => {
const knownTemplate = schema.cast(
{
[GENERAL_ID]: { NAME: cluster.NAME },
[HOSTS_ID]: !_.isEmpty(cluster?.HOSTS)
? {
ID: Array.isArray(cluster?.HOSTS?.ID)
? cluster?.HOSTS?.ID
: [cluster?.HOSTS?.ID],
}
: undefined,
[VNETS_ID]: !_.isEmpty(cluster?.VNETS)
? {
ID: Array.isArray(cluster?.VNETS?.ID)
? cluster?.VNETS?.ID
: [cluster?.VNETS?.ID],
}
: undefined,
[DATASTORES_ID]: !_.isEmpty(cluster?.DATASTORES)
? {
ID: Array.isArray(cluster?.DATASTORES?.ID)
? cluster?.DATASTORES?.ID
: [cluster?.DATASTORES?.ID],
}
: undefined,
},
{
stripUnknown: true,
}
)
return knownTemplate
},
transformBeforeSubmit: (formData, initialValues) => {
const update = !!initialValues
if (update) {
// Get hosts to add and hosts to delete
const initialHosts = !_.isEmpty(initialValues?.HOSTS?.ID)
? Array.isArray(initialValues?.HOSTS?.ID)
? initialValues?.HOSTS?.ID
: [initialValues?.HOSTS?.ID]
: undefined
const addHosts = _.difference(formData?.hosts?.ID, initialHosts)
const removeHosts = _.difference(initialHosts, formData?.hosts?.ID)
// Get vnets to add and vnets to delete
const initialVnets = !_.isEmpty(initialValues?.VNETS?.ID)
? Array.isArray(initialValues?.VNETS?.ID)
? initialValues?.VNETS?.ID
: [initialValues?.VNETS?.ID]
: undefined
const addVnets = _.difference(formData?.vnets?.ID, initialVnets)
const removeVnets = _.difference(initialVnets, formData?.vnets?.ID)
// Get datastores to add and datastores to delete
const initialDatastores = !_.isEmpty(initialValues?.DATASTORES?.ID)
? Array.isArray(initialValues?.DATASTORES?.ID)
? initialValues?.DATASTORES?.ID
: [initialValues?.DATASTORES?.ID]
: undefined
const addDatastores = _.difference(
formData?.datastores?.ID,
initialDatastores
)
const removeDatastores = _.difference(
initialDatastores,
formData?.datastores?.ID
)
// Check if the name has been changed
const changeName =
initialValues?.NAME === formData?.general?.NAME
? undefined
: formData?.general?.NAME
return {
...formData,
addHosts,
removeHosts,
addVnets,
removeVnets,
addDatastores,
removeDatastores,
changeName,
}
}
return formData
},
})
export default Steps

View File

@ -0,0 +1,16 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
export { default } from 'client/components/Forms/Cluster/CreateForm/Steps'

View File

@ -24,4 +24,11 @@ import { CreateFormCallback } from 'client/utils/schema'
const ChangeClusterForm = (configProps) =>
AsyncLoadForm({ formPath: 'Cluster/ChangeClusterForm' }, configProps)
export { ChangeClusterForm }
/**
* @param {ConfigurationProps} configProps - Configuration
* @returns {ReactElement|CreateFormCallback} Asynchronous loaded form
*/
const CreateForm = (configProps) =>
AsyncLoadForm({ formPath: 'Cluster/CreateForm' }, configProps)
export { ChangeClusterForm, CreateForm }

View File

@ -0,0 +1,121 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Typography } from '@mui/material'
import { AddCircledOutline, Trash } from 'iconoir-react'
import { useMemo } from 'react'
import { useHistory } from 'react-router-dom'
import { useViews } from 'client/features/Auth'
import { useRemoveClusterMutation } from 'client/features/OneApi/cluster'
import {
createActions,
GlobalAction,
} from 'client/components/Tables/Enhanced/Utils'
import { PATH } from 'client/apps/sunstone/routesOne'
import { Translate } from 'client/components/HOC'
import { RESOURCE_NAMES, T, CLUSTER_ACTIONS } from 'client/constants'
const ListClusterNames = ({ rows = [] }) =>
rows?.map?.(({ id, original }) => {
const { ID, NAME } = original
return (
<Typography
key={`cluster-${id}`}
variant="inherit"
component="span"
display="block"
>
{`#${ID} ${NAME}`}
</Typography>
)
})
const MessageToConfirmAction = (rows, description) => (
<>
<ListClusterNames rows={rows} />
{description && <Translate word={description} />}
<Translate word={T.DoYouWantProceed} />
</>
)
MessageToConfirmAction.displayName = 'MessageToConfirmAction'
/**
* Generates the actions to operate resources on Clusters table.
*
* @returns {GlobalAction} - Actions
*/
const Actions = () => {
const history = useHistory()
const { view, getResourceView } = useViews()
const [remove] = useRemoveClusterMutation()
return useMemo(
() =>
createActions({
filters: getResourceView(RESOURCE_NAMES.CLUSTER)?.actions,
actions: [
{
accessor: CLUSTER_ACTIONS.CREATE_DIALOG,
tooltip: T.Create,
icon: AddCircledOutline,
action: () => history.push(PATH.INFRASTRUCTURE.CLUSTERS.CREATE),
},
{
accessor: CLUSTER_ACTIONS.UPDATE_DIALOG,
label: T.Update,
tooltip: T.Update,
selected: { max: 1 },
color: 'secondary',
action: (rows) => {
const cluster = rows?.[0]?.original ?? {}
const path = PATH.INFRASTRUCTURE.CLUSTERS.CREATE
history.push(path, cluster)
},
},
{
accessor: CLUSTER_ACTIONS.DELETE,
tooltip: T.Delete,
icon: Trash,
color: 'error',
selected: { min: 1 },
dataCy: `cluster_${CLUSTER_ACTIONS.DELETE}`,
options: [
{
isConfirmDialog: true,
dialogProps: {
title: T.Delete,
dataCy: `modal-${CLUSTER_ACTIONS.DELETE}`,
children: MessageToConfirmAction,
},
onSubmit: (rows) => async () => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map((id) => remove({ id })))
},
},
],
},
],
}),
[view]
)
}
export default Actions

View File

@ -29,23 +29,29 @@ const Row = ({ original, value, ...props }) => {
<div data-cy={`cluster-${ID}`} {...props}>
<div className={classes.main}>
<div className={classes.title}>
<Typography noWrap component="span">
<Typography noWrap component="span" data-cy="cluster-card-name">
{NAME}
</Typography>
</div>
<div className={classes.caption}>
<span>{`#${ID}`}</span>
<span title={`Total Hosts: ${HOSTS}`}>
<span data-cy="cluster-card-id">{`#${ID}`}</span>
<span data-cy="cluster-card-hosts" title={`Total Hosts: ${HOSTS}`}>
<HardDrive />
<span>{` ${HOSTS}`}</span>
<span>{`${HOSTS}`}</span>
</span>
<span title={`Total Datastores: ${DATASTORES}`}>
<Folder />
<span>{` ${DATASTORES}`}</span>
</span>
<span title={`Total Virtual Networks: ${VNETS}`}>
<span
data-cy="cluster-card-vnets"
title={`Total Virtual Networks: ${VNETS}`}
>
<NetworkAlt />
<span>{` ${VNETS}`}</span>
<span>{`${VNETS}`}</span>
</span>
<span
data-cy="cluster-card-datastores"
title={`Total Datastores: ${DATASTORES}`}
>
<Folder />
<span>{`${DATASTORES}`}</span>
</span>
{PROVIDER_NAME && (
<span title={`Provider: ${PROVIDER_NAME}`}>

View File

@ -54,7 +54,7 @@ const DatastoresTable = (props) => {
let values
if (typeof filter === 'function') {
if (typeof filter === 'function' && dependOf) {
const { watch } = useFormContext()
const getDataForDepend = useCallback(

View File

@ -54,7 +54,7 @@ const HostsTable = (props) => {
let values
if (typeof filter === 'function') {
if (typeof filter === 'function' && dependOf) {
const { watch } = useFormContext()
const getDataForDepend = useCallback(

View File

@ -54,7 +54,7 @@ const VNetworksTable = (props) => {
let values
if (typeof filter === 'function') {
if (typeof filter === 'function' && dependOf) {
const { watch } = useFormContext()
const getDataForDepend = useCallback(

View File

@ -0,0 +1,77 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { DatastoresTable } from 'client/components/Tables'
import { useGetClusterQuery } from 'client/features/OneApi/cluster'
import { useHistory, generatePath } from 'react-router-dom'
import { PATH } from 'client/apps/sunstone/routesOne'
const _ = require('lodash')
/**
* Renders datastores tab showing the datastores of the cluster.
*
* @param {object} props - Props
* @param {string} props.id - Cluster id
* @returns {ReactElement} Datastores tab
*/
const Datastores = ({ id }) => {
// Get info about the cluster
const { data: cluster } = useGetClusterQuery({ id })
// Define function to get details of a datastore
const history = useHistory()
const handleRowClick = (rowId) => {
history.push(
generatePath(PATH.STORAGE.DATASTORES.DETAIL, { id: String(rowId) })
)
}
// Get datastores of the cluster
const datastores = _.isEmpty(cluster?.DATASTORES)
? []
: Array.isArray(cluster?.DATASTORES?.ID)
? cluster?.DATASTORES?.ID
: [cluster?.DATASTORES?.ID]
return (
<div>
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
<DatastoresTable
disableRowSelect
filter={(dataToFilter) =>
dataToFilter.filter((ds) => _.includes(datastores, ds.ID))
}
onRowClick={(row) => handleRowClick(row.ID)}
/>
</Stack>
</div>
)
}
Datastores.propTypes = {
id: PropTypes.string,
}
Datastores.displayName = 'Datastores'
export default Datastores

View File

@ -0,0 +1,77 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { HostsTable } from 'client/components/Tables'
import { useGetClusterQuery } from 'client/features/OneApi/cluster'
import { useHistory, generatePath } from 'react-router-dom'
import { PATH } from 'client/apps/sunstone/routesOne'
const _ = require('lodash')
/**
* Renders hosts tab showing the hosts of the cluster.
*
* @param {object} props - Props
* @param {string} props.id - Cluster id
* @returns {ReactElement} Hosts tab
*/
const Hosts = ({ id }) => {
// Get info about the cluster
const { data: cluster } = useGetClusterQuery({ id })
// Define function to get details of a host
const history = useHistory()
const handleRowClick = (rowId) => {
history.push(
generatePath(PATH.INFRASTRUCTURE.HOSTS.DETAIL, { id: String(rowId) })
)
}
// Get hosts of the cluster
const hosts = _.isEmpty(cluster?.HOSTS)
? []
: Array.isArray(cluster?.HOSTS?.ID)
? cluster?.HOSTS?.ID
: [cluster?.HOSTS?.ID]
return (
<div>
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
<HostsTable
disableRowSelect
filter={(dataToFilter) =>
dataToFilter.filter((host) => _.includes(hosts, host.ID))
}
onRowClick={(row) => handleRowClick(row.ID)}
/>
</Stack>
</div>
)
}
Hosts.propTypes = {
id: PropTypes.string,
}
Hosts.displayName = 'Hosts'
export default Hosts

View File

@ -15,10 +15,13 @@
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import PropTypes from 'prop-types'
import { useRenameClusterMutation } from 'client/features/OneApi/cluster'
import {
useRenameClusterMutation,
useUpdateClusterMutation,
} from 'client/features/OneApi/cluster'
import { List } from 'client/components/Tabs/Common'
import { T, Cluster, CLUSTER_ACTIONS } from 'client/constants'
import { jsonToXml } from 'client/models/Helper'
/**
* Renders mainly information tab.
@ -30,6 +33,7 @@ import { T, Cluster, CLUSTER_ACTIONS } from 'client/constants'
*/
const InformationPanel = ({ cluster = {}, actions }) => {
const [renameCluster] = useRenameClusterMutation()
const [updateCluster] = useUpdateClusterMutation()
const { ID, NAME, TEMPLATE } = cluster
const { RESERVED_MEM, RESERVED_CPU } = TEMPLATE
@ -37,6 +41,7 @@ const InformationPanel = ({ cluster = {}, actions }) => {
await renameCluster({ id: ID, name: newName })
}
// Info section
const info = [
{ name: T.ID, value: ID, dataCy: 'id' },
{
@ -48,9 +53,64 @@ const InformationPanel = ({ cluster = {}, actions }) => {
},
]
/**
* Update reserved CPU on the template cluster.
*
* @param {string} name - Name of the attribute
* @param {number} value - Value of the attribute
*/
const handleOvercommitmentCPU = async (name, value) => {
const newTemplate = {
RESERVED_CPU: value + '%',
}
await updateCluster({
id: ID,
template: jsonToXml(newTemplate),
replace: 1,
})
}
/**
* Update reserved memory on the template cluster.
*
* @param {string} name - Name of the attribute
* @param {number} value - Value of the attribute
*/
const handleOvercommitmentMemory = async (name, value) => {
const newTemplate = {
RESERVED_MEM: value + '%',
}
await updateCluster({
id: ID,
template: jsonToXml(newTemplate),
replace: 1,
})
}
// Overcommitment section
const overcommitment = [
{ name: T.ReservedMemory, value: RESERVED_MEM },
{ name: T.ReservedCpu, value: RESERVED_CPU },
{
name: T.ReservedCpu,
handleEdit: handleOvercommitmentCPU,
canEdit: true,
value: <span>{RESERVED_CPU}</span>,
min: '-100',
max: '100',
currentValue: RESERVED_CPU?.replace(/%/g, ''),
dataCy: 'allocated-cpu',
},
{
name: T.ReservedMemory,
handleEdit: handleOvercommitmentMemory,
canEdit: true,
value: <span>{RESERVED_MEM}</span>,
min: '-100',
max: '100',
currentValue: RESERVED_MEM?.replace(/%/g, ''),
dataCy: 'allocated-memory',
},
]
return (

View File

@ -0,0 +1,75 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { VNetworksTable } from 'client/components/Tables'
import { useGetClusterQuery } from 'client/features/OneApi/cluster'
import { useHistory, generatePath } from 'react-router-dom'
import { PATH } from 'client/apps/sunstone/routesOne'
const _ = require('lodash')
/**
* Renders vnets tab showing the vnets of the cluster.
*
* @param {object} props - Props
* @param {string} props.id - Cluster id
* @returns {ReactElement} Vnets tab
*/
const Vnets = ({ id }) => {
// Get info about the cluster
const { data: cluster } = useGetClusterQuery({ id })
// Define function to get details of a vnet
const history = useHistory()
const handleRowClick = (rowId) => {
history.push(generatePath(PATH.NETWORK.VNETS.DETAIL, { id: String(rowId) }))
}
// Get vnets of the cluster
const vnets = _.isEmpty(cluster?.VNETS)
? []
: Array.isArray(cluster?.VNETS?.ID)
? cluster?.VNETS?.ID
: [cluster?.VNETS?.ID]
return (
<div>
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
<VNetworksTable
disableRowSelect
filter={(dataToFilter) =>
dataToFilter.filter((vnet) => _.includes(vnets, vnet.ID))
}
onRowClick={(row) => handleRowClick(row.ID)}
/>
</Stack>
</div>
)
}
Vnets.propTypes = {
id: PropTypes.string,
}
Vnets.displayName = 'Vnets'
export default Vnets

View File

@ -24,10 +24,16 @@ import { getAvailableInfoTabs } from 'client/models/Helper'
import Tabs from 'client/components/Tabs'
import Info from 'client/components/Tabs/Cluster/Info'
import Hosts from 'client/components/Tabs/Cluster/Hosts'
import Vnets from 'client/components/Tabs/Cluster/Vnets'
import Datastores from 'client/components/Tabs/Cluster/Datastores'
const getTabComponent = (tabName) =>
({
info: Info,
host: Hosts,
vnet: Vnets,
datastore: Datastores,
}[tabName])
const ClusterTabs = memo(({ id }) => {

View File

@ -150,8 +150,8 @@ const SliderInput = forwardRef(
value={newValue}
marks={[
{
value: 0,
label: unitParser ? prettyBytes(0) : '0',
value: min ?? 0,
label: unitParser ? prettyBytes(0) : min ?? '0',
},
{
value: max,

View File

@ -73,22 +73,30 @@ const InformationPanel = ({ host = {}, actions }) => {
await renameHost({ id: ID, name: newName })
}
const handleOvercommitment = async (name, value) => {
const handleOvercommitmentCPU = async (name, value) => {
let valueNumber = +value
let newTemplate
if (/cpu/i.test(name)) {
valueNumber === 0 && (valueNumber = usageCpu)
newTemplate = {
RESERVED_CPU:
value !== totalCpu ? totalCpu - valueNumber : reservedCpu ? 0 : '',
}
valueNumber === 0 && (valueNumber = usageCpu)
const newTemplate = {
RESERVED_CPU:
value !== totalCpu ? totalCpu - valueNumber : reservedCpu ? 0 : '',
}
if (/memory/i.test(name)) {
valueNumber === 0 && (valueNumber = usageMem)
newTemplate = {
RESERVED_MEM:
value !== totalMem ? totalMem - valueNumber : reservedMem ? 0 : '',
}
newTemplate &&
(await updateHost({
id: ID,
template: jsonToXml(newTemplate),
replace: 1,
}))
}
const handleOvercommitmentMemory = async (name, value) => {
let valueNumber = +value
valueNumber === 0 && (valueNumber = usageMem)
const newTemplate = {
RESERVED_MEM:
value !== totalMem ? totalMem - valueNumber : reservedMem ? 0 : '',
}
newTemplate &&
@ -129,7 +137,7 @@ const InformationPanel = ({ host = {}, actions }) => {
const capacity = [
{
name: T.AllocatedCpu,
handleEdit: handleOvercommitment,
handleEdit: handleOvercommitmentCPU,
canEdit: true,
value: (
<LinearProgressWithLabel
@ -147,7 +155,7 @@ const InformationPanel = ({ host = {}, actions }) => {
},
{
name: T.AllocatedMemory,
handleEdit: handleOvercommitment,
handleEdit: handleOvercommitmentMemory,
canEdit: true,
value: (
<LinearProgressWithLabel

View File

@ -30,6 +30,7 @@ import * as ACTIONS from 'client/constants/actions'
/** @enum {string} Cluster actions */
export const CLUSTER_ACTIONS = {
CREATE_DIALOG: 'create_dialog',
UPDATE_DIALOG: 'update_dialog',
DELETE: 'delete',
RENAME: ACTIONS.RENAME,

View File

@ -167,7 +167,7 @@ module.exports = {
SelectClusters: 'Select Clusters',
SelectDatastore: 'Select a Datastore to store the resource',
SelectDatastoreImage: 'Select a Datastore',
SelectDatastores: 'Select Datastores',
SelectDatastores: 'Select datastores',
SelectDockerHubTag: 'Select DockerHub image tag (default latest)',
SelectGroup: 'Select a group',
SelectHost: 'Select a host',
@ -392,6 +392,34 @@ module.exports = {
Infrastructure: 'Infrastructure',
Zone: 'Zone',
Zones: 'Zones',
'cluster.form.create.general.help.title': 'Cluster',
'cluster.form.create.general.help.paragraph.1.1':
'Clusters group together hosts, datastores and virtual networks that are configured to work together. A cluster is used to:',
'cluster.form.create.general.help.paragraph.1.2':
'Ensure that VMs use resources that are compatible.',
'cluster.form.create.general.help.paragraph.1.3':
'Assign resources to user groups by creating Virtual Private Clouds.',
'cluster.form.create.general.help.paragraph.2':
'Please, select a name for the cluster',
'cluster.form.create.help.link':
'See Open Nebula documentation to get more details about clusters.',
'cluster.form.create.hosts.help.title': 'Hosts',
'cluster.form.create.hosts.help.paragraph.1':
'Please, select one or more hosts in the hosts table. Hosts are not mandatory, so you can skip this step.',
'cluster.form.create.hosts.help.paragraph.2':
'Remember that hosts can be in only one cluster at a time so if a host it is added to this cluster, it will be removed from any other cluster.',
'cluster.form.create.vnets.help.title': 'Virtual Networks',
'cluster.form.create.vnets.help.paragraph.1':
'Please, select one or more virtual networks in the virtual networks table. Virtual networks are not mandatory, so you can skip this step.',
'cluster.form.create.vnets.help.paragraph.2':
'Virtual networks can be added to multiple clusters. This means that any host in those clusters is properly configured to use leases from those virtual networks.',
'cluster.form.create.datastores.help.title': 'Datastores',
'cluster.form.create.datastores.help.paragraph.1':
'Please, select one or more datastores in the datastores table. Datastores are not mandatory, so you can skip this step.',
'cluster.form.create.datastores.help.paragraph.2':
'Datastores can be added to multiple clusters. This means that any host in those clusters is properly configured to run VMs using images from those datastores.',
'cluster.form.create.datastores.help.paragraph.3':
'Remember that in order to create a complete environment where the scheduler can deploy VMs, your clusters need to have at least one System Datastore.',
/* sections - network */
Network: 'Network',
@ -1327,6 +1355,10 @@ module.exports = {
'Comma separated list of CPU IDs that will be isolated from the NUMA scheduler',
/* Cluster schema */
CreateCluster: 'Create Cluster',
'cluster.create.name': 'Name',
'cluster.create.general.info': 'Cluster info',
/* Cluster schema - capacity */
ReservedMemory: 'Allocated Memory',
ReservedCpu: 'Allocated CPU',

View File

@ -0,0 +1,205 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import { useHistory, useLocation } from 'react-router'
import { useGeneralApi } from 'client/features/General'
import {
useAllocateClusterMutation,
useGetClusterQuery,
useAddHostToClusterMutation,
useAddDatastoreToClusterMutation,
useAddNetworkToClusterMutation,
useRemoveHostFromClusterMutation,
useRemoveDatastoreFromClusterMutation,
useRemoveNetworkFromClusterMutation,
useRenameClusterMutation,
} from 'client/features/OneApi/cluster'
import {
DefaultFormStepper,
SkeletonStepsForm,
} from 'client/components/FormStepper'
import { CreateForm } from 'client/components/Forms/Cluster'
import { PATH } from 'client/apps/sunstone/routesOne'
import systemApi from 'client/features/OneApi/system'
/**
* Displays the creation form for a cluster.
*
* @returns {ReactElement} - The cluster form component
*/
function CreateCluster() {
const history = useHistory()
const { state: { ID: clusterId } = {} } = useLocation()
const { enqueueSuccess, enqueueError } = useGeneralApi()
const [createCluster] = useAllocateClusterMutation()
const [addHost] = useAddHostToClusterMutation()
const [addDatastore] = useAddDatastoreToClusterMutation()
const [addVnet] = useAddNetworkToClusterMutation()
const [removeHost] = useRemoveHostFromClusterMutation()
const [removeDatastore] = useRemoveDatastoreFromClusterMutation()
const [removeVnet] = useRemoveNetworkFromClusterMutation()
const [rename] = useRenameClusterMutation()
const { data: views } = systemApi.useGetSunstoneAvalaibleViewsQuery()
const { data: version } = systemApi.useGetOneVersionQuery()
const { data: cluster } = clusterId
? useGetClusterQuery({ id: clusterId })
: { data: undefined }
const onSubmit = async ({
general,
hosts,
addHosts,
removeHosts,
vnets,
addVnets,
removeVnets,
datastores,
addDatastores,
removeDatastores,
changeName,
}) => {
try {
// Request to create a cluster but not to update
if (!clusterId) {
// Create cluster
const newClusterId = await createCluster({
name: general?.NAME,
}).unwrap()
// Add hosts
if (newClusterId && hosts?.ID) {
const hostIds = hosts?.ID?.map?.((host) => host)
await Promise.all(
hostIds.map((hostId) => addHost({ id: newClusterId, host: hostId }))
)
}
// Add vnets
if (newClusterId && vnets?.ID) {
const vnetIds = vnets?.ID?.map?.((vnet) => vnet)
await Promise.all(
vnetIds.map((vnetId) => addVnet({ id: newClusterId, vnet: vnetId }))
)
}
// Add datastores
if (newClusterId && datastores?.ID) {
const datastoreIds = datastores?.ID?.map?.((ds) => ds)
await Promise.all(
datastoreIds.map((dsId) =>
addDatastore({ id: newClusterId, datastore: dsId })
)
)
}
// Only show cluster message
enqueueSuccess(`Cluster created - #${newClusterId}`)
// Go to clusters list
history.push(PATH.INFRASTRUCTURE.CLUSTERS.LIST)
} else {
// Add hosts
if (addHosts?.length > 0) {
const hostIds = addHosts?.map?.((host) => host)
await Promise.all(
hostIds.map((hostId) => addHost({ id: clusterId, host: hostId }))
)
}
// Remove hosts
if (removeHosts?.length > 0) {
const hostIds = removeHosts?.map?.((host) => host)
await Promise.all(
hostIds.map((hostId) => removeHost({ id: clusterId, host: hostId }))
)
}
// Add vnets
if (addVnets?.length > 0) {
const vnetIds = addVnets?.map?.((vnet) => vnet)
await Promise.all(
vnetIds.map((vnetId) => addVnet({ id: clusterId, vnet: vnetId }))
)
}
// Remove vnets
if (removeVnets?.length > 0) {
const vnetIds = removeVnets?.map?.((vnet) => vnet)
await Promise.all(
vnetIds.map((vnetId) => removeVnet({ id: clusterId, vnet: vnetId }))
)
}
// Add datastores
if (addDatastores?.length > 0) {
const datastoreIds = addDatastores?.map?.((ds) => ds)
await Promise.all(
datastoreIds.map((dsId) =>
addDatastore({ id: clusterId, datastore: dsId })
)
)
}
// Remove datastores
if (removeDatastores?.length > 0) {
const datastoreIds = removeDatastores?.map?.((ds) => ds)
await Promise.all(
datastoreIds.map((dsId) =>
removeDatastore({ id: clusterId, datastore: dsId })
)
)
}
// Rename if the name has been changed
if (changeName) {
await rename({ id: clusterId, name: general?.NAME }).unwrap()
}
// Only show cluster message
enqueueSuccess(`Cluster updated - #${clusterId}`)
// Go to clusters list
history.push(PATH.INFRASTRUCTURE.CLUSTERS.LIST)
}
} catch (error) {
enqueueError('Error performing operation on cluster')
}
}
return views && version && (!clusterId || (clusterId && cluster)) ? (
<CreateForm
initialValues={cluster}
onSubmit={onSubmit}
stepProps={{
views,
version,
}}
fallback={<SkeletonStepsForm />}
>
{(config) => <DefaultFormStepper {...config} />}
</CreateForm>
) : (
<SkeletonStepsForm />
)
}
export default CreateCluster

View File

@ -32,6 +32,7 @@ import MultipleTags from 'client/components/MultipleTags'
import { SubmitButton } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { T, Cluster } from 'client/constants'
import ClusterActions from 'client/components/Tables/Clusters/actions'
/**
* Displays a list of Clusters with a split pane between the list and selected row(s).
@ -44,6 +45,8 @@ function Clusters() {
const hasSelectedRows = selectedRows?.length > 0
const moreThanOneSelected = selectedRows?.length > 1
const actions = ClusterActions()
return (
<SplitPane gridTemplateRows="1fr auto 1fr">
{({ getGridProps, GutterComponent }) => (
@ -51,6 +54,7 @@ function Clusters() {
<ClustersTable
onSelectedRowsChange={onSelectedRowsChange}
useUpdateMutation={useUpdateClusterMutation}
globalActions={actions}
/>
{hasSelectedRows && (

View File

@ -25,6 +25,12 @@ import {
ONE_RESOURCES_POOL,
} from 'client/features/OneApi'
import { Cluster } from 'client/constants'
import {
removeResourceOnPool,
updateNameOnResource,
updateResourceOnPool,
updateTemplateOnResource,
} from 'client/features/OneApi/common'
const { CLUSTER, HOST, DATASTORE } = ONE_RESOURCES
const { CLUSTER_POOL, HOST_POOL, DATASTORE_POOL } = ONE_RESOURCES_POOL
@ -78,19 +84,25 @@ const clusterApi = oneApi.injectEndpoints({
providesTags: (_, __, { id }) => [{ type: CLUSTER, id }],
async onQueryStarted({ id }, { dispatch, queryFulfilled }) {
try {
const { data: queryVm } = await queryFulfilled
const { data: resourceFromQuery } = await queryFulfilled
dispatch(
clusterApi.util.updateQueryData(
'getGClusters',
undefined,
updateResourceOnPool({ id, resourceFromQuery })
)
)
} catch {
// if the query fails, we want to remove the resource from the pool
dispatch(
clusterApi.util.updateQueryData(
'getClusters',
undefined,
(draft) => {
const index = draft.findIndex(({ ID }) => +ID === +id)
index !== -1 && (draft[index] = queryVm)
}
removeResourceOnPool({ id })
)
)
} catch {}
}
},
}),
getClusterAdmin: builder.query({
@ -132,17 +144,41 @@ const clusterApi = oneApi.injectEndpoints({
/**
* Deletes the given cluster from the pool.
*
* @param {number|string} id - Cluster id
* @param {number|string} params - Cluster id
* @returns {number} Cluster id
* @throws Fails when response isn't code 200
*/
query: (id) => {
query: (params) => {
const name = Actions.CLUSTER_DELETE
const command = { name, ...Commands[name] }
return { params: { id }, command }
return { params, command }
},
invalidatesTags: [CLUSTER_POOL],
async onQueryStarted(params, { dispatch, queryFulfilled }) {
try {
const patchCluster = dispatch(
clusterApi.util.updateQueryData(
'getCluster',
{ id: params.id },
updateNameOnResource(params)
)
)
const patchClusters = dispatch(
clusterApi.util.updateQueryData(
'getClusters',
undefined,
updateNameOnResource(params)
)
)
queryFulfilled.catch(() => {
patchCluster.undo()
patchClusters.undo()
})
} catch {}
},
}),
updateCluster: builder.mutation({
/**
@ -165,6 +201,30 @@ const clusterApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: CLUSTER, id }],
async onQueryStarted(params, { dispatch, queryFulfilled }) {
try {
const patchCluster = dispatch(
clusterApi.util.updateQueryData(
'getCluster',
{ id: params.id },
updateTemplateOnResource(params)
)
)
const patchClusters = dispatch(
clusterApi.util.updateQueryData(
'getClusters',
undefined,
updateTemplateOnResource(params)
)
)
queryFulfilled.catch(() => {
patchCluster.undo()
patchClusters.undo()
})
} catch {}
},
}),
addHostToCluster: builder.mutation({
/**

View File

@ -110,7 +110,7 @@ module.exports = {
default: 0,
},
host: {
from: query,
from: postBody,
default: 0,
},
},