mirror of https://github.com/portainer/portainer
				
				
				
			refactor(containers): migrate volumes tab to react [EE-5209] (#10284)
							parent
							
								
									16ccf5871e
								
							
						
					
					
						commit
						e92f067e42
					
				| 
						 | 
				
			
			@ -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<ComponentProps<typeof CommandsTab>, CommandsTabValues>(
 | 
			
		|||
  commandsTabValidation
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
withFormValidation<ComponentProps<typeof EnvVarsTab>, EnvVarsTabValues>(
 | 
			
		||||
withFormValidation(
 | 
			
		||||
  ngModule,
 | 
			
		||||
  withUIRouter(withReactQuery(EnvVarsTab)),
 | 
			
		||||
  'dockerCreateContainerEnvVarsTab',
 | 
			
		||||
  [],
 | 
			
		||||
  envVarsTabUtils.validation
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
withFormValidation(
 | 
			
		||||
  ngModule,
 | 
			
		||||
  withUIRouter(withReactQuery(VolumesTab)),
 | 
			
		||||
  'dockerCreateContainerVolumesTab',
 | 
			
		||||
  ['allowBindMounts'],
 | 
			
		||||
  volumesTabUtils.validation
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -214,77 +214,12 @@
 | 
			
		|||
              ></docker-create-container-commands-tab>
 | 
			
		||||
            </div>
 | 
			
		||||
            <!-- !tab-command -->
 | 
			
		||||
            <!-- tab-volume -->
 | 
			
		||||
 | 
			
		||||
            <div class="tab-pane" id="volumes">
 | 
			
		||||
              <form class="form-horizontal" style="margin-top: 15px">
 | 
			
		||||
                <!-- volumes -->
 | 
			
		||||
                <div class="form-group">
 | 
			
		||||
                  <div class="col-sm-12" style="margin-top: 5px">
 | 
			
		||||
                    <label class="control-label text-left">Volume mapping</label>
 | 
			
		||||
                    <span class="label label-default interactive" style="margin-left: 10px" ng-click="addVolume()">
 | 
			
		||||
                      <pr-icon icon="'plus'" mode="'alt'"></pr-icon> map additional volume
 | 
			
		||||
                    </span>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <!-- volumes-input-list -->
 | 
			
		||||
                  <div class="form-inline" style="margin-top: 10px">
 | 
			
		||||
                    <div ng-repeat="volume in formValues.Volumes">
 | 
			
		||||
                      <!-- volume-line1 -->
 | 
			
		||||
                      <div class="col-sm-12 form-inline" style="margin-top: 10px">
 | 
			
		||||
                        <!-- container-path -->
 | 
			
		||||
                        <div class="input-group input-group-sm col-sm-6">
 | 
			
		||||
                          <span class="input-group-addon">container</span>
 | 
			
		||||
                          <input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container" />
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <!-- !container-path -->
 | 
			
		||||
                        <!-- volume-type -->
 | 
			
		||||
                        <div class="input-group col-sm-5" style="margin-left: 5px">
 | 
			
		||||
                          <div class="btn-group btn-group-sm" ng-if="allowBindMounts">
 | 
			
		||||
                            <label class="btn btn-light" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
 | 
			
		||||
                            <label class="btn btn-light" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
 | 
			
		||||
                          </div>
 | 
			
		||||
                          <button class="btn btn-light" type="button" ng-click="removeVolume($index)">
 | 
			
		||||
                            <pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
 | 
			
		||||
                          </button>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <!-- !volume-type -->
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <!-- !volume-line1 -->
 | 
			
		||||
                      <!-- volume-line2 -->
 | 
			
		||||
                      <div class="col-sm-12 form-inline" style="margin-top: 5px">
 | 
			
		||||
                        <pr-icon icon="'arrow-right'"></pr-icon>
 | 
			
		||||
                        <!-- volume -->
 | 
			
		||||
                        <div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
 | 
			
		||||
                          <span class="input-group-addon">volume</span>
 | 
			
		||||
                          <select class="form-control" ng-model="volume.name">
 | 
			
		||||
                            <option selected disabled hidden value="">Select a volume</option>
 | 
			
		||||
                            <option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name | truncate: 30 }} - {{ vol.Driver | truncate: 30 }}</option>
 | 
			
		||||
                          </select>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <!-- !volume -->
 | 
			
		||||
                        <!-- bind -->
 | 
			
		||||
                        <div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
 | 
			
		||||
                          <span class="input-group-addon">host</span>
 | 
			
		||||
                          <input type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host" />
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <!-- !bind -->
 | 
			
		||||
                        <!-- read-only -->
 | 
			
		||||
                        <div class="input-group input-group-sm col-sm-5" style="margin-left: 5px">
 | 
			
		||||
                          <div class="btn-group btn-group-sm">
 | 
			
		||||
                            <label class="btn btn-light" ng-model="volume.readOnly" uib-btn-radio="false">Writable</label>
 | 
			
		||||
                            <label class="btn btn-light" ng-model="volume.readOnly" uib-btn-radio="true">Read-only</label>
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <!-- !read-only -->
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <!-- !volume-line2 -->
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <!-- !volumes-input-list -->
 | 
			
		||||
                </div>
 | 
			
		||||
              </form>
 | 
			
		||||
              <!-- !volumes -->
 | 
			
		||||
              <docker-create-container-volumes-tab ng-if="state.containerIsLoaded" values="formValues.volumes" on-change="(onVolumesChange)" allow-bind-mounts="allowBindMounts">
 | 
			
		||||
              </docker-create-container-volumes-tab>
 | 
			
		||||
            </div>
 | 
			
		||||
            <!-- !tab-volume -->
 | 
			
		||||
 | 
			
		||||
            <!-- tab-network -->
 | 
			
		||||
            <div class="tab-pane" id="network">
 | 
			
		||||
              <form class="form-horizontal" style="margin-top: 15px">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,10 +16,10 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
 | 
			
		|||
  return (
 | 
			
		||||
    <div className="flex justify-between whitespace-normal pt-3">
 | 
			
		||||
      <div className="flex items-center gap-2">
 | 
			
		||||
        <div className="text-2xl font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white">
 | 
			
		||||
        <h1 className="m-0 text-2xl font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white">
 | 
			
		||||
          {title}
 | 
			
		||||
        </div>
 | 
			
		||||
        {children && <span>{children}</span>}
 | 
			
		||||
        </h1>
 | 
			
		||||
        {children && <>{children}</>}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="flex items-end">
 | 
			
		||||
        <NotificationsMenu />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<Props>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={clsx('btn-group', sizeClass(size), className)} role="group">
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx('btn-group', sizeClass(size), className)}
 | 
			
		||||
      role="group"
 | 
			
		||||
      aria-label={ariaLabel}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,9 +19,10 @@ interface Props<T> {
 | 
			
		|||
  disabled?: boolean;
 | 
			
		||||
  readOnly?: boolean;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  'aria-label'?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ButtonSelector<T extends string | number>({
 | 
			
		||||
export function ButtonSelector<T extends string | number | boolean>({
 | 
			
		||||
  value,
 | 
			
		||||
  onChange,
 | 
			
		||||
  size,
 | 
			
		||||
| 
						 | 
				
			
			@ -29,12 +30,17 @@ export function ButtonSelector<T extends string | number>({
 | 
			
		|||
  disabled,
 | 
			
		||||
  readOnly,
 | 
			
		||||
  className,
 | 
			
		||||
  'aria-label': ariaLabel,
 | 
			
		||||
}: Props<T>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <ButtonGroup size={size} className={clsx(styles.group, className)}>
 | 
			
		||||
    <ButtonGroup
 | 
			
		||||
      size={size}
 | 
			
		||||
      className={clsx(styles.group, className)}
 | 
			
		||||
      aria-label={ariaLabel}
 | 
			
		||||
    >
 | 
			
		||||
      {options.map((option) => (
 | 
			
		||||
        <OptionItem
 | 
			
		||||
          key={option.value}
 | 
			
		||||
          key={option.value.toString()}
 | 
			
		||||
          selected={value === option.value}
 | 
			
		||||
          onChange={() => onChange(option.value)}
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,8 +17,8 @@ type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
 | 
			
		|||
  ? ElementType
 | 
			
		||||
  : never;
 | 
			
		||||
 | 
			
		||||
export type ArrayError<T> =
 | 
			
		||||
  | FormikErrors<ArrElement<T>>[]
 | 
			
		||||
export type ArrayError<TArray> =
 | 
			
		||||
  | Array<FormikErrors<ArrElement<TArray> | undefined>>
 | 
			
		||||
  | string
 | 
			
		||||
  | string[]
 | 
			
		||||
  | undefined;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<Volume>) {
 | 
			
		||||
  const allowBindMounts = useInputContext();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <div className="col-sm-12 form-inline flex gap-1">
 | 
			
		||||
        <InputLabeled
 | 
			
		||||
          label="container"
 | 
			
		||||
          placeholder="e.g. /path/in/container"
 | 
			
		||||
          value={volume.containerPath}
 | 
			
		||||
          onChange={(e) => setValue({ containerPath: e.target.value })}
 | 
			
		||||
          size="small"
 | 
			
		||||
          className="flex-1"
 | 
			
		||||
          id={`container-path-${index}`}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {allowBindMounts && (
 | 
			
		||||
          <InputGroup size="small">
 | 
			
		||||
            <ButtonSelector
 | 
			
		||||
              value={volume.type}
 | 
			
		||||
              onChange={(type) => {
 | 
			
		||||
                onChange({ ...volume, type, name: '' });
 | 
			
		||||
              }}
 | 
			
		||||
              options={[
 | 
			
		||||
                { value: 'volume', label: 'Volume' },
 | 
			
		||||
                { value: 'bind', label: 'Bind' },
 | 
			
		||||
              ]}
 | 
			
		||||
              aria-label="Volume type"
 | 
			
		||||
            />
 | 
			
		||||
          </InputGroup>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="col-sm-12 form-inline mt-1 flex items-center gap-1">
 | 
			
		||||
        <Icon icon={ArrowRight} />
 | 
			
		||||
        {volume.type === 'volume' && (
 | 
			
		||||
          <InputGroup size="small" className="flex-1">
 | 
			
		||||
            <InputGroup.Addon as="label" htmlFor={`volume-${index}`}>
 | 
			
		||||
              volume
 | 
			
		||||
            </InputGroup.Addon>
 | 
			
		||||
            <VolumeSelector
 | 
			
		||||
              value={volume.name}
 | 
			
		||||
              onChange={(name) => setValue({ name })}
 | 
			
		||||
              inputId={`volume-${index}`}
 | 
			
		||||
            />
 | 
			
		||||
          </InputGroup>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {volume.type === 'bind' && (
 | 
			
		||||
          <InputLabeled
 | 
			
		||||
            size="small"
 | 
			
		||||
            className="flex-1"
 | 
			
		||||
            label="host"
 | 
			
		||||
            placeholder="e.g. /path/on/host"
 | 
			
		||||
            value={volume.name}
 | 
			
		||||
            onChange={(e) => setValue({ name: e.target.value })}
 | 
			
		||||
            id={`host-path-${index}`}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <InputGroup size="small">
 | 
			
		||||
          <ButtonSelector<boolean>
 | 
			
		||||
            aria-label="ReadWrite"
 | 
			
		||||
            value={volume.readOnly}
 | 
			
		||||
            onChange={(readOnly) => setValue({ readOnly })}
 | 
			
		||||
            options={[
 | 
			
		||||
              { value: false, label: 'Writable' },
 | 
			
		||||
              { value: true, label: 'Read-only' },
 | 
			
		||||
            ]}
 | 
			
		||||
          />
 | 
			
		||||
        </InputGroup>
 | 
			
		||||
      </div>
 | 
			
		||||
      {error && <FormError>{_.first(Object.values(error))}</FormError>}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  function setValue(partial: Partial<Volume>) {
 | 
			
		||||
    onChange({ ...volume, ...partial });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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 (
 | 
			
		||||
    <Select
 | 
			
		||||
      placeholder="Select a volume"
 | 
			
		||||
      options={volumes}
 | 
			
		||||
      getOptionLabel={(vol) =>
 | 
			
		||||
        `${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
 | 
			
		||||
      }
 | 
			
		||||
      getOptionValue={(vol) => vol.Name}
 | 
			
		||||
      isMulti={false}
 | 
			
		||||
      value={selectedValue}
 | 
			
		||||
      onChange={(vol) => onChange(vol?.Name)}
 | 
			
		||||
      inputId={inputId}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,46 @@
 | 
			
		|||
import { useState } from 'react';
 | 
			
		||||
import { FormikErrors } from 'formik';
 | 
			
		||||
 | 
			
		||||
import { InputList } from '@@/form-components/InputList';
 | 
			
		||||
 | 
			
		||||
import { Values, Volume } from './types';
 | 
			
		||||
import { InputContext } from './context';
 | 
			
		||||
import { Item } from './Item';
 | 
			
		||||
 | 
			
		||||
export function VolumesTab({
 | 
			
		||||
  onChange,
 | 
			
		||||
  values,
 | 
			
		||||
  allowBindMounts,
 | 
			
		||||
  errors,
 | 
			
		||||
}: {
 | 
			
		||||
  onChange: (values: Values) => void;
 | 
			
		||||
  values: Values;
 | 
			
		||||
  allowBindMounts: boolean;
 | 
			
		||||
  errors?: FormikErrors<Values>;
 | 
			
		||||
}) {
 | 
			
		||||
  const [controlledValues, setControlledValues] = useState(values);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <InputContext.Provider value={allowBindMounts}>
 | 
			
		||||
      <InputList<Volume>
 | 
			
		||||
        errors={Array.isArray(errors) ? errors : []}
 | 
			
		||||
        label="Volume mapping"
 | 
			
		||||
        onChange={(volumes) => handleChange(volumes)}
 | 
			
		||||
        value={controlledValues}
 | 
			
		||||
        addLabel="map additional volume"
 | 
			
		||||
        item={Item}
 | 
			
		||||
        itemBuilder={() => ({
 | 
			
		||||
          containerPath: '',
 | 
			
		||||
          type: 'volume',
 | 
			
		||||
          name: '',
 | 
			
		||||
          readOnly: false,
 | 
			
		||||
        })}
 | 
			
		||||
      />
 | 
			
		||||
    </InputContext.Provider>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  function handleChange(newValues: Values) {
 | 
			
		||||
    onChange(newValues);
 | 
			
		||||
    setControlledValues(() => newValues);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
import { createContext, useContext } from 'react';
 | 
			
		||||
 | 
			
		||||
export const InputContext = createContext<boolean | null>(null);
 | 
			
		||||
 | 
			
		||||
export function useInputContext() {
 | 
			
		||||
  const value = useContext(InputContext);
 | 
			
		||||
 | 
			
		||||
  if (value === null) {
 | 
			
		||||
    throw new Error('useContext must be used within a Context.Provider');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return value;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import { validation } from './validation';
 | 
			
		||||
import { toRequest } from './toRequest';
 | 
			
		||||
import { toViewModel, getDefaultViewModel } from './toViewModel';
 | 
			
		||||
 | 
			
		||||
export { VolumesTab } from './VolumesTab';
 | 
			
		||||
 | 
			
		||||
export { type Values as VolumesTabValues } from './types';
 | 
			
		||||
 | 
			
		||||
export const volumesTabUtils = {
 | 
			
		||||
  toRequest,
 | 
			
		||||
  toViewModel,
 | 
			
		||||
  validation,
 | 
			
		||||
  getDefaultViewModel,
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
import { CreateContainerRequest } from '../types';
 | 
			
		||||
 | 
			
		||||
import { Values } from './types';
 | 
			
		||||
 | 
			
		||||
export function toRequest(
 | 
			
		||||
  oldConfig: CreateContainerRequest,
 | 
			
		||||
  values: Values
 | 
			
		||||
): CreateContainerRequest {
 | 
			
		||||
  const validValues = values.filter(
 | 
			
		||||
    (volume) => volume.containerPath && volume.name
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const volumes = Object.fromEntries(
 | 
			
		||||
    validValues.map((volume) => [volume.containerPath, {}])
 | 
			
		||||
  );
 | 
			
		||||
  const binds = validValues.map((volume) => {
 | 
			
		||||
    let bind = `${volume.name}:${volume.containerPath}`;
 | 
			
		||||
    if (volume.readOnly) {
 | 
			
		||||
      bind += ':ro';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return bind;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    ...oldConfig,
 | 
			
		||||
    Volumes: volumes,
 | 
			
		||||
    HostConfig: {
 | 
			
		||||
      ...oldConfig.HostConfig,
 | 
			
		||||
      Binds: binds,
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
import { ContainerJSON } from '../../queries/container';
 | 
			
		||||
 | 
			
		||||
import { VolumeType, Values } from './types';
 | 
			
		||||
 | 
			
		||||
export function toViewModel(config: ContainerJSON): Values {
 | 
			
		||||
  return Object.values(config.Mounts || {}).map((mount) => ({
 | 
			
		||||
    type: (mount.Type || 'volume') as VolumeType,
 | 
			
		||||
    name: mount.Name || mount.Source || '',
 | 
			
		||||
    containerPath: mount.Destination || '',
 | 
			
		||||
    readOnly: mount.RW === false,
 | 
			
		||||
  }));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getDefaultViewModel(): Values {
 | 
			
		||||
  return [];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
export const volumeTypes = ['bind', 'volume'] as const;
 | 
			
		||||
 | 
			
		||||
export type VolumeType = (typeof volumeTypes)[number];
 | 
			
		||||
 | 
			
		||||
export interface Volume {
 | 
			
		||||
  containerPath: string;
 | 
			
		||||
  type: VolumeType;
 | 
			
		||||
  name: string;
 | 
			
		||||
  readOnly: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type Values = Array<Volume>;
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
import { object, SchemaOf, array, string, mixed } from 'yup';
 | 
			
		||||
 | 
			
		||||
import { Values, VolumeType, volumeTypes } from './types';
 | 
			
		||||
 | 
			
		||||
export function validation(): SchemaOf<Values> {
 | 
			
		||||
  return array(
 | 
			
		||||
    object({
 | 
			
		||||
      containerPath: string().required('Container path is required'),
 | 
			
		||||
      type: mixed<VolumeType>()
 | 
			
		||||
        .oneOf([...volumeTypes])
 | 
			
		||||
        .default('volume'),
 | 
			
		||||
      name: string().required('Volume name is required'),
 | 
			
		||||
      readOnly: mixed<boolean>().default(false),
 | 
			
		||||
    })
 | 
			
		||||
  ).default([]);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
import { EnvironmentId } from '@/react/portainer/environments/types';
 | 
			
		||||
 | 
			
		||||
import { queryKeys as dockerQueryKeys } from '../../queries/utils/root';
 | 
			
		||||
 | 
			
		||||
export const queryKeys = {
 | 
			
		||||
  base: (environmentId: EnvironmentId) =>
 | 
			
		||||
    [...dockerQueryKeys.root(environmentId), 'volumes'] as const,
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,45 @@
 | 
			
		|||
import { useQuery } from 'react-query';
 | 
			
		||||
import { Volume } from 'docker-types/generated/1.41';
 | 
			
		||||
 | 
			
		||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
 | 
			
		||||
import { buildUrl as buildDockerUrl } from '@/react/docker/proxy/queries/build-url';
 | 
			
		||||
import { EnvironmentId } from '@/react/portainer/environments/types';
 | 
			
		||||
 | 
			
		||||
import { queryKeys } from './query-keys';
 | 
			
		||||
 | 
			
		||||
export function useVolumes<T = Volume[]>(
 | 
			
		||||
  environmentId: EnvironmentId,
 | 
			
		||||
  { select }: { select?: (data: Volume[]) => T } = {}
 | 
			
		||||
) {
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    queryKeys.base(environmentId),
 | 
			
		||||
    () => getVolumes(environmentId),
 | 
			
		||||
    { select }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface VolumesResponse {
 | 
			
		||||
  Volumes: Volume[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getVolumes(environmentId: EnvironmentId) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios.get<VolumesResponse>(
 | 
			
		||||
      buildUrl(environmentId, 'volumes')
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return data.Volumes;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    throw parseAxiosError(error as Error, 'Unable to retrieve volumes');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function buildUrl(environmentId: EnvironmentId, action: string, id?: string) {
 | 
			
		||||
  let url = buildDockerUrl(environmentId, action);
 | 
			
		||||
 | 
			
		||||
  if (id) {
 | 
			
		||||
    url += `/${id}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return url;
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue