diff --git a/app/docker/react/components/containers.ts b/app/docker/react/components/containers.ts index 827ae9d5e..1aa96e5b9 100644 --- a/app/docker/react/components/containers.ts +++ b/app/docker/react/components/containers.ts @@ -4,19 +4,22 @@ import { ComponentProps } from 'react'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withFormValidation } from '@/react-tools/withFormValidation'; +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { ContainerNetworksDatatable } from '@/react/docker/containers/ItemView/ContainerNetworksDatatable'; import { CommandsTab, CommandsTabValues, commandsTabValidation, } from '@/react/docker/containers/CreateView/CommandsTab'; -import { r2a } from '@/react-tools/react2angular'; -import { withCurrentUser } from '@/react-tools/withCurrentUser'; -import { ContainerNetworksDatatable } from '@/react/docker/containers/ItemView/ContainerNetworksDatatable'; import { EnvVarsTab, - Values as EnvVarsTabValues, envVarsTabUtils, } from '@/react/docker/containers/CreateView/EnvVarsTab'; +import { + VolumesTab, + volumesTabUtils, +} from '@/react/docker/containers/CreateView/VolumesTab'; const ngModule = angular .module('portainer.docker.react.components.containers', []) @@ -39,10 +42,18 @@ withFormValidation, CommandsTabValues>( commandsTabValidation ); -withFormValidation, EnvVarsTabValues>( +withFormValidation( ngModule, withUIRouter(withReactQuery(EnvVarsTab)), 'dockerCreateContainerEnvVarsTab', [], envVarsTabUtils.validation ); + +withFormValidation( + ngModule, + withUIRouter(withReactQuery(VolumesTab)), + 'dockerCreateContainerVolumesTab', + ['allowBindMounts'], + volumesTabUtils.validation +); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index 25104500a..41150384f 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -7,6 +7,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { buildConfirmButton } from '@@/modals/utils'; import { commandsTabUtils } from '@/react/docker/containers/CreateView/CommandsTab'; +import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab'; import { ContainerCapabilities, ContainerCapability } from '@/docker/models/containerCapabilities'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; import { ContainerDetailsViewModel } from '@/docker/models/container'; @@ -25,7 +26,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ 'Container', 'ContainerHelper', 'ImageHelper', - 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', @@ -49,7 +49,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ Container, ContainerHelper, ImageHelper, - Volume, NetworkService, ResourceControlService, Authentication, @@ -75,7 +74,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ selectedGPUs: ['all'], capabilities: ['compute', 'utility'], }, - Volumes: [], NetworkContainer: null, Labels: [], ExtraHosts: [], @@ -95,6 +93,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ RegistryModel: new PorImageRegistryModel(), commands: commandsTabUtils.getDefaultViewModel(), envVars: envVarsTabUtils.getDefaultViewModel(), + volumes: volumesTabUtils.getDefaultViewModel(), }; $scope.extraNetworks = {}; @@ -128,6 +127,12 @@ angular.module('portainer.docker').controller('CreateContainerController', [ }); } + $scope.onVolumesChange = function (volumes) { + return $scope.$evalAsync(() => { + $scope.formValues.volumes = volumes; + }); + }; + function onAlwaysPullChange(checked) { return $scope.$evalAsync(() => { $scope.formValues.alwaysPull = checked; @@ -215,14 +220,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ Labels: {}, }; - $scope.addVolume = function () { - $scope.formValues.Volumes.push({ name: '', containerPath: '', readOnly: false, type: 'volume' }); - }; - - $scope.removeVolume = function (index) { - $scope.formValues.Volumes.splice(index, 1); - }; - $scope.addPortBinding = function () { $scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' }); }; @@ -283,26 +280,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ config.HostConfig.PortBindings = bindings; } - function prepareVolumes(config) { - var binds = []; - var volumes = {}; - - $scope.formValues.Volumes.forEach(function (volume) { - var name = volume.name; - var containerPath = volume.containerPath; - if (name && containerPath) { - var bind = name + ':' + containerPath; - volumes[containerPath] = {}; - if (volume.readOnly) { - bind += ':ro'; - } - binds.push(bind); - } - }); - config.HostConfig.Binds = binds; - config.Volumes = volumes; - } - function prepareNetworkConfig(config) { var mode = config.HostConfig.NetworkMode; var container = $scope.formValues.NetworkContainer; @@ -461,11 +438,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [ var config = angular.copy($scope.config); config = commandsTabUtils.toRequest(config, $scope.formValues.commands); config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars); + config = volumesTabUtils.toRequest(config, $scope.formValues.volumes); prepareNetworkConfig(config); prepareImageConfig(config); preparePortBindings(config); - prepareVolumes(config); prepareLabels(config); prepareDevices(config); prepareResources(config); @@ -480,21 +457,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.config.HostConfig.PortBindings = bindings; } - function loadFromContainerVolumes(d) { - for (var v in d.Mounts) { - if ({}.hasOwnProperty.call(d.Mounts, v)) { - var mount = d.Mounts[v]; - var volume = { - type: mount.Type, - name: mount.Name || mount.Source, - containerPath: mount.Destination, - readOnly: mount.RW === false, - }; - $scope.formValues.Volumes.push(volume); - } - } - } - $scope.resetNetworkConfig = function () { $scope.config.NetworkingConfig = { EndpointsConfig: {}, @@ -682,9 +644,10 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.formValues.commands = commandsTabUtils.toViewModel(d); $scope.formValues.envVars = envVarsTabUtils.toViewModel(d); + $scope.formValues.volumes = volumesTabUtils.toViewModel(d); loadFromContainerPortBindings(d); - loadFromContainerVolumes(d); + loadFromContainerNetworkConfig(d); loadFromContainerLabels(d); @@ -714,18 +677,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled(); $scope.isAdminOrEndpointAdmin = Authentication.isAdmin(); - Volume.query( - {}, - function (d) { - $scope.availableVolumes = d.Volumes.sort((vol1, vol2) => { - return vol1.Name.localeCompare(vol2.Name); - }); - }, - function (e) { - Notifications.error('Failure', e, 'Unable to retrieve volumes'); - } - ); - var provider = $scope.applicationState.endpoint.mode.provider; var apiVersion = $scope.applicationState.endpoint.apiVersion; NetworkService.networks(provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', false, provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25) diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index ca32dcba2..140d62c23 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -214,77 +214,12 @@ > - +
-
- -
-
- - - map additional volume - -
- -
-
- -
- -
- container - -
- - -
-
- - -
- -
- -
- - -
- - -
- volume - -
- - -
- host - -
- - -
-
- - -
-
- -
- -
-
- -
-
- + +
- +
diff --git a/app/react/components/PageHeader/HeaderTitle.tsx b/app/react/components/PageHeader/HeaderTitle.tsx index e5363d9fd..43ece0244 100644 --- a/app/react/components/PageHeader/HeaderTitle.tsx +++ b/app/react/components/PageHeader/HeaderTitle.tsx @@ -16,10 +16,10 @@ export function HeaderTitle({ title, children }: PropsWithChildren) { return (
-
+

{title} -

- {children && {children}} + + {children && <>{children}}
diff --git a/app/react/components/buttons/ButtonGroup.tsx b/app/react/components/buttons/ButtonGroup.tsx index 94d296911..9b459b0e3 100644 --- a/app/react/components/buttons/ButtonGroup.tsx +++ b/app/react/components/buttons/ButtonGroup.tsx @@ -5,15 +5,21 @@ export type Size = 'xsmall' | 'small' | 'large'; export interface Props { size?: Size; className?: string; + 'aria-label'?: string; } export function ButtonGroup({ size = 'small', children, className, + 'aria-label': ariaLabel, }: PropsWithChildren) { return ( -
+
{children}
); diff --git a/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx b/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx index 989d4b61a..44a9301e5 100644 --- a/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx +++ b/app/react/components/form-components/ButtonSelector/ButtonSelector.tsx @@ -19,9 +19,10 @@ interface Props { disabled?: boolean; readOnly?: boolean; className?: string; + 'aria-label'?: string; } -export function ButtonSelector({ +export function ButtonSelector({ value, onChange, size, @@ -29,12 +30,17 @@ export function ButtonSelector({ disabled, readOnly, className, + 'aria-label': ariaLabel, }: Props) { return ( - + {options.map((option) => ( onChange(option.value)} disabled={disabled} diff --git a/app/react/components/form-components/InputList/InputList.tsx b/app/react/components/form-components/InputList/InputList.tsx index 700de04ec..8ab9e6059 100644 --- a/app/react/components/form-components/InputList/InputList.tsx +++ b/app/react/components/form-components/InputList/InputList.tsx @@ -17,8 +17,8 @@ type ArrElement = ArrType extends readonly (infer ElementType)[] ? ElementType : never; -export type ArrayError = - | FormikErrors>[] +export type ArrayError = + | Array | undefined>> | string | string[] | undefined; diff --git a/app/react/docker/containers/CreateView/VolumesTab/Item.tsx b/app/react/docker/containers/CreateView/VolumesTab/Item.tsx new file mode 100644 index 000000000..4be1a662c --- /dev/null +++ b/app/react/docker/containers/CreateView/VolumesTab/Item.tsx @@ -0,0 +1,98 @@ +import _ from 'lodash'; +import { ArrowRight } from 'lucide-react'; + +import { Icon } from '@@/Icon'; +import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; +import { FormError } from '@@/form-components/FormError'; +import { InputGroup } from '@@/form-components/InputGroup'; +import { ItemProps } from '@@/form-components/InputList'; +import { InputLabeled } from '@@/form-components/Input/InputLabeled'; + +import { Volume } from './types'; +import { useInputContext } from './context'; +import { VolumeSelector } from './VolumeSelector'; + +export function Item({ + item: volume, + onChange, + error, + index, +}: ItemProps) { + const allowBindMounts = useInputContext(); + + return ( +
+
+ setValue({ containerPath: e.target.value })} + size="small" + className="flex-1" + id={`container-path-${index}`} + /> + + {allowBindMounts && ( + + { + onChange({ ...volume, type, name: '' }); + }} + options={[ + { value: 'volume', label: 'Volume' }, + { value: 'bind', label: 'Bind' }, + ]} + aria-label="Volume type" + /> + + )} +
+
+ + {volume.type === 'volume' && ( + + + volume + + setValue({ name })} + inputId={`volume-${index}`} + /> + + )} + + {volume.type === 'bind' && ( + setValue({ name: e.target.value })} + id={`host-path-${index}`} + /> + )} + + + + aria-label="ReadWrite" + value={volume.readOnly} + onChange={(readOnly) => setValue({ readOnly })} + options={[ + { value: false, label: 'Writable' }, + { value: true, label: 'Read-only' }, + ]} + /> + +
+ {error && {_.first(Object.values(error))}} +
+ ); + + function setValue(partial: Partial) { + onChange({ ...volume, ...partial }); + } +} diff --git a/app/react/docker/containers/CreateView/VolumesTab/VolumeSelector.tsx b/app/react/docker/containers/CreateView/VolumesTab/VolumeSelector.tsx new file mode 100644 index 000000000..ef85fafa5 --- /dev/null +++ b/app/react/docker/containers/CreateView/VolumesTab/VolumeSelector.tsx @@ -0,0 +1,45 @@ +import { truncate } from '@/portainer/filters/filters'; +import { useVolumes } from '@/react/docker/volumes/queries/useVolumes'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { Select } from '@@/form-components/ReactSelect'; + +export function VolumeSelector({ + value, + onChange, + inputId, +}: { + value: string; + onChange: (value?: string) => void; + inputId?: string; +}) { + const environmentId = useEnvironmentId(); + const volumesQuery = useVolumes(environmentId, { + select(volumes) { + return volumes.sort((vol1, vol2) => vol1.Name.localeCompare(vol2.Name)); + }, + }); + + if (!volumesQuery.data) { + return null; + } + + const volumes = volumesQuery.data; + + const selectedValue = volumes.find((vol) => vol.Name === value); + + return ( +