1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-25 02:50:08 +03:00
This commit is contained in:
Tino Vazquez 2022-07-26 18:02:06 +02:00
commit 7a658cdc36
70 changed files with 2988 additions and 212 deletions

View File

@ -54,7 +54,10 @@ public:
~Driver()
{
stream_thr.join();
if (stream_thr.joinable())
{
stream_thr.join();
}
}
/**

View File

@ -2906,6 +2906,7 @@ FIREEDGE_SUNSTONE_ETC_VIEW_ADMIN="src/fireedge/etc/sunstone/admin/vm-tab.yaml \
src/fireedge/etc/sunstone/admin/vm-template-tab.yaml \
src/fireedge/etc/sunstone/admin/marketplace-app-tab.yaml \
src/fireedge/etc/sunstone/admin/vnet-tab.yaml \
src/fireedge/etc/sunstone/admin/image-tab.yaml\
src/fireedge/etc/sunstone/admin/host-tab.yaml"
FIREEDGE_SUNSTONE_ETC_VIEW_USER="src/fireedge/etc/sunstone/user/vm-tab.yaml \

View File

@ -28,6 +28,8 @@
</xs:element>
<xs:element name="CLUSTER_ENCRYPTED_ATTR" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="CONTEXT_RESTRICTED_DIRS" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="CONTEXT_SAFE_DIRS" type="xs:string" minOccurs="0" maxOccurs="1"/>
<xs:element name="DATASTORE_CAPACITY_CHECK" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="DATASTORE_ENCRYPTED_ATTR" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>
<xs:element name="DATASTORE_LOCATION" type="xs:string" minOccurs="0" maxOccurs="unbounded"/>

View File

@ -239,21 +239,21 @@ CommandParser::CmdParser.new(ARGV) do
number = options[:multiple] || 1
params = {}
rc = 0
client = helper.client(options)
number.times do
params['merge_template'] = nil
params['merge_template'] = JSON.parse(File.read(args[1])) if args[1]
unless params['merge_template']
secret = "#{options[:username]}:#{options[:password]}"
one_client = OpenNebula::Client.new(secret)
response = client.get("#{RESOURCE_PATH}/#{args[0]}")
service_template = OpenNebula::ServiceTemplate.new_with_id(
args[0], one_client
)
service_template.info
if CloudClient.is_error?(response)
rc = [response.code.to_i, response.to_s]
break
end
body = JSON.parse(service_template['/DOCUMENT/TEMPLATE/BODY'])
body = JSON.parse(response.body)['DOCUMENT']['TEMPLATE']['BODY']
params['merge_template'] = helper.custom_attrs(
body['custom_attrs']
@ -266,7 +266,6 @@ CommandParser::CmdParser.new(ARGV) do
end
json = Service.build_json_action('instantiate', params)
client = helper.client(options)
response = client.post("#{RESOURCE_PATH}/#{args[0]}/action", json)
if CloudClient.is_error?(response)

View File

@ -25,23 +25,25 @@ resource_name: "IMAGE"
actions:
create_dialog: true
clone_dialog: true
import_dialog: true
create_app_dialog: true # reference to create_dialog in marketplace-app-tab.yaml
chown: true
chgrp: true
enable: true
disable: true
persistent: true
nonpersistent: true
delete: true
dockerfile_dialog: false
clone: true
lock: true
unlock: true
disable: true
enable: true
persistent: true
nonpersistent: true
chown: true
chgrp: true
delete: true
# Filters - List of criteria to filter the resources
filters:
label: true
state: true
# Info Tabs - Which info tabs are used to show extended information
@ -52,8 +54,6 @@ info-tabs:
enabled: true
actions:
rename: true
chtype: true
persistent: true
permissions_panel:
enabled: true
actions:
@ -70,13 +70,11 @@ info-tabs:
add: true
edit: true
delete: true
vm:
vms:
enabled: true
snapshot:
enabled: true
actions:
snapshot_flatten: true
snapshot_revert: true
snapshot_delete: true
snapshot_delete: true

View File

@ -29,6 +29,7 @@ import { useGeneralApi } from 'client/features/General'
import systemApi from 'client/features/OneApi/system'
import Sidebar from 'client/components/Sidebar'
import Notifier from 'client/components/Notifier'
import NotifierUpload from 'client/components/Notifier/upload'
import { AuthLayout } from 'client/components/HOC'
import { isDevelopment } from 'client/utils'
import { _APPS } from 'client/constants'
@ -73,6 +74,7 @@ const SunstoneApp = () => {
<>
<Sidebar endpoints={endpoints} />
<Notifier />
<NotifierUpload />
</>
)}
<Router redirectWhenAuth={PATH.DASHBOARD} endpoints={endpoints} />

View File

@ -97,6 +97,15 @@ const Datastores = loadable(() => import('client/containers/Datastores'), {
const Images = loadable(() => import('client/containers/Images'), {
ssr: false,
})
const CreateImages = loadable(() => import('client/containers/Images/Create'), {
ssr: false,
})
const CreateDockerfile = loadable(
() => import('client/containers/Images/Dockerfile'),
{
ssr: false,
}
)
const Marketplaces = loadable(() => import('client/containers/Marketplaces'), {
ssr: false,
})
@ -189,6 +198,8 @@ export const PATH = {
IMAGES: {
LIST: `/${RESOURCE_NAMES.IMAGE}`,
DETAIL: `/${RESOURCE_NAMES.IMAGE}/:id`,
CREATE: `/${RESOURCE_NAMES.IMAGE}/create`,
DOCKERFILE: `/${RESOURCE_NAMES.IMAGE}/dockerfile`,
},
MARKETPLACES: {
LIST: `/${RESOURCE_NAMES.MARKETPLACE}`,
@ -364,6 +375,16 @@ const ENDPOINTS = [
icon: ImageIcon,
Component: Images,
},
{
title: T.CreateImage,
path: PATH.STORAGE.IMAGES.CREATE,
Component: CreateImages,
},
{
title: T.CreateDockerfile,
path: PATH.STORAGE.IMAGES.DOCKERFILE,
Component: CreateDockerfile,
},
{
title: T.Marketplaces,
path: PATH.STORAGE.MARKETPLACES.LIST,

View File

@ -0,0 +1,98 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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, memo } from 'react'
import PropTypes from 'prop-types'
import { Typography, Paper, Grid, Box } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
const useStyles = makeStyles(
({ palette, typography, breakpoints, shadows }) => ({
root: {
padding: '0.8em',
color: palette.text.primary,
backgroundColor: palette.background.paper,
fontWeight: typography.fontWeightRegular,
fontSize: '1em',
borderRadius: 6,
display: 'flex',
gap: 8,
cursor: 'pointer',
[breakpoints.down('md')]: {
flexWrap: 'wrap',
},
},
figure: {
flexBasis: '10%',
aspectRatio: '16/9',
display: 'flex',
justifyContent: 'center',
},
main: {
flex: 'auto',
overflow: 'hidden',
alignSelf: 'center',
},
title: {
color: palette.text.primary,
display: 'flex',
gap: 6,
alignItems: 'center',
},
})
)
const ImageCreateCard = memo(
/**
* @param {object} props - Props
* @param {string} props.name - Card name
* @param {Function} props.onClick - Card name
* @param {ReactElement} props.Icon - Card Icon
* @returns {ReactElement} - Card
*/
({ name = '', onClick, Icon }) => {
const classes = useStyles()
return (
<Grid item xs={12} md={6} onClick={onClick} data-cy="create">
<Paper variant="outlined" className={classes.root}>
{Icon && (
<Box className={classes.figure}>
<Icon />
</Box>
)}
<div className={classes.main}>
<div className={classes.title}>
<Typography noWrap component="span">
{name}
</Typography>
</div>
</div>
</Paper>
</Grid>
)
}
)
ImageCreateCard.propTypes = {
name: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
Icon: PropTypes.oneOfType([PropTypes.node, PropTypes.object]),
}
ImageCreateCard.displayName = 'ImageCreateCard'
export default ImageCreateCard

View File

@ -0,0 +1,99 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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, memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { ModernTv } from 'iconoir-react'
import { Typography, Paper } from '@mui/material'
import { StatusChip } from 'client/components/Status'
import { rowStyles } from 'client/components/Tables/styles'
import { Tr, Translate } from 'client/components/HOC'
import { stringToBoolean, timeFromMilliseconds } from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
import { T, DiskSnapshot } from 'client/constants'
const ImageSnapshotCard = memo(
/**
* @param {object} props - Props
* @param {DiskSnapshot} props.snapshot - Disk snapshot
* @param {function({ snapshot: DiskSnapshot }):ReactElement} [props.actions] - Actions
* @returns {ReactElement} - Card
*/
({ snapshot = {}, actions }) => {
const classes = rowStyles()
const {
ID,
NAME,
ACTIVE,
DATE,
SIZE: SNAPSHOT_SIZE,
MONITOR_SIZE: SNAPSHOT_MONITOR_SIZE,
} = snapshot
const isActive = useMemo(() => stringToBoolean(ACTIVE), [ACTIVE])
const time = useMemo(() => timeFromMilliseconds(+DATE), [DATE])
const timeFormat = useMemo(() => time.toFormat('ff'), [DATE])
const timeAgo = useMemo(() => `created ${time.toRelative()}`, [DATE])
const sizeInfo = useMemo(() => {
const size = +SNAPSHOT_SIZE ? prettyBytes(+SNAPSHOT_SIZE, 'MB') : '-'
const monitorSize = +SNAPSHOT_MONITOR_SIZE
? prettyBytes(+SNAPSHOT_MONITOR_SIZE, 'MB')
: '-'
return `${monitorSize}/${size}`
}, [SNAPSHOT_SIZE, SNAPSHOT_MONITOR_SIZE])
return (
<Paper variant="outlined" className={classes.root}>
<div className={classes.main}>
<div className={classes.title}>
<Typography noWrap component="span">
{NAME}
</Typography>
<span className={classes.labels}>
{isActive && <StatusChip text={<Translate word={T.Active} />} />}
<StatusChip text={<Translate word={T.Snapshot} />} />
</span>
</div>
<div className={classes.caption}>
<span title={timeFormat}>{`#${ID} ${timeAgo}`}</span>
<span
title={`${Tr(T.Monitoring)} / ${Tr(T.DiskSize)}: ${sizeInfo}`}
>
<ModernTv />
<span>{` ${sizeInfo}`}</span>
</span>
</div>
</div>
{typeof actions === 'function' && (
<div className={classes.actions}>{actions({ snapshot })}</div>
)}
</Paper>
)
}
)
ImageSnapshotCard.propTypes = {
snapshot: PropTypes.object.isRequired,
actions: PropTypes.func,
}
ImageSnapshotCard.displayName = 'ImageSnapshotCard'
export default ImageSnapshotCard

View File

@ -23,6 +23,8 @@ import DiskCard from 'client/components/Cards/DiskCard'
import DiskSnapshotCard from 'client/components/Cards/DiskSnapshotCard'
import EmptyCard from 'client/components/Cards/EmptyCard'
import HostCard from 'client/components/Cards/HostCard'
import ImageCreateCard from 'client/components/Cards/ImageCreateCard'
import ImageSnapshotCard from 'client/components/Cards/ImageSnapshotCard'
import MarketplaceAppCard from 'client/components/Cards/MarketplaceAppCard'
import MarketplaceCard from 'client/components/Cards/MarketplaceCard'
import NetworkCard from 'client/components/Cards/NetworkCard'
@ -52,6 +54,8 @@ export {
DiskSnapshotCard,
EmptyCard,
HostCard,
ImageCreateCard,
ImageSnapshotCard,
MarketplaceAppCard,
MarketplaceCard,
NetworkCard,

View File

@ -0,0 +1,69 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { useFormContext } from 'react-hook-form'
import { ErrorHelper } from 'client/components/FormControl'
import { generateKey } from 'client/utils'
import InputCode from 'client/components/FormControl/InputCode'
const DockerfileController = memo(
({ control, cy = `input-${generateKey()}`, name = '' }) => {
const {
getValues,
setValue,
formState: { errors },
} = useFormContext()
const [internalError, setInternalError] = useState()
const messageError = name
.split('.')
.reduce((errs, current) => errs?.[current], errors)?.message?.[0]
useEffect(() => {
setInternalError(messageError)
}, [messageError])
return (
<div data-cy={cy}>
<InputCode
mode="dockerfile"
height="600px"
value={getValues(name)}
onChange={(value) => {
setValue(name, value)
}}
onFocus={(e) => {
setInternalError()
}}
/>
{internalError && <ErrorHelper label={internalError} />}
</div>
)
},
(prevProps, nextProps) => prevProps.cy === nextProps.cy
)
DockerfileController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
}
DockerfileController.displayName = 'DockerfileController'
export default DockerfileController

View File

@ -35,9 +35,9 @@ const WrapperToLoadMode = ({ children, mode }) => {
return () => {
// remove all styles when component will be unmounted
document
.querySelectorAll('[id^=ace]')
.forEach((child) => child.parentNode.removeChild(child))
document.querySelectorAll('[id^=ace-]').forEach((child) => {
child.parentNode.removeChild(child)
})
}
}, [])

View File

@ -29,6 +29,7 @@ import SubmitButton, {
SubmitButtonPropTypes,
} from 'client/components/FormControl/SubmitButton'
import InputCode from 'client/components/FormControl/InputCode'
import DockerfileController from 'client/components/FormControl/DockerfileController'
import ErrorHelper from 'client/components/FormControl/ErrorHelper'
import Tooltip from 'client/components/FormControl/Tooltip'
@ -47,6 +48,7 @@ export {
SubmitButton,
SubmitButtonPropTypes,
InputCode,
DockerfileController,
ErrorHelper,
Tooltip,
}

View File

@ -51,6 +51,7 @@ const INPUT_CONTROLLER = {
[INPUT_TYPES.TIME]: FC.TimeController,
[INPUT_TYPES.TABLE]: FC.TableController,
[INPUT_TYPES.TOGGLE]: FC.ToggleController,
[INPUT_TYPES.DOCKERFILE]: FC.DockerfileController,
}
/**

View File

@ -0,0 +1,59 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import {
SCHEMA,
FIELDS,
} from 'client/components/Forms/Image/CloneForm/Steps/BasicConfiguration/schema'
import { Step } from 'client/utils'
import { T } from 'client/constants'
export const STEP_ID = 'configuration'
const Content = (props) => (
<FormWithSchema
cy="clone-configuration"
id={STEP_ID}
fields={() => FIELDS(props)}
/>
)
/**
* Step to configure the marketplace app.
*
* @param {object} isMultiple - is multiple rows
* @returns {Step} Configuration step
*/
const ConfigurationStep = (isMultiple) => ({
id: STEP_ID,
label: T.Configuration,
resolver: () => SCHEMA(isMultiple),
optionsValidate: { abortEarly: false },
content: () => Content(isMultiple),
})
Content.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
nics: PropTypes.array,
isMultiple: PropTypes.bool,
}
export default ConfigurationStep

View File

@ -0,0 +1,55 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { string, object, ObjectSchema } from 'yup'
import { Field, getValidationFromFields } from 'client/utils'
import { T, INPUT_TYPES } from 'client/constants'
const PREFIX = {
name: 'prefix',
label: T.Prefix,
tooltip: T.PrefixMultipleConcept,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => T.CopyOf),
}
const NAME = {
name: 'name',
label: T.Name,
tooltip: T.NewTemplateNameConcept,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
}
/**
* @param {object} [stepProps] - Step props
* @param {boolean} [stepProps.isMultiple]
* - If true, the prefix will be added to the name of the new template
* @returns {Field[]} Fields
*/
export const FIELDS = ({ isMultiple } = {}) => [isMultiple ? PREFIX : NAME]
/**
* @param {object} [stepProps] - Step props
* @returns {ObjectSchema} Schema
*/
export const SCHEMA = (stepProps) =>
object(getValidationFromFields(FIELDS(stepProps)))

View File

@ -0,0 +1,79 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { useMemo } from 'react'
import PropTypes from 'prop-types'
import { useFormContext } from 'react-hook-form'
import { DatastoresTable } from 'client/components/Tables'
import { SCHEMA } from 'client/components/Forms/Image/CloneForm/Steps/DatastoresTable/schema'
import { Step, decodeBase64 } from 'client/utils'
import { T } from 'client/constants'
export const STEP_ID = 'datastore'
const Content = ({ data, app }) => {
const { NAME } = data?.[0] ?? {}
const { setValue } = useFormContext()
const isKernelType = useMemo(() => {
const appTemplate = String(decodeBase64(app?.TEMPLATE?.APPTEMPLATE64, ''))
return appTemplate.includes('TYPE="KERNEL"')
}, [])
const handleSelectedRows = (rows) => {
const { original = {} } = rows?.[0] ?? {}
setValue(STEP_ID, original.ID !== undefined ? [original] : [])
}
return (
<DatastoresTable
singleSelect
disableGlobalSort
displaySelectedRows
pageSize={5}
getRowId={(row) => String(row.NAME)}
initialState={{
selectedRowIds: { [NAME]: true },
filters: [{ id: 'TYPE', value: isKernelType ? 'FILE' : 'IMAGE' }],
}}
onSelectedRowsChange={handleSelectedRows}
/>
)
}
/**
* Step to select the Datastore.
*
* @param {object} app - Marketplace App resource
* @returns {Step} Datastore step
*/
const DatastoreStep = (app) => ({
id: STEP_ID,
label: T.SelectDatastoreImage,
resolver: SCHEMA,
content: (props) => Content({ ...props, app }),
})
Content.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
app: PropTypes.object,
}
export default DatastoreStep

View File

@ -0,0 +1,24 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { array, object, ArraySchema } from 'yup'
/** @type {ArraySchema} Datastore table schema */
export const SCHEMA = array(object())
.min(1)
.max(1)
.required()
.ensure()
.default(() => [])

View File

@ -0,0 +1,41 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 BasicConfiguration, {
STEP_ID as BASIC_ID,
} from 'client/components/Forms/Image/CloneForm/Steps/BasicConfiguration'
import DatastoresTable, {
STEP_ID as DATASTORE_ID,
} from 'client/components/Forms/Image/CloneForm/Steps/DatastoresTable'
import { createSteps } from 'client/utils'
const Steps = createSteps(
(app) => [BasicConfiguration, DatastoresTable].filter(Boolean),
{
transformInitialValue: (app, schema) =>
schema.cast({}, { context: { app } }),
transformBeforeSubmit: (formData) => {
const { [BASIC_ID]: configuration, [DATASTORE_ID]: [datastore] = [] } =
formData
return {
datastore: datastore?.ID,
...configuration,
}
},
}
)
export default Steps

View File

@ -0,0 +1,41 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 from 'client/components/Forms/FormWithSchema'
import {
SCHEMA,
FIELDS,
} from 'client/components/Forms/Image/CreateDockerfile/Steps/Dockerfile/schema'
import { T } from 'client/constants'
export const STEP_ID = 'dockerfile'
const Content = () => <FormWithSchema id={STEP_ID} fields={FIELDS} />
/**
* Docker file.
*
* @returns {object} Dockerfile step
*/
const DockerFile = () => ({
id: STEP_ID,
label: T.Dockerfile,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: Content,
})
export default DockerFile

View File

@ -0,0 +1,41 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { string, object, ObjectSchema } from 'yup'
import { Field, getValidationFromFields, encodeBase64 } from 'client/utils'
import { T, INPUT_TYPES } from 'client/constants'
/** @type {Field} Dockerfile field */
export const DOCKERFILE = {
name: 'PATH',
label: T.Dockerfile,
type: INPUT_TYPES.DOCKERFILE,
cy: 'dockerfile',
validation: string()
.trim()
.required()
.afterSubmit((value) => encodeBase64(value)),
grid: { md: 12 },
}
/**
* @returns {Field[]} Fields
*/
export const FIELDS = [DOCKERFILE]
/**
* @returns {ObjectSchema} Schema
*/
export const SCHEMA = object(getValidationFromFields(FIELDS))

View File

@ -0,0 +1,42 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 from 'client/components/Forms/FormWithSchema'
import {
SCHEMA,
FIELDS,
} from 'client/components/Forms/Image/CreateDockerfile/Steps/General/schema'
import { T } from 'client/constants'
export const STEP_ID = 'general'
const Content = () => <FormWithSchema id={STEP_ID} fields={FIELDS} />
/**
* General configuration about VM Template.
*
* @returns {object} General configuration step
*/
const General = () => ({
id: STEP_ID,
label: T.Configuration,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: Content,
})
export default General

View File

@ -0,0 +1,64 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { string, boolean, number, object, ObjectSchema } from 'yup'
import { Field, getValidationFromFields } from 'client/utils'
import { T, INPUT_TYPES } from 'client/constants'
export const IMAGE_LOCATION_TYPES = {
PATH: 'path',
UPLOAD: 'upload',
EMPTY: 'empty',
}
/** @type {Field} Name field */
export const NAME = {
name: 'NAME',
label: T.Name,
type: INPUT_TYPES.TEXT,
validation: string().trim().required(),
grid: { xs: 12, md: 6 },
}
/** @type {Field} Context field */
export const CONTEXT = {
name: 'CONTEXT',
label: T.Context,
type: INPUT_TYPES.SWITCH,
validation: boolean().yesOrNo(),
grid: { xs: 12, md: 6 },
}
/** @type {Field} Size field */
export const SIZE = {
name: 'SIZE',
htmlType: 'number',
label: T.Size,
type: INPUT_TYPES.TEXT,
tooltip: T.ImageSize,
validation: number().positive().required(),
grid: { md: 12 },
}
/**
* @returns {Field[]} Fields
*/
export const FIELDS = [NAME, CONTEXT, SIZE]
/**
* @param {object} [stepProps] - Step props
* @returns {ObjectSchema} Schema
*/
export const SCHEMA = object(getValidationFromFields(FIELDS))

View File

@ -0,0 +1,63 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 Datastore, {
STEP_ID as DATASTORE_ID,
} from 'client/components/Forms/Image/CloneForm/Steps/DatastoresTable'
import General, {
STEP_ID as GENERAL_ID,
} from 'client/components/Forms/Image/CreateForm/Steps/General'
import AdvancedOptions, {
STEP_ID as ADVANCE_ID,
} from 'client/components/Forms/Image/CreateForm/Steps/AdvancedOptions'
import CustomAttributes, {
STEP_ID as CUSTOM_ID,
} from 'client/components/Forms/Image/CreateForm/Steps/CustomAttributes'
import { jsonToXml } from 'client/models/Helper'
import { createSteps, cloneObject, set } from 'client/utils'
const Steps = createSteps(
[General, Datastore, AdvancedOptions, CustomAttributes],
{
transformBeforeSubmit: (formData) => {
const {
[GENERAL_ID]: general = {},
[DATASTORE_ID]: [datastore] = [],
[ADVANCE_ID]: advanced = {},
[CUSTOM_ID]: custom = {},
} = formData ?? {}
const generalData = cloneObject(general)
set(generalData, 'UPLOAD', undefined)
set(generalData, 'IMAGE_LOCATION', undefined)
return {
template: jsonToXml({
...custom,
...advanced,
...generalData,
}),
datastore: datastore?.ID,
file: general?.UPLOAD,
}
},
}
)
export default Steps

View File

