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 { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
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 {
|
import {
|
||||||
CommandsTab,
|
CommandsTab,
|
||||||
CommandsTabValues,
|
CommandsTabValues,
|
||||||
commandsTabValidation,
|
commandsTabValidation,
|
||||||
} from '@/react/docker/containers/CreateView/CommandsTab';
|
} 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 {
|
import {
|
||||||
EnvVarsTab,
|
EnvVarsTab,
|
||||||
Values as EnvVarsTabValues,
|
|
||||||
envVarsTabUtils,
|
envVarsTabUtils,
|
||||||
} from '@/react/docker/containers/CreateView/EnvVarsTab';
|
} from '@/react/docker/containers/CreateView/EnvVarsTab';
|
||||||
|
import {
|
||||||
|
VolumesTab,
|
||||||
|
volumesTabUtils,
|
||||||
|
} from '@/react/docker/containers/CreateView/VolumesTab';
|
||||||
|
|
||||||
const ngModule = angular
|
const ngModule = angular
|
||||||
.module('portainer.docker.react.components.containers', [])
|
.module('portainer.docker.react.components.containers', [])
|
||||||
|
@ -39,10 +42,18 @@ withFormValidation<ComponentProps<typeof CommandsTab>, CommandsTabValues>(
|
||||||
commandsTabValidation
|
commandsTabValidation
|
||||||
);
|
);
|
||||||
|
|
||||||
withFormValidation<ComponentProps<typeof EnvVarsTab>, EnvVarsTabValues>(
|
withFormValidation(
|
||||||
ngModule,
|
ngModule,
|
||||||
withUIRouter(withReactQuery(EnvVarsTab)),
|
withUIRouter(withReactQuery(EnvVarsTab)),
|
||||||
'dockerCreateContainerEnvVarsTab',
|
'dockerCreateContainerEnvVarsTab',
|
||||||
[],
|
[],
|
||||||
envVarsTabUtils.validation
|
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 { buildConfirmButton } from '@@/modals/utils';
|
||||||
|
|
||||||
import { commandsTabUtils } from '@/react/docker/containers/CreateView/CommandsTab';
|
import { commandsTabUtils } from '@/react/docker/containers/CreateView/CommandsTab';
|
||||||
|
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||||
import { ContainerCapabilities, ContainerCapability } from '@/docker/models/containerCapabilities';
|
import { ContainerCapabilities, ContainerCapability } from '@/docker/models/containerCapabilities';
|
||||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||||
import { ContainerDetailsViewModel } from '@/docker/models/container';
|
import { ContainerDetailsViewModel } from '@/docker/models/container';
|
||||||
|
@ -25,7 +26,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
'Container',
|
'Container',
|
||||||
'ContainerHelper',
|
'ContainerHelper',
|
||||||
'ImageHelper',
|
'ImageHelper',
|
||||||
'Volume',
|
|
||||||
'NetworkService',
|
'NetworkService',
|
||||||
'ResourceControlService',
|
'ResourceControlService',
|
||||||
'Authentication',
|
'Authentication',
|
||||||
|
@ -49,7 +49,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
Container,
|
Container,
|
||||||
ContainerHelper,
|
ContainerHelper,
|
||||||
ImageHelper,
|
ImageHelper,
|
||||||
Volume,
|
|
||||||
NetworkService,
|
NetworkService,
|
||||||
ResourceControlService,
|
ResourceControlService,
|
||||||
Authentication,
|
Authentication,
|
||||||
|
@ -75,7 +74,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
selectedGPUs: ['all'],
|
selectedGPUs: ['all'],
|
||||||
capabilities: ['compute', 'utility'],
|
capabilities: ['compute', 'utility'],
|
||||||
},
|
},
|
||||||
Volumes: [],
|
|
||||||
NetworkContainer: null,
|
NetworkContainer: null,
|
||||||
Labels: [],
|
Labels: [],
|
||||||
ExtraHosts: [],
|
ExtraHosts: [],
|
||||||
|
@ -95,6 +93,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
RegistryModel: new PorImageRegistryModel(),
|
RegistryModel: new PorImageRegistryModel(),
|
||||||
commands: commandsTabUtils.getDefaultViewModel(),
|
commands: commandsTabUtils.getDefaultViewModel(),
|
||||||
envVars: envVarsTabUtils.getDefaultViewModel(),
|
envVars: envVarsTabUtils.getDefaultViewModel(),
|
||||||
|
volumes: volumesTabUtils.getDefaultViewModel(),
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.extraNetworks = {};
|
$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) {
|
function onAlwaysPullChange(checked) {
|
||||||
return $scope.$evalAsync(() => {
|
return $scope.$evalAsync(() => {
|
||||||
$scope.formValues.alwaysPull = checked;
|
$scope.formValues.alwaysPull = checked;
|
||||||
|
@ -215,14 +220,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
Labels: {},
|
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.addPortBinding = function () {
|
||||||
$scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
|
$scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
|
||||||
};
|
};
|
||||||
|
@ -283,26 +280,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
config.HostConfig.PortBindings = bindings;
|
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) {
|
function prepareNetworkConfig(config) {
|
||||||
var mode = config.HostConfig.NetworkMode;
|
var mode = config.HostConfig.NetworkMode;
|
||||||
var container = $scope.formValues.NetworkContainer;
|
var container = $scope.formValues.NetworkContainer;
|
||||||
|
@ -461,11 +438,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
var config = angular.copy($scope.config);
|
var config = angular.copy($scope.config);
|
||||||
config = commandsTabUtils.toRequest(config, $scope.formValues.commands);
|
config = commandsTabUtils.toRequest(config, $scope.formValues.commands);
|
||||||
config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars);
|
config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars);
|
||||||
|
config = volumesTabUtils.toRequest(config, $scope.formValues.volumes);
|
||||||
|
|
||||||
prepareNetworkConfig(config);
|
prepareNetworkConfig(config);
|
||||||
prepareImageConfig(config);
|
prepareImageConfig(config);
|
||||||
preparePortBindings(config);
|
preparePortBindings(config);
|
||||||
prepareVolumes(config);
|
|
||||||
prepareLabels(config);
|
prepareLabels(config);
|
||||||
prepareDevices(config);
|
prepareDevices(config);
|
||||||
prepareResources(config);
|
prepareResources(config);
|
||||||
|
@ -480,21 +457,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
$scope.config.HostConfig.PortBindings = bindings;
|
$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.resetNetworkConfig = function () {
|
||||||
$scope.config.NetworkingConfig = {
|
$scope.config.NetworkingConfig = {
|
||||||
EndpointsConfig: {},
|
EndpointsConfig: {},
|
||||||
|
@ -682,9 +644,10 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
|
|
||||||
$scope.formValues.commands = commandsTabUtils.toViewModel(d);
|
$scope.formValues.commands = commandsTabUtils.toViewModel(d);
|
||||||
$scope.formValues.envVars = envVarsTabUtils.toViewModel(d);
|
$scope.formValues.envVars = envVarsTabUtils.toViewModel(d);
|
||||||
|
$scope.formValues.volumes = volumesTabUtils.toViewModel(d);
|
||||||
|
|
||||||
loadFromContainerPortBindings(d);
|
loadFromContainerPortBindings(d);
|
||||||
loadFromContainerVolumes(d);
|
|
||||||
loadFromContainerNetworkConfig(d);
|
loadFromContainerNetworkConfig(d);
|
||||||
|
|
||||||
loadFromContainerLabels(d);
|
loadFromContainerLabels(d);
|
||||||
|
@ -714,18 +677,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
|
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
|
||||||
$scope.isAdminOrEndpointAdmin = Authentication.isAdmin();
|
$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 provider = $scope.applicationState.endpoint.mode.provider;
|
||||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||||
NetworkService.networks(provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', false, provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25)
|
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>
|
></docker-create-container-commands-tab>
|
||||||
</div>
|
</div>
|
||||||
<!-- !tab-command -->
|
<!-- !tab-command -->
|
||||||
<!-- tab-volume -->
|
|
||||||
<div class="tab-pane" id="volumes">
|
<div class="tab-pane" id="volumes">
|
||||||
<form class="form-horizontal" style="margin-top: 15px">
|
<docker-create-container-volumes-tab ng-if="state.containerIsLoaded" values="formValues.volumes" on-change="(onVolumesChange)" allow-bind-mounts="allowBindMounts">
|
||||||
<!-- volumes -->
|
</docker-create-container-volumes-tab>
|
||||||
<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 -->
|
|
||||||
</div>
|
</div>
|
||||||
<!-- !tab-volume -->
|
|
||||||
<!-- tab-network -->
|
<!-- tab-network -->
|
||||||
<div class="tab-pane" id="network">
|
<div class="tab-pane" id="network">
|
||||||
<form class="form-horizontal" style="margin-top: 15px">
|
<form class="form-horizontal" style="margin-top: 15px">
|
||||||
|
|
|
@ -16,10 +16,10 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between whitespace-normal pt-3">
|
<div className="flex justify-between whitespace-normal pt-3">
|
||||||
<div className="flex items-center gap-2">
|
<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}
|
{title}
|
||||||
</div>
|
</h1>
|
||||||
{children && <span>{children}</span>}
|
{children && <>{children}</>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<NotificationsMenu />
|
<NotificationsMenu />
|
||||||
|
|
|
@ -5,15 +5,21 @@ export type Size = 'xsmall' | 'small' | 'large';
|
||||||
export interface Props {
|
export interface Props {
|
||||||
size?: Size;
|
size?: Size;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ButtonGroup({
|
export function ButtonGroup({
|
||||||
size = 'small',
|
size = 'small',
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -19,9 +19,10 @@ interface Props<T> {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ButtonSelector<T extends string | number>({
|
export function ButtonSelector<T extends string | number | boolean>({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
size,
|
size,
|
||||||
|
@ -29,12 +30,17 @@ export function ButtonSelector<T extends string | number>({
|
||||||
disabled,
|
disabled,
|
||||||
readOnly,
|
readOnly,
|
||||||
className,
|
className,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
return (
|
return (
|
||||||
<ButtonGroup size={size} className={clsx(styles.group, className)}>
|
<ButtonGroup
|
||||||
|
size={size}
|
||||||
|
className={clsx(styles.group, className)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<OptionItem
|
<OptionItem
|
||||||
key={option.value}
|
key={option.value.toString()}
|
||||||
selected={value === option.value}
|
selected={value === option.value}
|
||||||
onChange={() => onChange(option.value)}
|
onChange={() => onChange(option.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -17,8 +17,8 @@ type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
|
||||||
? ElementType
|
? ElementType
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type ArrayError<T> =
|
export type ArrayError<TArray> =
|
||||||
| FormikErrors<ArrElement<T>>[]
|
| Array<FormikErrors<ArrElement<TArray> | undefined>>
|
||||||
| string
|
| string
|
||||||
| string[]
|
| string[]
|
||||||
| undefined;
|
| 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