@ -0,0 +1,55 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 Datastore, {
STEP_ID as DATASTORE_ID,
} from 'client/components/Forms/Image/CloneForm/Steps/DatastoresTable'
import General, {
STEP_ID as GENERAL_ID,
} from 'client/components/Forms/Image/CreateDockerfile/Steps/General'
import Dockerfile, {
STEP_ID as DOCKERFILE_ID,
} from 'client/components/Forms/Image/CreateDockerfile/Steps/Dockerfile'
import { jsonToXml } from 'client/models/Helper'
import { createSteps, cloneObject, set } from 'client/utils'
const Steps = createSteps([General, Datastore, Dockerfile], {
transformBeforeSubmit: (formData) => {
const {
[GENERAL_ID]: general = {},
[DATASTORE_ID]: [datastore] = [],
[DOCKERFILE_ID]: dockerfile = {},
} = formData ?? {}
const generalData = cloneObject(general)
set(generalData, 'CONTEXT', undefined)
set(generalData, 'SIZE', undefined)
return {
template: jsonToXml({
...{
PATH: `dockerfile://?fileb64=${dockerfile.PATH}&amp;context=${general.CONTEXT}&amp;size=${general.SIZE}`,
},
...generalData,
}),
datastore: datastore?.ID,
}
},
})
export default Steps

View File

@ -0,0 +1,43 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 from 'client/components/Forms/FormWithSchema'
import {
SCHEMA,
FIELDS,
} from 'client/components/Forms/Image/CreateForm/Steps/AdvancedOptions/schema'
import { T } from 'client/constants'
export const STEP_ID = 'advanced'
const Content = () => (
<FormWithSchema id={STEP_ID} fields={FIELDS} cy={`${STEP_ID}`} />
)
/**
* Advanced options create image.
*
* @returns {object} Advanced options configuration step
*/
const AdvancedOptions = () => ({
id: STEP_ID,
label: T.AdvancedOptions,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: Content,
})
export default AdvancedOptions

View File

@ -0,0 +1,180 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { string, object, ObjectSchema } from 'yup'
import { Field, arrayToOptions, getValidationFromFields } from 'client/utils'
import { T, INPUT_TYPES } from 'client/constants'
import {
IMAGE_LOCATION_TYPES,
IMAGE_LOCATION_FIELD,
} from 'client/components/Forms/Image/CreateForm/Steps/General/schema'
import { useGetSunstoneConfigQuery } from 'client/features/OneApi/system'
export const BUS_TYPES = {
VD: 'path',
SD: 'upload',
HD: 'empty',
CUSTOM: 'custom',
}
const FORMAT_TYPES = {
RAW: 'raw',
QCOW2: 'qcow2',
CUSTOM: 'custom',
}
const BUS = {
[BUS_TYPES.VD]: T.Vd,
[BUS_TYPES.SD]: T.Sd,
[BUS_TYPES.HD]: T.Hd,
[BUS_TYPES.CUSTOM]: T.Custom,
}
const htmlType = (opt) => (value) => value !== opt && INPUT_TYPES.HIDDEN
/** @type {Field} Bus field */
export const DEV_PREFIX = {
name: 'DEV_PREFIX',
label: T.Bus,
type: INPUT_TYPES.SELECT,
values: arrayToOptions(Object.entries(BUS), {
addEmpty: true,
getText: ([_, name]) => name,
getValue: ([key]) => key,
}),
validation: string()
.trim()
.default(() => undefined)
.afterSubmit((value, { context }) => {
const notEmptyString = value === '' ? undefined : value
return BUS_TYPES.CUSTOM === value
? context?.advanced?.CUSTOM_DEV_PREFIX
: notEmptyString
}),
grid: { md: 6 },
}
/** @type {Field} Dev Prefix field */
export const CUSTOM_DEV_PREFIX = {
name: 'CUSTOM_DEV_PREFIX',
dependOf: DEV_PREFIX.name,
htmlType: htmlType(BUS_TYPES.CUSTOM),
label: T.CustomBus,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.when(DEV_PREFIX.name, {
is: (location) => location === BUS_TYPES.CUSTOM,
then: (schema) => schema.required(),
otherwise: (schema) => schema.strip(),
})
.default(() => undefined),
grid: { md: 12 },
}
/** @type {Field} Device field */
export const DEVICE = {
name: 'DEVICE',
label: T.TargetDevice,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.default(() => undefined),
grid: { md: 6 },
}
/** @type {Field} Format field */
export const FORMAT_FIELD = {
name: 'FORMAT',
dependOf: `$general.${IMAGE_LOCATION_FIELD.name}`,
htmlType: htmlType(IMAGE_LOCATION_TYPES.EMPTY),
label: T.Format,
type: INPUT_TYPES.SELECT,
values: arrayToOptions(Object.values(FORMAT_TYPES), {
addEmpty: true,
getText: (type) => type.toUpperCase(),
getValue: (type) => type,
}),
validation: string()
.trim()
.afterSubmit((value, { context }) => {
const notEmptyString = value === '' ? undefined : value
return FORMAT_TYPES.CUSTOM === value
? context?.advanced?.CUSTOM_FORMAT
: notEmptyString
}),
grid: { md: 6 },
}
/** @type {Field} Custom format field */
export const CUSTOM_FORMAT = {
name: 'CUSTOM_FORMAT',
dependOf: [`$general.${IMAGE_LOCATION_FIELD.name}`, FORMAT_FIELD.name],
htmlType: ([imageLocation, formatField] = []) =>
(imageLocation !== IMAGE_LOCATION_TYPES.EMPTY ||
formatField !== FORMAT_TYPES.CUSTOM) &&
INPUT_TYPES.HIDDEN,
label: T.CustomFormat,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.when(FORMAT_FIELD.name, {
is: (format) => format === FORMAT_TYPES.CUSTOM,
then: (schema) => schema.required(),
otherwise: (schema) => schema.strip(),
}),
grid: { md: 12 },
}
/** @type {Field} FS field */
export const FS = {
name: 'FS',
dependOf: `$general.${IMAGE_LOCATION_FIELD.name}`,
htmlType: htmlType(IMAGE_LOCATION_TYPES.EMPTY),
label: T.Fs,
type: INPUT_TYPES.SELECT,
values: () => {
const { data: sunstoneConfig = {} } = useGetSunstoneConfigQuery()
return arrayToOptions(sunstoneConfig?.supported_fs || [], {
addEmpty: true,
getText: (fs) => fs.toUpperCase(),
getValue: (fs) => fs,
})
},
validation: string()
.trim()
.afterSubmit((value) => (value === '' ? undefined : value)),
grid: { md: 6 },
}
/**
* @returns {Field[]} Fields
*/
export const FIELDS = [
DEV_PREFIX,
DEVICE,
CUSTOM_DEV_PREFIX,
FORMAT_FIELD,
FS,
CUSTOM_FORMAT,
]
/**
* @returns {ObjectSchema} Schema
*/
export const SCHEMA = object(getValidationFromFields(FIELDS))

View File

@ -0,0 +1,67 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { useCallback } from 'react'
import { object } from 'yup'
import { useFormContext, useWatch } from 'react-hook-form'
import { Box } from '@mui/material'
import { AttributePanel } from 'client/components/Tabs/Common'
import { cleanEmpty, cloneObject, set } from 'client/utils'
import { T } from 'client/constants'
export const STEP_ID = 'custom-attributes'
const Content = () => {
const { setValue } = useFormContext()
const customVars = useWatch({ name: STEP_ID, defaultValue: {} })
const handleChangeAttribute = useCallback(
(path, newValue) => {
const newCustomVars = cloneObject(customVars)
set(newCustomVars, path, newValue)
setValue(STEP_ID, cleanEmpty(newCustomVars))
},
[customVars]
)
return (
<Box display="grid" gap="1em">
<AttributePanel
allActionsEnabled
handleAdd={handleChangeAttribute}
handleEdit={handleChangeAttribute}
handleDelete={handleChangeAttribute}
attributes={customVars}
filtersSpecialAttributes={false}
/>
</Box>
)
}
/**
* Custom variables about VM Template.
*
* @returns {object} Custom configuration step
*/
const CustomAttributes = () => ({
id: STEP_ID,
label: T.CustomAttributes,
resolver: object(),
optionsValidate: { abortEarly: false },
content: Content,
})
export default CustomAttributes

View File

@ -0,0 +1,44 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 from 'client/components/Forms/FormWithSchema'
import {
SCHEMA,
FIELDS,
} from 'client/components/Forms/Image/CreateForm/Steps/General/schema'
import { T } from 'client/constants'
export const STEP_ID = 'general'
const Content = () => (
<FormWithSchema id={STEP_ID} fields={FIELDS} cy={`${STEP_ID}`} />
)
/**
* General configuration about VM Template.
*
* @returns {object} General configuration step
*/
const General = () => ({
id: STEP_ID,
label: T.Configuration,
resolver: SCHEMA,
optionsValidate: { abortEarly: false },
content: Content,
})
export default General

View File

@ -0,0 +1,185 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { string, boolean, number, object, ObjectSchema, mixed } from 'yup'
import {
Field,
arrayToOptions,
getValidationFromFields,
upperCaseFirst,
} from 'client/utils'
import { T, INPUT_TYPES, IMAGE_TYPES_STR } from 'client/constants'
export const IMAGE_LOCATION_TYPES = {
PATH: 'path',
UPLOAD: 'upload',
EMPTY: 'empty',
}
const IMAGE_LOCATION = {
[IMAGE_LOCATION_TYPES.PATH]: T.Path,
[IMAGE_LOCATION_TYPES.UPLOAD]: T.Upload,
[IMAGE_LOCATION_TYPES.EMPTY]: T.EmptyDisk,
}
const htmlType = (opt, inputNumber) => (location) => {
if (location === opt && inputNumber) {
return 'number'
}
return location !== opt && INPUT_TYPES.HIDDEN
}
/** @type {Field} name field */
export const NAME = {
name: 'NAME',
label: T.Name,
type: INPUT_TYPES.TEXT,
validation: string().trim().required(),
grid: { xs: 12, md: 6 },
}
/** @type {Field} Description field */
export const DESCRIPTION = {
name: 'DESCRIPTION',
label: T.Description,
type: INPUT_TYPES.TEXT,
multiline: true,
validation: string().trim(),
grid: { xs: 12, md: 6 },
}
/** @type {Field} Type field */
export const TYPE = {
name: 'TYPE',
label: T.Type,
type: INPUT_TYPES.SELECT,
values: arrayToOptions(Object.values(IMAGE_TYPES_STR), {
addEmpty: false,
getText: (type) => {
switch (type) {
case IMAGE_TYPES_STR.OS:
return T.Os
case IMAGE_TYPES_STR.CDROM:
return T.Cdrom
case IMAGE_TYPES_STR.DATABLOCK:
return T.Datablock
default:
return upperCaseFirst(type.toLowerCase())
}
},
getValue: (type) => type,
}),
validation: string()
.trim()
.default(() => IMAGE_TYPES_STR.OS),
grid: { xs: 12, md: 6 },
}
/** @type {Field} Persistent field */
export const PERSISTENT = {
name: 'PERSISTENT',
label: T.MakePersistent,
type: INPUT_TYPES.SWITCH,
validation: boolean().yesOrNo(),
grid: { xs: 12, md: 6 },
}
/** @type {Field} Toggle select type image */
export const IMAGE_LOCATION_FIELD = {
name: 'IMAGE_LOCATION',
type: INPUT_TYPES.TOGGLE,
values: arrayToOptions(Object.entries(IMAGE_LOCATION), {
addEmpty: false,
getText: ([_, name]) => name,
getValue: ([image]) => image,
}),
validation: string()
.trim()
.required()
.default(() => IMAGE_LOCATION_TYPES.PATH),
grid: { md: 12 },
notNull: true,
}
/** @type {Field} path field */
export const PATH_FIELD = {
name: 'PATH',
dependOf: IMAGE_LOCATION_FIELD.name,
htmlType: htmlType(IMAGE_LOCATION_TYPES.PATH),
label: T.ImagePath,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.when(IMAGE_LOCATION_FIELD.name, {
is: (location) => location === IMAGE_LOCATION_TYPES.PATH,
then: (schema) => schema.required(),
otherwise: (schema) => schema.strip(),
}),
grid: { md: 12 },
}
/** @type {Field} upload field */
export const UPLOAD_FIELD = {
name: 'UPLOAD',
dependOf: IMAGE_LOCATION_FIELD.name,
htmlType: htmlType(IMAGE_LOCATION_TYPES.UPLOAD),
label: T.Upload,
type: INPUT_TYPES.FILE,
validation: mixed().when(IMAGE_LOCATION_FIELD.name, {
is: (location) => location === IMAGE_LOCATION_TYPES.UPLOAD,
then: (schema) => schema.required(),
otherwise: (schema) => schema.strip(),
}),
grid: { md: 12 },
}
/** @type {Field} size field */
export const SIZE = {
name: 'SIZE',
dependOf: IMAGE_LOCATION_FIELD.name,
htmlType: htmlType(IMAGE_LOCATION_TYPES.EMPTY, true),
label: T.Size,
type: INPUT_TYPES.TEXT,
tooltip: T.ImageSize,
validation: number()
.positive()
.default(() => undefined)
.when(IMAGE_LOCATION_FIELD.name, {
is: (location) => location === IMAGE_LOCATION_TYPES.EMPTY,
then: (schema) => schema.required(),
otherwise: (schema) => schema.strip(),
}),
grid: { md: 12 },
}
/**
* @returns {Field[]} Fields
*/
export const FIELDS = [
NAME,
DESCRIPTION,
TYPE,
PERSISTENT,
IMAGE_LOCATION_FIELD,
PATH_FIELD,
UPLOAD_FIELD,
SIZE,
]
/**
* @param {object} [stepProps] - Step props
* @returns {ObjectSchema} Schema
*/
export const SCHEMA = object(getValidationFromFields(FIELDS))

View File

@ -0,0 +1,62 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 Datastore, {
STEP_ID as DATASTORE_ID,
} from 'client/components/Forms/Image/CloneForm/Steps/DatastoresTable'
import General, {
STEP_ID as GENERAL_ID,
} from 'client/components/Forms/Image/CreateForm/Steps/General'
import AdvancedOptions, {
STEP_ID as ADVANCE_ID,
} from 'client/components/Forms/Image/CreateForm/Steps/AdvancedOptions'
import CustomAttributes, {
STEP_ID as CUSTOM_ID,
} from 'client/components/Forms/Image/CreateForm/Steps/CustomAttributes'
import { createSteps, cloneObject, set } from 'client/utils'
const Steps = createSteps(
[General, Datastore, AdvancedOptions, CustomAttributes],
{
transformBeforeSubmit: (formData) => {
const {
[GENERAL_ID]: general = {},
[DATASTORE_ID]: [datastore] = [],
[ADVANCE_ID]: advanced = {},
[CUSTOM_ID]: custom = {},
} = formData ?? {}
const generalData = cloneObject(general)
set(generalData, 'UPLOAD', undefined)
set(generalData, 'IMAGE_LOCATION', undefined)
return {
template: {
...custom,
...advanced,
...generalData,
},
datastore: datastore?.ID,
file: general?.UPLOAD,
}
},
}
)
export default Steps

View File

@ -0,0 +1,16 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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/Image/CreateForm/Steps'

View File

@ -0,0 +1,41 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { AsyncLoadForm, ConfigurationProps } from 'client/components/HOC'
import { CreateFormCallback, CreateStepsCallback } from 'client/utils/schema'
/**
* @param {ConfigurationProps} configProps - Configuration
* @returns {ReactElement|CreateFormCallback} Asynchronous loaded form
*/
const CloneForm = (configProps) =>
AsyncLoadForm({ formPath: 'Image/CloneForm' }, configProps)
/**
* @param {ConfigurationProps} configProps - Configuration
* @returns {ReactElement|CreateStepsCallback} Asynchronous loaded form
*/
const CreateForm = (configProps) =>
AsyncLoadForm({ formPath: 'Image/CreateForm' }, configProps)
/**
* @param {ConfigurationProps} configProps - Configuration
* @returns {ReactElement|CreateStepsCallback} Asynchronous loaded form
*/
const CreateDockerfileForm = (configProps) =>
AsyncLoadForm({ formPath: 'Image/CreateDockerfile' }, configProps)
export { CloneForm, CreateForm, CreateDockerfileForm }

View File

@ -22,7 +22,7 @@ import AdvancedOptions, {
import { createSteps } from 'client/utils'
const Steps = createSteps([NetworksTable, AdvancedOptions], {
transformInitialValue: (nic) => {
transformInitialValue: (nic, schema) => {
const {
NETWORK,
NETWORK_ID: ID,
@ -32,6 +32,11 @@ const Steps = createSteps([NetworksTable, AdvancedOptions], {
...rest
} = nic ?? {}
const castedValue = schema.cast(
{ [ADVANCED_ID]: rest },
{ stripUnknown: true }
)
return {
[NETWORK_ID]: [
{
@ -43,7 +48,7 @@ const Steps = createSteps([NetworksTable, AdvancedOptions], {
SECURITY_GROUPS,
},
],
[ADVANCED_ID]: rest,
[ADVANCED_ID]: castedValue[ADVANCED_ID],
}
},
transformBeforeSubmit: (formData) => {

View File

@ -0,0 +1,84 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 {
Snackbar,
Alert,
CircularProgress,
Box,
Typography,
} from '@mui/material'
import { useGeneral, useGeneralApi } from 'client/features/General'
/**
* @returns {ReactElement} App rendered.
*/
const NotifierUpload = () => {
const { upload } = useGeneral()
const { uploadSnackbar } = useGeneralApi()
const handleClose = () => uploadSnackbar(0)
return (
<Snackbar
open={upload > 0}
autoHideDuration={10000}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
>
<Alert
icon={false}
onClose={handleClose}
severity="info"
variant="filled"
sx={{ width: '100%' }}
>
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<CircularProgress
variant="determinate"
value={upload}
color="inherit"
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
>
{`${upload}%`}
</Typography>
</Box>
</Box>
</Alert>
</Snackbar>
)
}
export default NotifierUpload

View File

@ -0,0 +1,388 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { useMemo } from 'react'
import { useHistory } from 'react-router-dom'
import { Typography, Grid } from '@mui/material'
import {
MoreVert,
AddCircledOutline,
Lock,
Cart,
Group,
Trash,
Code,
PageEdit,
} from 'iconoir-react'
import { useViews } from 'client/features/Auth'
import {
useLockImageMutation,
useCloneImageMutation,
useUnlockImageMutation,
useEnableImageMutation,
useDisableImageMutation,
usePersistentImageMutation,
useChangeImageOwnershipMutation,
useRemoveImageMutation,
} from 'client/features/OneApi/image'
import { ChangeUserForm, ChangeGroupForm } from 'client/components/Forms/Vm'
import { CloneForm } from 'client/components/Forms/Image'
import {
createActions,
GlobalAction,
} from 'client/components/Tables/Enhanced/Utils'
import ImageCreateCard from 'client/components/Cards/ImageCreateCard'
import { Tr, Translate } from 'client/components/HOC'
import { PATH } from 'client/apps/sunstone/routesOne'
import { isAvailableAction } from 'client/models/VirtualMachine'
import { T, IMAGE_ACTIONS, VM_ACTIONS, RESOURCE_NAMES } from 'client/constants'
const isDisabled = (action) => (rows) =>
!isAvailableAction(
action,
rows.map(({ original }) => original)
)
const ListImagesNames = ({ rows = [] }) =>
rows?.map?.(({ id, original }) => {
const { ID, NAME } = original
return (
<Typography
key={`image-${id}`}
variant="inherit"
component="span"
display="block"
>
{`#${ID} ${NAME}`}
</Typography>
)
})
const SubHeader = (rows) => <ListImagesNames rows={rows} />
const MessageToConfirmAction = (rows) => (
<>
<ListImagesNames rows={rows} />
<Translate word={T.DoYouWantProceed} />
</>
)
/**
* Generates the actions to operate resources on Image table.
*
* @returns {GlobalAction} - Actions
*/
const Actions = () => {
const history = useHistory()
const { view, getResourceView } = useViews()
const [clone] = useCloneImageMutation()
const [lock] = useLockImageMutation()
const [unlock] = useUnlockImageMutation()
const [enable] = useEnableImageMutation()
const [disable] = useDisableImageMutation()
const [persistent] = usePersistentImageMutation()
const [changeOwnership] = useChangeImageOwnershipMutation()
const [deleteImage] = useRemoveImageMutation()
const resourcesView = getResourceView(RESOURCE_NAMES.IMAGE)?.actions
const imageActions = useMemo(
() =>
createActions({
filters: resourcesView,
actions: [
{
accessor: IMAGE_ACTIONS.CREATE_DIALOG,
dataCy: `image_${IMAGE_ACTIONS.CREATE_DIALOG}`,
tooltip: T.Create,
icon: AddCircledOutline,
options: [
{
isConfirmDialog: true,
dialogProps: {
title: T.CreateImage,
children: () => (
<Grid container spacing={3}>
<ImageCreateCard
name={Tr(T.CreateImage)}
Icon={PageEdit}
onClick={() => history.push(PATH.STORAGE.IMAGES.CREATE)}
/>
{resourcesView?.dockerfile_dialog && (
<ImageCreateCard
name={Tr(T.CreateDockerfile)}
Icon={Code}
onClick={() =>
history.push(PATH.STORAGE.IMAGES.DOCKERFILE)
}
/>
)}
</Grid>
),
fixedWidth: true,
fixedHeight: true,
handleAccept: undefined,
dataCy: `modal-${IMAGE_ACTIONS.CREATE_DIALOG}`,
},
},
],
},
{
accessor: VM_ACTIONS.CREATE_APP_DIALOG,
dataCy: `image_${VM_ACTIONS.CREATE_APP_DIALOG}`,
disabled: isDisabled(VM_ACTIONS.CREATE_APP_DIALOG),
tooltip: T.CreateMarketApp,
selected: { max: 1 },
icon: Cart,
action: (rows) => {
const vm = rows?.[0]?.original ?? {}
const path = PATH.STORAGE.MARKETPLACE_APPS.CREATE
history.push(path, [RESOURCE_NAMES.VM, vm])
},
},
{
accessor: IMAGE_ACTIONS.CLONE,
label: T.Clone,
tooltip: T.Clone,
selected: true,
color: 'secondary',
options: [
{
dialogProps: {
title: (rows) => {
const isMultiple = rows?.length > 1
const { ID, NAME } = rows?.[0]?.original ?? {}
return [
Tr(
isMultiple ? T.CloneSeveralTemplates : T.CloneTemplate
),
!isMultiple && `#${ID} ${NAME}`,
]
.filter(Boolean)
.join(' - ')
},
dataCy: 'modal-clone',
},
form: (rows) => {
const names = rows?.map(({ original }) => original?.NAME)
const stepProps = { isMultiple: names.length > 1 }
const initialValues = { name: `Copy of ${names?.[0]}` }
return CloneForm({ stepProps, initialValues })
},
onSubmit:
(rows) =>
async ({ prefix, name, datastore } = {}) => {
const images = rows?.map?.(
({ original: { ID, NAME } = {} }) =>
// overwrite all names with prefix+NAME
({
id: ID,
name: prefix ? `${prefix} ${NAME}` : name,
datastore,
})
)
await Promise.all(images.map(clone))
},
},
],
},
{
tooltip: T.Lock,
icon: Lock,
selected: true,
color: 'secondary',
dataCy: 'image-lock',
options: [
{
accessor: IMAGE_ACTIONS.LOCK,
name: T.Lock,
isConfirmDialog: true,
dialogProps: {
title: T.Lock,
dataCy: `modal-${IMAGE_ACTIONS.LOCK}`,
children: MessageToConfirmAction,
},
onSubmit: (rows) => async () => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map((id) => lock({ id })))
},
},
{
accessor: IMAGE_ACTIONS.UNLOCK,
name: T.Unlock,
isConfirmDialog: true,
dialogProps: {
title: T.Unlock,
dataCy: `modal-${IMAGE_ACTIONS.UNLOCK}`,
children: MessageToConfirmAction,
},
onSubmit: (rows) => async () => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map((id) => unlock({ id })))
},
},
],
},
{
tooltip: T.Enable,
icon: MoreVert,
selected: true,
color: 'secondary',
dataCy: 'image-enable',
options: [
{
accessor: IMAGE_ACTIONS.ENABLE,
name: T.Enable,
isConfirmDialog: true,
dialogProps: {
title: T.Enable,
children: MessageToConfirmAction,
dataCy: `modal-${IMAGE_ACTIONS.ENABLE}`,
},
onSubmit: (rows) => async () => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map((id) => enable(id)))
},
},
{
accessor: IMAGE_ACTIONS.DISABLE,
name: T.Disable,
isConfirmDialog: true,
dialogProps: {
title: T.Disable,
children: MessageToConfirmAction,
dataCy: `modal-${IMAGE_ACTIONS.DISABLE}`,
},
onSubmit: (rows) => async () => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map((id) => disable(id)))
},
},
{
accessor: IMAGE_ACTIONS.PERSISTENT,
name: T.Persistent,
isConfirmDialog: true,
dialogProps: {
title: T.Persistent,
children: MessageToConfirmAction,
dataCy: `modal-${IMAGE_ACTIONS.PERSISTENT}`,
},
onSubmit: (rows) => async () => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(
ids.map((id) => persistent({ id, persistent: true }))
)
},
},
{
accessor: IMAGE_ACTIONS.NON_PERSISTENT,
name: T.NonPersistyent,
isConfirmDialog: true,
dialogProps: {
title: T.NonPersistyent,
children: MessageToConfirmAction,
dataCy: `modal-${IMAGE_ACTIONS.NON_PERSISTENT}`,
},
onSubmit: (rows) => async () => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(
ids.map((id) => persistent({ id, persistent: false }))
)
},
},
],
},
{
tooltip: T.Ownership,
icon: Group,
selected: true,
color: 'secondary',
dataCy: 'image-ownership',
options: [
{
accessor: IMAGE_ACTIONS.CHANGE_OWNER,
disabled: isDisabled(IMAGE_ACTIONS.CHANGE_OWNER),
name: T.ChangeOwner,
dialogProps: {
title: T.ChangeOwner,
subheader: SubHeader,
dataCy: `modal-${IMAGE_ACTIONS.CHANGE_OWNER}`,
},
form: ChangeUserForm,
onSubmit: (rows) => async (newOwnership) => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(
ids.map((id) => changeOwnership({ id, ...newOwnership }))
)
},
},
{
accessor: IMAGE_ACTIONS.CHANGE_GROUP,
disabled: isDisabled(IMAGE_ACTIONS.CHANGE_GROUP),
name: T.ChangeGroup,
dialogProps: {
title: T.ChangeGroup,
subheader: SubHeader,
dataCy: `modal-${IMAGE_ACTIONS.CHANGE_GROUP}`,
},
form: ChangeGroupForm,
onSubmit: (rows) => async (newOwnership) => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(
ids.map((id) => changeOwnership({ id, ...newOwnership }))
)
},
},
],
},
{
accessor: IMAGE_ACTIONS.DELETE,
tooltip: T.Delete,
icon: Trash,
color: 'error',
selected: { min: 1 },
dataCy: `image_${IMAGE_ACTIONS.DELETE}`,
options: [
{
isConfirmDialog: true,
dialogProps: {
title: T.Delete,
dataCy: `modal-${IMAGE_ACTIONS.DELETE}`,
children: MessageToConfirmAction,
},
onSubmit: (rows) => async () => {
const ids = rows?.map?.(({ original }) => original?.ID)
await Promise.all(ids.map((id) => deleteImage({ id })))
},
},
],
},
],
}),
[view]
)
return imageActions
}
export default Actions

View File

@ -25,6 +25,7 @@ export default [
{ Header: 'Name', accessor: 'NAME' },
{ Header: 'Owner', accessor: 'UNAME' },
{ Header: 'Group', accessor: 'GNAME' },
{ Header: 'Locked', id: 'locked', accessor: 'LOCK' },
{
Header: 'State',
id: 'STATE',

View File

@ -36,7 +36,7 @@ const Row = ({ original, value, ...props }) => {
TYPE,
DISK_TYPE,
PERSISTENT,
LOCK,
locked,
DATASTORE,
TOTAL_VMS,
RUNNING_VMS,
@ -59,7 +59,7 @@ const Row = ({ original, value, ...props }) => {
<Typography noWrap component="span" data-cy="name">
{NAME}
</Typography>
{LOCK && <Lock />}
{locked && <Lock />}
<span className={classes.labels}>
{labels.map((label) => (
<StatusChip key={label} text={label} />

View File

@ -21,7 +21,7 @@ import { useGetVmsQuery } from 'client/features/OneApi/vm'
import EnhancedTable, { createColumns } from 'client/components/Tables/Enhanced'
import VmColumns from 'client/components/Tables/Vms/columns'
import VmRow from 'client/components/Tables/Vms/row'
import { RESOURCE_NAMES } from 'client/constants'
import { RESOURCE_NAMES, VM_STATES, STATES } from 'client/constants'
const DEFAULT_DATA_CY = 'vms'
@ -55,7 +55,7 @@ const VmsTable = (props) => {
?.filter((vm) =>
host?.ID ? [host?.VMS?.ID ?? []].flat().includes(vm.ID) : true
)
?.filter(({ STATE }) => STATE !== '6') ?? [],
?.filter(({ STATE }) => VM_STATES[STATE]?.name !== STATES.DONE) ?? [],
}),
})

View File

@ -1,18 +1,3 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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. *
* ------------------------------------------------------------------------- */
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, OpenNebula Project, OpenNebula Systems *
* *

View File

@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { ReactElement } from 'react'
import { InfoEmpty } from 'iconoir-react'
@ -24,19 +25,23 @@ import { T } from 'client/constants'
/**
* Renders default empty tab.
*
* @param {object} props - Props
* @param {string} props.label - label string
* @returns {ReactElement} Empty tab
*/
const EmptyTab = () => {
const EmptyTab = ({ label = T.NoDataAvailable }) => {
const classes = useStyles()
return (
<span className={classes.noDataMessage}>
<InfoEmpty />
<Translate word={T.NoDataAvailable} />
<Translate word={label} />
</span>
)
}
EmptyTab.propTypes = {
label: PropTypes.string,
}
EmptyTab.displayName = 'EmptyTab'
export default EmptyTab

View File

@ -26,7 +26,11 @@ import { StatusChip } from 'client/components/Status'
import { List } from 'client/components/Tabs/Common'
import { getType, getState } from 'client/models/Image'
import { timeToString, booleanToString } from 'client/models/Helper'
import {
timeToString,
booleanToString,
levelLockToString,
} from 'client/models/Helper'
import { arrayToOptions, prettyBytes } from 'client/utils'
import { T, Image, IMAGE_ACTIONS, IMAGE_TYPES } from 'client/constants'
import { PATH } from 'client/apps/sunstone/routesOne'
@ -49,6 +53,7 @@ const InformationPanel = ({ image = {}, actions }) => {
NAME,
SIZE,
PERSISTENT,
LOCK,
REGTIME,
DATASTORE_ID,
DATASTORE = '--',
@ -110,6 +115,11 @@ const InformationPanel = ({ image = {}, actions }) => {
handleEdit: handleChangeType,
dataCy: 'type',
},
{
name: T.Locked,
value: levelLockToString(LOCK?.LOCKED),
dataCy: 'locked',
},
{
name: T.Persistent,
value: booleanToString(+PERSISTENT),
@ -127,6 +137,7 @@ const InformationPanel = ({ image = {}, actions }) => {
{
name: T.State,
value: <StatusChip text={stateName} stateColor={stateColor} />,
dataCy: 'state',
},
{
name: T.RunningVMs,

View File

@ -0,0 +1,142 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import PropTypes from 'prop-types'
import { Trash, UndoAction } from 'iconoir-react'
import {
useFlattenImageSnapshotMutation,
useRevertImageSnapshotMutation,
useDeleteImageSnapshotMutation,
} from 'client/features/OneApi/image'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import { Tr, Translate } from 'client/components/HOC'
import { T, IMAGE_ACTIONS } from 'client/constants'
const SnapshotFlattenAction = memo(({ id, snapshot }) => {
const [flattenImageSnapshot] = useFlattenImageSnapshotMutation()
const { ID, NAME = T.Snapshot } = snapshot
const handleDelete = async () => {
await flattenImageSnapshot({ id, snapshot: ID })
}
return (
<ButtonToTriggerForm
buttonProps={{
'data-cy': IMAGE_ACTIONS.SNAPSHOT_FLATTEN,
tooltip: Tr(T.Flatten),
label: Tr(T.Flatten),
}}
options={[
{
isConfirmDialog: true,
dialogProps: {
title: (
<Translate word={T.FlattenSnapshot} values={`#${ID} - ${NAME}`} />
),
children: (
<>
<p>{Tr(T.DeleteOtherSnapshots)}</p>
<p>{Tr(T.DoYouWantProceed)}</p>
</>
),
},
onSubmit: handleDelete,
},
]}
/>
)
})
const SnapshotRevertAction = memo(({ id, snapshot }) => {
const [revertImageSnapshot] = useRevertImageSnapshotMutation()
const { ID, NAME = T.Snapshot } = snapshot
const handleRevert = async () => {
await revertImageSnapshot({ id, snapshot: ID })
}
return (
<ButtonToTriggerForm
buttonProps={{
'data-cy': IMAGE_ACTIONS.SNAPSHOT_REVERT,
icon: <UndoAction />,
tooltip: Tr(T.Revert),
}}
options={[
{
isConfirmDialog: true,
dialogProps: {
title: (
<Translate word={T.RevertSomething} values={`#${ID} - ${NAME}`} />
),
children: <p>{Tr(T.DoYouWantProceed)}</p>,
},
onSubmit: handleRevert,
},
]}
/>
)
})
const SnapshotDeleteAction = memo(({ id, snapshot }) => {
const [deleteImageSnapshot] = useDeleteImageSnapshotMutation()
const { ID, NAME = T.Snapshot } = snapshot
const handleDelete = async () => {
await deleteImageSnapshot({ id, snapshot: ID })
}
return (
<ButtonToTriggerForm
buttonProps={{
'data-cy': IMAGE_ACTIONS.SNAPSHOT_DELETE,
icon: <Trash />,
tooltip: Tr(T.Delete),
}}
options={[
{
isConfirmDialog: true,
dialogProps: {
title: (
<Translate word={T.DeleteSomething} values={`#${ID} - ${NAME}`} />
),
children: <p>{Tr(T.DoYouWantProceed)}</p>,
},
onSubmit: handleDelete,
},
]}
/>
)
})
const ActionPropTypes = {
id: PropTypes.string,
snapshot: PropTypes.object,
}
SnapshotFlattenAction.propTypes = ActionPropTypes
SnapshotFlattenAction.displayName = 'SnapshotFlattenAction'
SnapshotRevertAction.propTypes = ActionPropTypes
SnapshotRevertAction.displayName = 'SnapshotRevertAction'
SnapshotDeleteAction.propTypes = ActionPropTypes
SnapshotDeleteAction.displayName = 'SnapshotDeleteAction'
export { SnapshotFlattenAction, SnapshotDeleteAction, SnapshotRevertAction }

View File

@ -0,0 +1,84 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { T } from 'client/constants'
import { useGetImageQuery } from 'client/features/OneApi/image'
import EmptyTab from 'client/components/Tabs/EmptyTab'
import ImageSnapshotCard from 'client/components/Cards/ImageSnapshotCard'
import {
SnapshotFlattenAction,
SnapshotDeleteAction,
SnapshotRevertAction,
} from 'client/components/Tabs/Image/Snapshots/Actions'
import { getSnapshots } from 'client/models/Image'
/**
* Renders the list of disks from a VM.
*
* @param {object} props - Props
* @param {object} props.tabProps - Tab information
* @param {string[]} props.tabProps.actions - Actions tab
* @param {string} props.id - Image id
* @returns {ReactElement} Storage tab
*/
const ImageStorageTab = ({ tabProps: { actions } = {}, id }) => {
const { data: image = {} } = useGetImageQuery({ id })
const [snapshots] = useMemo(() => [getSnapshots(image)], [image])
return (
<div>
<Stack gap="1em" py="0.8em">
{snapshots.length ? (
snapshots?.map?.((snapshot) => (
<ImageSnapshotCard
key={snapshot.ID}
snapshot={snapshot}
actions={() => (
<>
{actions.snapshot_flatten && (
<SnapshotFlattenAction id={id} snapshot={snapshot} />
)}
{actions.snapshot_revert && (
<SnapshotRevertAction id={id} snapshot={snapshot} />
)}
{actions.snapshot_delete && (
<SnapshotDeleteAction id={id} snapshot={snapshot} />
)}
</>
)}
/>
))
) : (
<EmptyTab label={T.NotSnapshotCurrenty} />
)}
</Stack>
</div>
)
}
ImageStorageTab.propTypes = {
tabProps: PropTypes.object,
id: PropTypes.string,
}
ImageStorageTab.displayName = 'ImageStorageTab'
export default ImageStorageTab

View File

@ -0,0 +1,59 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 { T } from 'client/constants'
import EmptyTab from 'client/components/Tabs/EmptyTab'
import { useHistory, generatePath } from 'react-router-dom'
import { PATH } from 'client/apps/sunstone/routesOne'
import { useGetImageQuery } from 'client/features/OneApi/image'
import { VmsTable } from 'client/components/Tables'
/**
* Renders mainly Vms tab.
*
* @param {object} props - Props
* @param {string} props.id - Image id
* @returns {ReactElement} vms tab
*/
const VmsTab = ({ id }) => {
const { data: image = {} } = useGetImageQuery({ id })
const path = PATH.INSTANCE.VMS.DETAIL
const history = useHistory()
const handleRowClick = (rowId) => {
history.push(generatePath(path, { id: String(rowId) }))
}
return (
<VmsTable
disableGlobalSort
displaySelectedRows
host={image}
onRowClick={(row) => handleRowClick(row.ID)}
noDataMessage={<EmptyTab label={T.NotVmsCurrenty} />}
/>
)
}
VmsTab.propTypes = {
tabProps: PropTypes.object,
id: PropTypes.string,
}
VmsTab.displayName = 'VmsTab'
export default VmsTab

View File

@ -24,10 +24,14 @@ import { RESOURCE_NAMES } from 'client/constants'
import Tabs from 'client/components/Tabs'
import Info from 'client/components/Tabs/Image/Info'
import Vms from 'client/components/Tabs/Image/Vms'
import Snapshots from 'client/components/Tabs/Image/Snapshots'
const getTabComponent = (tabName) =>
({
info: Info,
vms: Vms,
snapshot: Snapshots,
}[tabName])
const ImageTabs = memo(({ id }) => {

View File

@ -165,6 +165,13 @@ export const IMAGE_STATES = [
export const IMAGE_ACTIONS = {
CREATE_DIALOG: 'create_dialog',
DELETE: 'delete',
LOCK: 'lock',
UNLOCK: 'unlock',
CLONE: 'clone',
ENABLE: 'enable',
DISABLE: 'disable',
PERSISTENT: 'persistent',
NON_PERSISTENT: 'nonpersistent',
// INFORMATION
RENAME: ACTIONS.RENAME,
@ -172,4 +179,7 @@ export const IMAGE_ACTIONS = {
CHANGE_GROUP: ACTIONS.CHANGE_GROUP,
CHANGE_TYPE: 'chtype',
CHANGE_PERS: 'persistent',
SNAPSHOT_FLATTEN: 'flatten',
SNAPSHOT_REVERT: 'revert',
SNAPSHOT_DELETE: 'delete',
}

View File

@ -128,6 +128,7 @@ export const INPUT_TYPES = {
TEXT: 'text',
TABLE: 'table',
TOGGLE: 'toggle',
DOCKERFILE: 'dockerfile',
}
export const DEBUG_LEVEL = {

View File

@ -71,6 +71,8 @@ module.exports = {
CreateServiceTemplate: 'Create Service Template',
CreateVirtualNetwork: 'Create Virtual Network',
CreateVmTemplate: 'Create VM Template',
CreateImage: 'Create Image',
CreateDockerfile: 'Create Dockerfile',
CurrentGroup: 'Current group: %s',
CurrentOwner: 'Current owner: %s',
Delete: 'Delete',
@ -83,6 +85,7 @@ module.exports = {
DeleteAddressRange: 'Delete Address Range',
DeleteTemplate: 'Delete Template',
DeleteVirtualNetwork: 'Delete Virtual Network',
DeleteOtherSnapshots: 'This will delete all the other image snapshots',
Deploy: 'Deploy',
DeployServiceTemplate: 'Deploy Service Template',
Detach: 'Detach',
@ -95,6 +98,8 @@ module.exports = {
Enable: 'Enable',
Failure: 'Failure',
Finish: 'Finish',
Flatten: 'Flatten',
FlattenSnapshot: 'Flatten %s',
Hold: 'Hold',
Import: 'Import',
Info: 'Info',
@ -138,6 +143,7 @@ module.exports = {
Select: 'Select',
SelectCluster: 'Select Cluster',
SelectDatastore: 'Select a Datastore to store the resource',
SelectDatastoreImage: 'Select a Datastore',
SelectDockerHubTag: 'Select DockerHub image tag (default latest)',
SelectGroup: 'Select a group',
SelectHost: 'Select a host',
@ -358,6 +364,21 @@ module.exports = {
Marketplaces: 'Marketplaces',
App: 'App',
Apps: 'Apps',
Os: 'Operating system image',
Cdrom: 'Readonly CD-ROM',
Datablock: 'Generic storage datablock',
Path: 'Path/URL',
ImagePath: 'Path in OpenNebula server or URL',
Upload: 'Upload',
EmptyDisk: 'Empty disk image',
ImageSize: 'Image size, in Megabytes',
Vd: 'Virtio',
Sd: 'SCSI/SATA',
Hd: 'Parallel ATA (IDE)',
CustomBus: 'Custom bus',
Fs: 'Fs',
CustomFormat: 'Custom Format',
Dockerfile: 'Dockerfile',
/* sections - templates & instances */
Instances: 'Instances',
@ -560,6 +581,7 @@ module.exports = {
CustomAttributes: 'Custom Attributes',
Hypervisor: 'Hypervisor',
Logo: 'Logo',
MakePersistent: 'Make Persistent',
MakeNewImagePersistent: 'Make the new images persistent',
TemplateName: 'Template name',
Virtualization: 'Virtualization',
@ -1039,6 +1061,7 @@ module.exports = {
BasePath: 'Base path',
FileSystemType: 'Filesystem type',
Persistent: 'Persistent',
NonPersistyent: 'Non Persistent',
RunningVMs: 'Running VMs',
/* Disk - general */
DiskType: 'Disk type',
@ -1074,6 +1097,7 @@ module.exports = {
Iothread id used by this disk. Default is round robin.
Can be used only if IOTHREADS > 0. If this input is disabled
please first configure IOTHREADS value on OS & CPU -> Features`,
ImageLocation: 'Image Location',
/* Provision schema */
/* Provision - general */
@ -1097,6 +1121,11 @@ module.exports = {
Mandatory: 'Mandatory',
PressKeysToAddAValue: 'Press any of the following keys to add a value: %s',
/** Image */
NotVmsCurrenty: 'There are currently no VMs associated with this image',
NotSnapshotCurrenty:
'There are currently no snapshots associated with this image',
/* Validation */
/* Validation - mixed */
'validation.mixed.default': 'Is invalid',

View File

@ -0,0 +1,81 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 } from 'react-router'
import { jsonToXml } from 'client/models/Helper'
import { useGeneralApi } from 'client/features/General'
import {
useAllocateImageMutation,
useUploadImageMutation,
} from 'client/features/OneApi/image'
import { useGetDatastoresQuery } from 'client/features/OneApi/datastore'
import {
DefaultFormStepper,
SkeletonStepsForm,
} from 'client/components/FormStepper'
import { CreateForm } from 'client/components/Forms/Image'
import { PATH } from 'client/apps/sunstone/routesOne'
/**
* Displays the creation or modification form to a VM Template.
*
* @returns {ReactElement} VM Template form
*/
function CreateImage() {
const history = useHistory()
const [allocate] = useAllocateImageMutation()
const [upload] = useUploadImageMutation()
const { enqueueSuccess, uploadSnackbar } = useGeneralApi()
useGetDatastoresQuery(undefined, { refetchOnMountOrArgChange: false })
const onSubmit = async ({ template, datastore, file }) => {
if (file) {
const uploadProcess = (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded / progressEvent.total) * 100
)
uploadSnackbar(percentCompleted)
percentCompleted === 100 && uploadSnackbar(0)
}
try {
const fileUploaded = await upload({
file,
uploadProcess,
}).unwrap()
template.PATH = fileUploaded[0]
} catch {}
}
try {
const newTemplateId = await allocate({
template: jsonToXml(template),
datastore,
}).unwrap()
history.push(PATH.STORAGE.IMAGES.LIST)
enqueueSuccess(`Image created - #${newTemplateId}`)
} catch {}
}
return (
<CreateForm onSubmit={onSubmit} fallback={<SkeletonStepsForm />}>
{(config) => <DefaultFormStepper {...config} />}
</CreateForm>
)
}
export default CreateImage

View File

@ -0,0 +1,56 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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 } from 'react-router'
import { useGeneralApi } from 'client/features/General'
import { useAllocateImageMutation } from 'client/features/OneApi/image'
import { useGetDatastoresQuery } from 'client/features/OneApi/datastore'
import {
DefaultFormStepper,
SkeletonStepsForm,
} from 'client/components/FormStepper'
import { CreateDockerfileForm } from 'client/components/Forms/Image'
import { PATH } from 'client/apps/sunstone/routesOne'
/**
* Displays the creation or modification form to a VM Template.
*
* @returns {ReactElement} VM Template form
*/
function CreateDockerfile() {
const history = useHistory()
const [allocate] = useAllocateImageMutation()
const { enqueueSuccess } = useGeneralApi()
useGetDatastoresQuery(undefined, { refetchOnMountOrArgChange: false })
const onSubmit = async (stepTemplate) => {
try {
const newTemplateId = await allocate(stepTemplate).unwrap()
history.push(PATH.STORAGE.IMAGES.LIST)
enqueueSuccess(`Image created - #${newTemplateId}`)
} catch {}
}
return (
<CreateDockerfileForm onSubmit={onSubmit} fallback={<SkeletonStepsForm />}>
{(config) => <DefaultFormStepper {...config} />}
</CreateDockerfileForm>
)
}
export default CreateDockerfile

View File

@ -21,11 +21,9 @@ import Cancel from 'iconoir-react/dist/Cancel'
import { Typography, Box, Stack, Chip } from '@mui/material'
import { Row } from 'react-table'
import {
useLazyGetImageQuery,
useUpdateImageMutation,
} from 'client/features/OneApi/image'
import { useLazyGetImageQuery } from 'client/features/OneApi/image'
import { ImagesTable } from 'client/components/Tables'
import ImageActions from 'client/components/Tables/Images/actions'
import ImageTabs from 'client/components/Tabs/Image'
import SplitPane from 'client/components/SplitPane'
import MultipleTags from 'client/components/MultipleTags'
@ -40,6 +38,7 @@ import { T, Image } from 'client/constants'
*/
function Images() {
const [selectedRows, onSelectedRowsChange] = useState(() => [])
const actions = ImageActions()
const hasSelectedRows = selectedRows?.length > 0
const moreThanOneSelected = selectedRows?.length > 1
@ -50,7 +49,7 @@ function Images() {
<Box height={1} {...(hasSelectedRows && getGridProps())}>
<ImagesTable
onSelectedRowsChange={onSelectedRowsChange}
useUpdateMutation={useUpdateImageMutation}
globalActions={actions}
/>
{hasSelectedRows && (
@ -82,24 +81,19 @@ function Images() {
* @returns {ReactElement} Image details
*/
const InfoTabs = memo(({ image, gotoPage, unselect }) => {
const [get, { data: lazyData, isFetching }] = useLazyGetImageQuery()
const [getImage, { data: lazyData, isFetching }] = useLazyGetImageQuery()
const id = lazyData?.ID ?? image.ID
const name = lazyData?.NAME ?? image.NAME
return (
<Stack overflow="auto">
<Stack direction="row" alignItems="center" gap={1} mx={1} mb={1}>
<Typography color="text.primary" noWrap flexGrow={1}>
{`#${id} | ${name}`}
</Typography>
{/* -- ACTIONS -- */}
<Stack direction="row" alignItems="center" gap={1} mb={1}>
<SubmitButton
data-cy="detail-refresh"
icon={<RefreshDouble />}
tooltip={Tr(T.Refresh)}
isSubmitting={isFetching}
onClick={() => get({ id })}
onClick={() => getImage({ id })}
/>
{typeof gotoPage === 'function' && (
<SubmitButton
@ -117,9 +111,11 @@ const InfoTabs = memo(({ image, gotoPage, unselect }) => {
onClick={() => unselect()}
/>
)}
{/* -- END ACTIONS -- */}
<Typography color="text.primary" noWrap>
{`#${id} | ${name}`}
</Typography>
</Stack>
<ImageTabs id={id} />
<ImageTabs id={image.ID} />
</Stack>
)
})

View File

@ -22,6 +22,7 @@ export const changeAppTitle = createAction('Change App title')
export const dismissSnackbar = createAction('Dismiss snackbar')
export const deleteSnackbar = createAction('Delete snackbar')
export const setUploadSnackbar = createAction('Change upload snackbar')
export const enqueueSnackbar = createAction(
'Enqueue snackbar',

View File

@ -32,6 +32,8 @@ export const useGeneralApi = () => {
changeAppTitle: (appTitle) => dispatch(actions.changeAppTitle(appTitle)),
changeZone: (zone) => dispatch(actions.changeZone(zone)),
uploadSnackbar: (percent) => dispatch(actions.setUploadSnackbar(percent)),
// dismiss all if no key has been defined
dismissSnackbar: (key) =>
dispatch(actions.dismissSnackbar({ key, dismissAll: !key })),

View File

@ -27,6 +27,7 @@ const initial = {
withGroupSwitcher: false,
isLoading: false,
isFixMenu: false,
upload: 0,
notifications: [],
}
@ -64,7 +65,11 @@ const slice = createSlice({
...state,
zone: payload,
}))
/* UPLOAD NOTIFICATION */
.addCase(actions.setUploadSnackbar, (state, { payload }) => ({
...state,
upload: payload,
}))
/* NOTIFICATION ACTIONS */
.addCase(actions.enqueueSnackbar, (state, { payload }) => {
const { key, options, message } = payload

View File

@ -20,12 +20,14 @@ import {
ONE_RESOURCES_POOL,
} from 'client/features/OneApi'
import { UpdateFromSocket } from 'client/features/OneApi/socket'
import http from 'client/utils/rest'
import {
FilterFlag,
Image,
Permission,
IMAGE_TYPES_STR,
} from 'client/constants'
import { getType } from 'client/models/Image'
const { IMAGE } = ONE_RESOURCES
const { IMAGE_POOL } = ONE_RESOURCES_POOL
@ -49,7 +51,19 @@ const imageApi = oneApi.injectEndpoints({
return { params, command }
},
transformResponse: (data) => [data?.IMAGE_POOL?.IMAGE ?? []].flat(),
transformResponse: (data) => {
const images = data?.IMAGE_POOL?.IMAGE?.filter?.((image) => {
const type = getType(image)
return (
type === IMAGE_TYPES_STR.OS ||
type === IMAGE_TYPES_STR.CDROM ||
type === IMAGE_TYPES_STR.DATABLOCK
)
})
return [images ?? []].flat()
},
providesTags: (images) =>
images
? [
@ -101,6 +115,35 @@ const imageApi = oneApi.injectEndpoints({
},
invalidatesTags: [IMAGE_POOL],
}),
uploadImage: builder.mutation({
/**
* Upload image.
*
* @param {object} params - request params
* @param {object} params.file - image file
* @param {Function} params.uploadProcess - upload process function
* @returns {number} Virtual machine id
* @throws Fails when response isn't code 200
*/
queryFn: async ({ file, uploadProcess }) => {
try {
const data = new FormData()
data.append('files', file)
const response = await http.request({
url: '/api/image/upload',
method: 'POST',
data,
onUploadProgress: uploadProcess,
})
return { data: response.data }
} catch (axiosError) {
const { response } = axiosError
return { error: { status: response?.status, data: response?.data } }
}
},
}),
cloneImage: builder.mutation({
/**
* Clones an existing image.
@ -408,4 +451,5 @@ export const {
useFlattenImageSnapshotMutation,
useLockImageMutation,
useUnlockImageMutation,
useUploadImageMutation,
} = imageApi

View File

@ -81,6 +81,22 @@ const systemApi = oneApi.injectEndpoints({
providesTags: [{ type: SYSTEM, id: 'sunstone-views' }],
keepUnusedDataFor: 600,
}),
getSunstoneConfig: builder.query({
/**
* Returns the Sunstone configuration for resource tabs.
*
* @returns {object} The loaded sunstone view files
* @throws Fails when response isn't code 200
*/
query: () => {
const name = SunstoneActions.SUNSTONE_CONFIG
const command = { name, ...SunstoneCommands[name] }
return { command }
},
providesTags: [{ type: SYSTEM, id: 'sunstone-config' }],
keepUnusedDataFor: 600,
}),
}),
})
@ -90,6 +106,8 @@ export const {
useLazyGetOneVersionQuery,
useGetOneConfigQuery,
useLazyGetOneConfigQuery,
useGetSunstoneConfigQuery,
useLazyGetSunstoneConfigQuery,
useGetSunstoneViewsQuery,
useLazyGetSunstoneViewsQuery,
} = systemApi

View File

@ -19,6 +19,7 @@ import {
IMAGE_STATES,
STATES,
Image,
DiskSnapshot,
} from 'client/constants'
import { prettyBytes } from 'client/utils'
@ -60,3 +61,15 @@ export const getDiskName = ({ IMAGE, SIZE, TYPE, FORMAT } = {}) => {
return IMAGE ?? { fs: `${FORMAT} - ${size}`, swap: size }[type]
}
/**
* @param {Image} image - Image
* @returns {DiskSnapshot[]} List of snapshots from resource
*/
export const getSnapshots = (image) => {
const {
SNAPSHOTS: { SNAPSHOT },
} = image ?? {}
return [SNAPSHOT].flat().filter(Boolean)
}

View File

@ -157,59 +157,60 @@ const upload = (
const { app, files, public: publicFile } = params
const { id, user, password } = userData
if (
global.paths.CPI &&
app &&
checkValidApp(app) &&
files &&
id &&
user &&
password
) {
const oneConnect = oneConnection(user, password)
checkUserAdmin(
oneConnect,
id,
(admin = false) => {
const pathUserData =
publicFile && admin ? `${app}` : `${app}${sep}${id}`
const pathUser = `${global.paths.CPI}${sep}${pathUserData}`
if (!existsSync(pathUser)) {
mkdirsSync(pathUser)
}
let method = ok
let message = ''
const data = []
for (const file of files) {
if (file && file.originalname && file.path && file.filename) {
const extFile = extname(file.originalname)
try {
const filenameApi = `${pathUserData}${sep}${file.filename}${extFile}`
const filename = `${pathUser}${sep}${file.filename}${extFile}`
moveSync(file.path, filename)
data.push(filenameApi)
} catch (error) {
method = internalServerError
message = error && error.message
break
}
}
}
res.locals.httpCode = httpResponse(
method,
data.length ? data : '',
message
)
next()
},
() => {
res.locals.httpCode = internalServerError
next()
}
!(
global.paths.CPI &&
app &&
checkValidApp(app) &&
files &&
id &&
user &&
password
)
} else {
) {
res.locals.httpCode = httpBadRequest
next()
}
const oneConnect = oneConnection(user, password)
checkUserAdmin(
oneConnect,
id,
(admin = false) => {
const pathUserData = publicFile && admin ? `${app}` : `${app}${sep}${id}`
const pathUser = `${global.paths.CPI}${sep}${pathUserData}`
if (!existsSync(pathUser)) {
mkdirsSync(pathUser)
}
let method = ok
let message = ''
const data = []
for (const file of files) {
if (file && file.originalname && file.path && file.filename) {
const extFile = extname(file.originalname)
try {
const filenameApi = `${pathUserData}${sep}${file.filename}${extFile}`
const filename = `${pathUser}${sep}${file.filename}${extFile}`
moveSync(file.path, filename)
data.push(filenameApi)
} catch (error) {
method = internalServerError
message = error && error.message
break
}
}
}
res.locals.httpCode = httpResponse(
method,
data.length ? data : '',
message
)
next()
},
() => {
res.locals.httpCode = internalServerError
next()
}
)
}
/**

View File

@ -0,0 +1,55 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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. *
* ------------------------------------------------------------------------- */
const { defaults, httpCodes } = require('server/utils/constants')
const { httpResponse } = require('server/utils/server')
const { defaultEmptyFunction } = defaults
const { ok, badRequest } = httpCodes
const httpBadRequest = httpResponse(badRequest, '', '')
/**
* Upload File.
*
* @param {object} res - response http
* @param {Function} next - express stepper
* @param {string} params - data response http
* @param {object} userData - user of http request
*/
const upload = (
res = {},
next = defaultEmptyFunction,
params = {},
userData = {}
) => {
const { files } = params
const { user, password } = userData
if (!(files && user && password)) {
res.locals.httpCode = httpBadRequest
next()
}
const data = files.map((file) => file.path)
res.locals.httpCode = httpResponse(ok, data.length ? data : '')
next()
}
const functionRoutes = {
upload,
}
module.exports = functionRoutes

View File

@ -0,0 +1,27 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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. *
* ------------------------------------------------------------------------- */
const { Actions, Commands } = require('server/routes/api/image/routes')
const { upload } = require('server/routes/api/image/functions')
const { IMAGE_UPLOAD } = Actions
module.exports = [
{
...Commands[IMAGE_UPLOAD],
action: upload,
},
]

View File

@ -0,0 +1,42 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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. *
* ------------------------------------------------------------------------- */
const { httpMethod } = require('../../../utils/constants/defaults')
const { POST } = httpMethod
const basepath = '/image'
const IMAGE_UPLOAD = 'image.upload'
const Actions = {
IMAGE_UPLOAD,
}
module.exports = {
Actions,
Commands: {
[IMAGE_UPLOAD]: {
path: `${basepath}/upload`,
httpMethod: POST,
auth: true,
params: {
files: {
from: 'files',
},
},
},
},
}

View File

@ -36,6 +36,7 @@ const routes = [
'2fa',
'auth',
'files',
'image',
'marketapp',
'oneflow',
'vcenter',

View File

@ -526,7 +526,11 @@ const getToken = (
const serverAdmin = getSunstoneAuth() ?? {}
const { token: authToken } = createTokenServerAdmin(serverAdmin) ?? {}
!authToken && responser()
if (!authToken) {
responser()
return
}
const { username } = serverAdmin
const oneClient = xmlrpc(`${username}:${username}`, authToken)
@ -537,6 +541,8 @@ const getToken = (
callback: (vmInfoErr, { VM } = {}) => {
if (vmInfoErr || !VM) {
responser(vmInfoErr, unauthorized)
return
}
if (!VM?.MONITORING?.VCENTER_ESX_HOST) {
@ -544,6 +550,8 @@ const getToken = (
Could not determine the vCenter ESX host where
the VM is running. Wait till the VCENTER_ESX_HOST attribute is
retrieved once the host has been monitored`)
return
}
const history = VM?.HISTORY_RECORDS?.HISTORY
@ -558,80 +566,104 @@ const getToken = (
if (String(hostHypervisor).toLowerCase() !== 'vcenter') {
responser('VMRC Connection is only for vCenter hypervisor')
return
}
if (!VM?.DEPLOY_ID || isNaN(hostId)) {
responser('VM is not deployed')
return
}
const responseError = (error) =>
responser(error && error.message, internalServerError)
const responseToken = (ticketData) => {
const { ticket } = ticketData
const { protocol, hostname, port, path } = parse(ticket)
const httpProtocol = protocol === 'wss:' ? 'https' : 'http'
const esxUrl = `${httpProtocol}://${hostname}:${port}`
const token = path.replace('/ticket/', '')
global.vcenterToken = { [token]: esxUrl }
responser(token, ok)
}
/**
* Get the vcenter token of vm.
*
* @param {string} sessionId - session id
* @param {string} vcenterHost - host ip
*/
const getVcenterToken = (sessionId, vcenterHost) => {
const vmIdFromDeployId = VM.DEPLOY_ID.match(regexGetVcenterId).groups.id
executeRequest(
{
params: {
url: `https://${vcenterHost}/api/vcenter/vm/vm-${vmIdFromDeployId}/console/tickets`,
headers: {
'Content-Type': 'application/json',
'vmware-api-session-id': sessionId,
},
data: JSON.stringify({ type: 'WEBMKS' }),
},
agent: 'https',
},
{
success: responseToken,
error: responseError,
}
)
}
/**
* Get vmware-api-session-id.
*
* @param {string} hostInfoError - error when get info host.
* @param {object} hostData - host data
* @param {object} hostData.HOST - data host
*/
const getSession = (hostInfoError, { HOST } = {}) => {
const { VCENTER_HOST, VCENTER_USER, VCENTER_PASSWORD } =
HOST?.TEMPLATE ?? {}
if (
hostInfoError ||
!VCENTER_HOST ||
!VCENTER_USER ||
!VCENTER_PASSWORD
) {
responser(hostInfoError, unauthorized)
return
}
executeRequest(
{
params: {
url: `https://${VCENTER_HOST}/api/session`,
headers: {
Authorization: `Basic ${btoa(
`${VCENTER_USER}:${VCENTER_PASSWORD}`
)}`,
},
},
agent: 'https',
},
{
success: (sessionId) => getVcenterToken(sessionId, VCENTER_HOST),
error: responseError,
}
)
}
oneClient({
action: ActionHost.HOST_INFO,
parameters: [parseInt(hostId, 10), true],
callback: (hostInfoError, { HOST } = {}) => {
const { VCENTER_HOST, VCENTER_USER, VCENTER_PASSWORD } =
HOST?.TEMPLATE ?? {}
if (
hostInfoError ||
!VCENTER_HOST ||
!VCENTER_USER ||
!VCENTER_PASSWORD
) {
responser(hostInfoError, unauthorized)
}
executeRequest(
{
params: {
url: `https://${VCENTER_HOST}/api/session`,
headers: {
Authorization: `Basic ${btoa(
`${VCENTER_USER}:${VCENTER_PASSWORD}`
)}`,
},
},
agent: 'https',
},
{
success: (sessionId) => {
const vmIdFromDeployId =
VM.DEPLOY_ID.match(regexGetVcenterId).groups.id
executeRequest(
{
params: {
url: `https://${VCENTER_HOST}/api/vcenter/vm/vm-${vmIdFromDeployId}/console/tickets`,
headers: {
'Content-Type': 'application/json',
'vmware-api-session-id': sessionId,
},
data: JSON.stringify({ type: 'WEBMKS' }),
},
agent: 'https',
},
{
success: (ticketData) => {
const { ticket } = ticketData
const { protocol, hostname, port, path } = parse(ticket)
const httpProtocol =
protocol === 'wss:' ? 'https' : 'http'
const esxUrl = `${httpProtocol}://${hostname}:${port}`
const token = path.replace('/ticket/', '')
global.vcenterToken = { [token]: esxUrl }
responser(token, ok)
},
error: (error) =>
responser(error && error.message, internalServerError),
}
)
},
error: (error) =>
responser(error && error.message, internalServerError),
}
)
},
callback: getSession,
})
},
})

View File

@ -399,6 +399,8 @@ void OpenNebulaTemplate::set_conf_default()
set_conf_single("HOST_ENCRYPTED_ATTR", "NSX_PASSWORD");
set_conf_single("HOST_ENCRYPTED_ATTR", "ONE_PASSWORD");
set_conf_single("SHOWBACK_ONLY_RUNNING", "NO");
set_conf_single("CONTEXT_RESTRICTED_DIRS", "/etc");
set_conf_single("CONTEXT_SAFE_DIRS", "");
//DB CONFIGURATION
vvalue.insert(make_pair("BACKEND","sqlite"));

View File

@ -122,11 +122,23 @@ EOF
)
LOCK="tm-fs_lvm-${DST_DS_SYS_ID}.lock"
exclusive "${LOCK}" 120 ssh_exec_and_log "${SRC_HOST}" "${CREATE_CMD}" \
exclusive "${LOCK}" 120 ssh_exec_and_log "${DST_HOST}" "${CREATE_CMD}" \
"Error creating LV named ${LV_NAME}"
# activate src volume (on DST)
CMD=$(cat <<EOF
set -ex -o pipefail
${SYNC}
${SUDO} ${LVSCAN}
${SUDO} ${LVCHANGE} -ay "${SRC_DEV}"
EOF
)
LOCK="tm-fs_lvm-${SRC_DS_SYS_ID}.lock"
exclusive "${LOCK}" 120 ssh_exec_and_log "${DST_HOST}" "${CMD}" \
"Error deactivating disk ${SRC_PATH}"
# copy volume data
ssh_exec_and_log "$SRC_HOST" \
ssh_exec_and_log "$DST_HOST" \
"${DD} if=${SRC_DEV} of=${DST_DEV} bs=${DD_BLOCK_SIZE:-64k}" \
"Error copying ${SRC} to ${DST}"
@ -144,20 +156,21 @@ EOF
LOCK="tm-fs_lvm-${SRC_DS_SYS_ID}.lock"
exclusive "${LOCK}" 120 ssh_exec_and_log "${SRC_HOST}" "${DELETE_CMD}" \
"Error deleting old LV ${SRC_DEV}"
fi
else
# activate
CMD=$(cat <<EOF
set -ex -o pipefail
${SYNC}
${SUDO} ${LVSCAN}
${SUDO} ${LVCHANGE} -ay "${DST_DEV}"
# activate
CMD=$(cat <<EOF
set -ex -o pipefail
${SYNC}
${SUDO} ${LVSCAN}
${SUDO} ${LVCHANGE} -ay "${DST_DEV}"
hostname -f >"${DST_DIR}/.host" || :
hostname -f >"${DST_DIR}/.host" || :
EOF
)
ssh_exec_and_log "$DST_HOST" "$CMD" \
"Error activating disk $DST_PATH"
ssh_exec_and_log "$DST_HOST" "$CMD" \
"Error activating disk $DST_PATH"
fi
exit 0
fi

View File

@ -122,11 +122,23 @@ EOF
)
LOCK="tm-fs_lvm-${DST_DS_SYS_ID}.lock"
exclusive "${LOCK}" 120 ssh_exec_and_log "${SRC_HOST}" "${CREATE_CMD}" \
exclusive "${LOCK}" 120 ssh_exec_and_log "${DST_HOST}" "${CREATE_CMD}" \
"Error creating LV named ${LV_NAME}"
# activate src volume (on DST)
CMD=$(cat <<EOF
set -ex -o pipefail
${SYNC}
${SUDO} ${LVSCAN}
${SUDO} ${LVCHANGE} -ay "${SRC_DEV}"
EOF
)
LOCK="tm-fs_lvm-${SRC_DS_SYS_ID}.lock"
exclusive "${LOCK}" 120 ssh_exec_and_log "${DST_HOST}" "${CMD}" \
"Error deactivating disk ${SRC_PATH}"
# copy volume data
ssh_exec_and_log "$SRC_HOST" \
ssh_exec_and_log "$DST_HOST" \
"${DD} if=${SRC_DEV} of=${DST_DEV} bs=${DD_BLOCK_SIZE:-64k}" \
"Error copying ${SRC} to ${DST}"
@ -144,21 +156,20 @@ EOF
LOCK="tm-fs_lvm-${SRC_DS_SYS_ID}.lock"
exclusive "${LOCK}" 120 ssh_exec_and_log "${SRC_HOST}" "${DELETE_CMD}" \
"Error deleting old LV ${SRC_DEV}"
fi
else
# activate
CMD=$(cat <<EOF
set -ex -o pipefail
${SYNC}
${SUDO} ${LVSCAN}
${SUDO} ${LVCHANGE} -ay "${DST_DEV}"
# activate
CMD=$(cat <<EOF
set -ex -o pipefail
${SYNC}
${SUDO} ${LVSCAN}
${SUDO} ${LVCHANGE} -ay "${DST_DEV}"
hostname -f >"${DST_DIR}/.host" || :
hostname -f >"${DST_DIR}/.host" || :
EOF
)
ssh_exec_and_log "$DST_HOST" "$CMD" \
"Error activating disk $DST_PATH"
ssh_exec_and_log "$DST_HOST" "$CMD" \
"Error activating disk $DST_PATH"
fi
fi
# After managing LV de/activation on different hosts, transfer normal files

View File

@ -70,6 +70,39 @@ const std::vector<ContextVariable> NETWORK6_CONTEXT = {
{"EXTERNAL", "EXTERNAL", "", false},
};
bool is_restricted(const string& path,
const set<string>& restricted,
const set<string>& safe)
{
auto canonical_c = realpath(path.c_str(), nullptr);
if (canonical_c == nullptr)
{
return false;
}
string canonical_str(canonical_c);
free(canonical_c);
for (auto& s : safe)
{
if (canonical_str.find(s) == 0)
{
return false;
}
}
for (auto& r : restricted)
{
if (canonical_str.find(r) == 0)
{
return true;
}
}
return false;
}
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */
/* CONTEXT - Public Interface */
@ -129,6 +162,33 @@ int VirtualMachine::generate_context(string &files, int &disk_id,
}
files = context->vector_value("FILES");
auto& nd = Nebula::instance();
string restricted_dirs, safe_dirs;
nd.get_configuration_attribute("CONTEXT_RESTRICTED_DIRS", restricted_dirs);
nd.get_configuration_attribute("CONTEXT_SAFE_DIRS", safe_dirs);
set<string> restricted, safe;
one_util::split_unique(restricted_dirs, ' ', restricted);
one_util::split_unique(safe_dirs, ' ', safe);
set<string> files_set;
one_util::split_unique(files, ' ', files_set);
for (auto& f : files_set)
{
if (is_restricted(f, restricted, safe))
{
string error = "CONTEXT/FILES cannot use " + f
+ ", it's in restricted directories";
log("VM", Log::ERROR, error);
set_template_error_message(error);
return -1;
}
}
files_ds = context->vector_value("FILES_DS");
if (!files_ds.empty())

View File

@ -333,6 +333,9 @@ static int do_context_command(VirtualMachine * vm, const string& password,
if ( rc == -1 )
{
auto vmpool = Nebula::instance().get_vmpool();
vmpool->update(vm);
return -1;
}
else if ( rc == 1 )

View File

@ -180,6 +180,11 @@ else
fi
done
# copy vm.xml and ds.xml from the $VM_DIR
if ls $VM_DIR/*.xml > /dev/null; then
multiline_exec_and_log "tar -cSf - $VM_DIR/*.xml | $SSH $DEST_HOST 'tar xSf - -C / '" \
"Failed to copy xml files to $DEST_HOST"
fi
# freeze/suspend domain and rsync raw disks again
if [ -n "$RAW_DISKS" ]; then
@ -287,4 +292,3 @@ fi
if [ "x$CLEANUP_MEMORY_ON_STOP" = "xyes" ]; then
(sudo -l | grep -q sysctl) && sudo -n sysctl vm.drop_caches=3 vm.compact_memory=1 &>/dev/null &
fi