mirror of https://github.com/portainer/portainer
feat(containers): migrate base form fields to react [EE-5207]
parent
9cc2f0b582
commit
3b96877616
|
@ -42,6 +42,10 @@ import {
|
|||
LabelsTab,
|
||||
labelsTabUtils,
|
||||
} from '@/react/docker/containers/CreateView/LabelsTab';
|
||||
import {
|
||||
BaseForm,
|
||||
baseFormUtils,
|
||||
} from '@/react/docker/containers/CreateView/BaseForm';
|
||||
|
||||
const ngModule = angular
|
||||
.module('portainer.docker.react.components.containers', [])
|
||||
|
@ -126,3 +130,11 @@ withFormValidation(
|
|||
[],
|
||||
labelsTabUtils.validation
|
||||
);
|
||||
|
||||
withFormValidation(
|
||||
ngModule,
|
||||
withUIRouter(withReactQuery(withCurrentUser(BaseForm))),
|
||||
'dockerCreateContainerBaseForm',
|
||||
['isValid', 'isLoading', 'setFieldError'],
|
||||
baseFormUtils.validation
|
||||
);
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
|
||||
import { confirmDestructive } from '@@/modals/confirm';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
@ -10,15 +8,19 @@ import { commandsTabUtils } from '@/react/docker/containers/CreateView/CommandsT
|
|||
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||
import { networkTabUtils } from '@/react/docker/containers/CreateView/NetworkTab';
|
||||
import { capabilitiesTabUtils } from '@/react/docker/containers/CreateView/CapabilitiesTab';
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { ContainerDetailsViewModel } from '@/docker/models/container';
|
||||
import { labelsTabUtils } from '@/react/docker/containers/CreateView/LabelsTab';
|
||||
|
||||
import './createcontainer.css';
|
||||
import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
|
||||
import { getContainers } from '@/react/docker/containers/queries/containers';
|
||||
import { resourcesTabUtils } from '@/react/docker/containers/CreateView/ResourcesTab';
|
||||
import { restartPolicyTabUtils } from '@/react/docker/containers/CreateView/RestartPolicyTab';
|
||||
import { baseFormUtils } from '@/react/docker/containers/CreateView/BaseForm';
|
||||
import { buildImageFullURI } from '@/react/docker/images/utils';
|
||||
|
||||
import './createcontainer.css';
|
||||
import { RegistryTypes } from '@/react/portainer/registries/types/registry';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
'$q',
|
||||
|
@ -43,6 +45,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
'SettingsService',
|
||||
'HttpRequestHelper',
|
||||
'endpoint',
|
||||
'EndpointService',
|
||||
'WebhookService',
|
||||
function (
|
||||
$q,
|
||||
$scope,
|
||||
|
@ -65,29 +69,19 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
SystemService,
|
||||
SettingsService,
|
||||
HttpRequestHelper,
|
||||
endpoint
|
||||
endpoint,
|
||||
EndpointService,
|
||||
WebhookService
|
||||
) {
|
||||
const nodeName = $transition$.params().nodeName;
|
||||
|
||||
$scope.create = create;
|
||||
$scope.endpoint = endpoint;
|
||||
$scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK;
|
||||
$scope.formValues = {
|
||||
alwaysPull: true,
|
||||
GPU: {
|
||||
enabled: false,
|
||||
useSpecific: false,
|
||||
selectedGPUs: ['all'],
|
||||
capabilities: ['compute', 'utility'],
|
||||
},
|
||||
ExtraHosts: [],
|
||||
MacAddress: '',
|
||||
IPv4: '',
|
||||
IPv6: '',
|
||||
DnsPrimary: '',
|
||||
DnsSecondary: '',
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
NodeName: null,
|
||||
RegistryModel: new PorImageRegistryModel(),
|
||||
$scope.isAdmin = Authentication.isAdmin();
|
||||
const userDetails = this.Authentication.getUserDetails();
|
||||
|
||||
$scope.formValues = {
|
||||
commands: commandsTabUtils.getDefaultViewModel(),
|
||||
envVars: envVarsTabUtils.getDefaultViewModel(),
|
||||
volumes: volumesTabUtils.getDefaultViewModel(),
|
||||
|
@ -96,6 +90,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
capabilities: capabilitiesTabUtils.getDefaultViewModel(),
|
||||
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
|
||||
labels: labelsTabUtils.getDefaultViewModel(),
|
||||
...baseFormUtils.getDefaultViewModel($scope.isAdmin, userDetails.ID, nodeName),
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
|
@ -107,13 +102,16 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
containerIsLoaded: false,
|
||||
};
|
||||
|
||||
$scope.onAlwaysPullChange = onAlwaysPullChange;
|
||||
$scope.handlePublishAllPortsChange = handlePublishAllPortsChange;
|
||||
$scope.handleAutoRemoveChange = handleAutoRemoveChange;
|
||||
$scope.handlePrivilegedChange = handlePrivilegedChange;
|
||||
$scope.handleInitChange = handleInitChange;
|
||||
$scope.handleCommandsChange = handleCommandsChange;
|
||||
$scope.handleEnvVarsChange = handleEnvVarsChange;
|
||||
$scope.onChange = onChange;
|
||||
|
||||
function onChange(values) {
|
||||
$scope.formValues = {
|
||||
...$scope.formValues,
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
function handleCommandsChange(commands) {
|
||||
return $scope.$evalAsync(() => {
|
||||
|
@ -126,6 +124,16 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
$scope.formValues.envVars = value;
|
||||
});
|
||||
}
|
||||
$scope.isDuplicateValid = function () {
|
||||
if (!$scope.fromContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const duplicatingPortainer = $scope.fromContainer.IsPortainer && $scope.fromContainer.Name === '/' + $scope.config.name;
|
||||
const duplicatingWithRegistry = !!$scope.formValues.image.registryId;
|
||||
|
||||
return !duplicatingPortainer && duplicatingWithRegistry;
|
||||
};
|
||||
|
||||
$scope.onVolumesChange = function (volumes) {
|
||||
return $scope.$evalAsync(() => {
|
||||
|
@ -161,36 +169,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
});
|
||||
};
|
||||
|
||||
function onAlwaysPullChange(checked) {
|
||||
return $scope.$evalAsync(() => {
|
||||
$scope.formValues.alwaysPull = checked;
|
||||
});
|
||||
}
|
||||
|
||||
function handlePublishAllPortsChange(checked) {
|
||||
return $scope.$evalAsync(() => {
|
||||
$scope.config.HostConfig.PublishAllPorts = checked;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAutoRemoveChange(checked) {
|
||||
return $scope.$evalAsync(() => {
|
||||
$scope.config.HostConfig.AutoRemove = checked;
|
||||
});
|
||||
}
|
||||
|
||||
function handlePrivilegedChange(checked) {
|
||||
return $scope.$evalAsync(() => {
|
||||
$scope.config.HostConfig.Privileged = checked;
|
||||
});
|
||||
}
|
||||
|
||||
function handleInitChange(checked) {
|
||||
return $scope.$evalAsync(() => {
|
||||
$scope.config.HostConfig.Init = checked;
|
||||
});
|
||||
}
|
||||
|
||||
$scope.refreshSlider = function () {
|
||||
$timeout(function () {
|
||||
$scope.$broadcast('rzSliderForceRender');
|
||||
|
@ -248,59 +226,13 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
Labels: {},
|
||||
};
|
||||
|
||||
$scope.addPortBinding = function () {
|
||||
$scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
|
||||
};
|
||||
async function prepareImageConfig() {
|
||||
const registryModel = await getRegistryModel();
|
||||
|
||||
$scope.removePortBinding = function (index) {
|
||||
$scope.config.HostConfig.PortBindings.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addExtraHost = function () {
|
||||
$scope.formValues.ExtraHosts.push({ value: '' });
|
||||
};
|
||||
|
||||
$scope.removeExtraHost = function (index) {
|
||||
$scope.formValues.ExtraHosts.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addDevice = function () {
|
||||
$scope.config.HostConfig.Devices.push({ pathOnHost: '', pathInContainer: '' });
|
||||
};
|
||||
|
||||
$scope.removeDevice = function (index) {
|
||||
$scope.config.HostConfig.Devices.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.onGpuChange = function (values) {
|
||||
return $async(async () => {
|
||||
$scope.formValues.GPU = values;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.addSysctl = function () {
|
||||
$scope.formValues.Sysctls.push({ name: '', value: '' });
|
||||
};
|
||||
|
||||
$scope.removeSysctl = function (index) {
|
||||
$scope.formValues.Sysctls.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.fromContainerMultipleNetworks = false;
|
||||
|
||||
function prepareImageConfig(config) {
|
||||
const imageConfig = ImageHelper.createImageConfigForContainer($scope.formValues.RegistryModel);
|
||||
config.Image = imageConfig.fromImage;
|
||||
return buildImageFullURI(registryModel);
|
||||
}
|
||||
|
||||
function preparePortBindings(config) {
|
||||
const bindings = ContainerHelper.preparePortBindings(config.HostConfig.PortBindings);
|
||||
config.ExposedPorts = {};
|
||||
_.forEach(bindings, (_, key) => (config.ExposedPorts[key] = {}));
|
||||
config.HostConfig.PortBindings = bindings;
|
||||
}
|
||||
|
||||
function prepareConfiguration() {
|
||||
async function prepareConfiguration() {
|
||||
var config = angular.copy($scope.config);
|
||||
config = commandsTabUtils.toRequest(config, $scope.formValues.commands);
|
||||
config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars);
|
||||
|
@ -310,41 +242,33 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
config = capabilitiesTabUtils.toRequest(config, $scope.formValues.capabilities);
|
||||
config = restartPolicyTabUtils.toRequest(config, $scope.formValues.restartPolicy);
|
||||
config = labelsTabUtils.toRequest(config, $scope.formValues.labels);
|
||||
config = baseFormUtils.toRequest(config, $scope.formValues);
|
||||
|
||||
prepareImageConfig(config);
|
||||
preparePortBindings(config);
|
||||
config.name = $scope.formValues.name;
|
||||
|
||||
config.Image = await prepareImageConfig(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadFromContainerPortBindings() {
|
||||
const bindings = ContainerHelper.sortAndCombinePorts($scope.config.HostConfig.PortBindings);
|
||||
$scope.config.HostConfig.PortBindings = bindings;
|
||||
}
|
||||
async function loadFromContainerWebhook(d) {
|
||||
return $async(async () => {
|
||||
if (!isBE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function loadFromContainerImageConfig() {
|
||||
RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id)
|
||||
.then((model) => {
|
||||
$scope.formValues.RegistryModel = model;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve registry');
|
||||
});
|
||||
const data = await WebhookService.webhooks(d.Id, endpoint.Id);
|
||||
if (data.webhooks.length > 0) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadFromContainerSpec() {
|
||||
// Get container
|
||||
Container.get({ id: $transition$.params().from })
|
||||
.$promise.then(function success(d) {
|
||||
.$promise.then(async function success(d) {
|
||||
var fromContainer = new ContainerDetailsViewModel(d);
|
||||
if (fromContainer.ResourceControl) {
|
||||
if (fromContainer.ResourceControl.Public) {
|
||||
$scope.formValues.AccessControlData.AccessControlEnabled = false;
|
||||
}
|
||||
|
||||
// When the container is create by duplicate/edit, the access permission
|
||||
// shouldn't be copied
|
||||
fromContainer.ResourceControl.UserAccesses = [];
|
||||
fromContainer.ResourceControl.TeamAccesses = [];
|
||||
}
|
||||
|
||||
$scope.fromContainer = fromContainer;
|
||||
$scope.state.mode = 'duplicate';
|
||||
|
@ -357,11 +281,22 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
$scope.formValues.resources = resourcesTabUtils.toViewModel(d);
|
||||
$scope.formValues.capabilities = capabilitiesTabUtils.toViewModel(d);
|
||||
$scope.formValues.labels = labelsTabUtils.toViewModel(d);
|
||||
|
||||
$scope.formValues.restartPolicy = restartPolicyTabUtils.toViewModel(d);
|
||||
|
||||
loadFromContainerPortBindings(d);
|
||||
loadFromContainerImageConfig(d);
|
||||
const imageModel = await RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id);
|
||||
const enableWebhook = await loadFromContainerWebhook(d);
|
||||
|
||||
$scope.formValues = baseFormUtils.toViewModel(
|
||||
d,
|
||||
$scope.isAdmin,
|
||||
userDetails.ID,
|
||||
{
|
||||
image: imageModel.Image,
|
||||
useRegistry: imageModel.UseRegistry,
|
||||
registryId: imageModel.Registry.Id,
|
||||
},
|
||||
enableWebhook
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
$scope.state.containerIsLoaded = true;
|
||||
|
@ -372,11 +307,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
}
|
||||
|
||||
async function initView() {
|
||||
var nodeName = $transition$.params().nodeName;
|
||||
$scope.formValues.NodeName = nodeName;
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
|
||||
|
||||
$scope.isAdmin = Authentication.isAdmin();
|
||||
$scope.showDeviceMapping = await shouldShowDevices();
|
||||
$scope.allowSysctl = await shouldShowSysctls();
|
||||
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
|
||||
|
@ -433,40 +365,9 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
$scope.allowPrivilegedMode = endpoint.SecuritySettings.allowPrivilegedModeForRegularUsers;
|
||||
}
|
||||
|
||||
function validateForm(accessControlData, isAdmin) {
|
||||
$scope.state.formValidationError = '';
|
||||
var error = '';
|
||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
$scope.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$scope.handleResourceChange = handleResourceChange;
|
||||
function handleResourceChange() {
|
||||
$scope.state.settingUnlimitedResources = false;
|
||||
if (
|
||||
($scope.config.HostConfig.Memory > 0 && $scope.formValues.MemoryLimit === 0) ||
|
||||
($scope.config.HostConfig.MemoryReservation > 0 && $scope.formValues.MemoryReservation === 0) ||
|
||||
($scope.config.HostConfig.NanoCpus > 0 && $scope.formValues.CpuLimit === 0)
|
||||
) {
|
||||
$scope.state.settingUnlimitedResources = true;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.redeployUnlimitedResources = function (resources) {
|
||||
return $async(async () => {
|
||||
$scope.formValues.resources = resources;
|
||||
return create();
|
||||
});
|
||||
};
|
||||
|
||||
function create() {
|
||||
var oldContainer = null;
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader($scope.formValues.NodeName);
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader($scope.formValues.nodeName);
|
||||
return findCurrentContainer().then(setOldContainer).then(confirmCreateContainer).then(startCreationProcess).catch(notifyOnError).finally(final);
|
||||
|
||||
function final() {
|
||||
|
@ -479,7 +380,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
}
|
||||
|
||||
function findCurrentContainer() {
|
||||
return Container.query({ all: 1, filters: { name: ['^/' + $scope.config.name + '$'] } })
|
||||
return Container.query({ all: 1, filters: { name: ['^/' + $scope.formValues.name + '$'] } })
|
||||
.$promise.then(function onQuerySuccess(containers) {
|
||||
if (!containers.length) {
|
||||
return;
|
||||
|
@ -497,9 +398,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
if (!confirmed) {
|
||||
return $q.when();
|
||||
}
|
||||
if (!validateAccessControl()) {
|
||||
return $q.when();
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
return pullImageIfNeeded()
|
||||
.then(stopAndRenameContainer)
|
||||
|
@ -507,8 +406,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
.then(applyResourceControl)
|
||||
.then(connectToExtraNetworks)
|
||||
.then(removeOldContainer)
|
||||
.then(onSuccess)
|
||||
.catch(onCreationProcessFail);
|
||||
.then(onSuccess, onCreationProcessFail);
|
||||
}
|
||||
|
||||
function onCreationProcessFail(error) {
|
||||
|
@ -579,13 +477,18 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
return ContainerService.renameContainer(oldContainer.Id, oldContainer.Names[0] + '-old');
|
||||
}
|
||||
|
||||
function pullImageIfNeeded() {
|
||||
return $q.when($scope.formValues.alwaysPull && ImageService.pullImage($scope.formValues.RegistryModel, true));
|
||||
async function pullImageIfNeeded() {
|
||||
if (!$scope.formValues.alwaysPull) {
|
||||
return;
|
||||
}
|
||||
const registryModel = await getRegistryModel();
|
||||
return ImageService.pullImage(registryModel, true);
|
||||
}
|
||||
|
||||
function createNewContainer() {
|
||||
return $async(async () => {
|
||||
const config = prepareConfiguration();
|
||||
const config = await prepareConfiguration();
|
||||
|
||||
return await ContainerService.createAndStartContainer(config);
|
||||
});
|
||||
}
|
||||
|
@ -593,7 +496,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
async function sendAnalytics() {
|
||||
const publicSettings = await SettingsService.publicSettings();
|
||||
const analyticsAllowed = publicSettings.EnableTelemetry;
|
||||
const image = `${$scope.formValues.RegistryModel.Registry.URL}/${$scope.formValues.RegistryModel.Image}`;
|
||||
const registryModel = await getRegistryModel();
|
||||
const image = `${registryModel.Registry.URL}/${registryModel.Image}`;
|
||||
if (analyticsAllowed && $scope.formValues.GPU.enabled) {
|
||||
$analytics.eventTrack('gpuContainerCreated', {
|
||||
category: 'docker',
|
||||
|
@ -606,7 +510,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
const userId = Authentication.getUserDetails().ID;
|
||||
const resourceControl = newContainer.Portainer.ResourceControl;
|
||||
const containerId = newContainer.Id;
|
||||
const accessControlData = $scope.formValues.AccessControlData;
|
||||
const accessControlData = $scope.formValues.accessControl;
|
||||
|
||||
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl).then(function onApplyResourceControlSuccess() {
|
||||
return containerId;
|
||||
|
@ -614,11 +518,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
}
|
||||
|
||||
function connectToExtraNetworks(newContainerId) {
|
||||
if (!$scope.formValues.network.extraNetworks) {
|
||||
if (!$scope.extraNetworks) {
|
||||
return $q.when();
|
||||
}
|
||||
|
||||
var connectionPromises = _.forOwn($scope.formValues.network.extraNetworks, function (network, networkName) {
|
||||
var connectionPromises = _.forOwn($scope.extraNetworks, function (network, networkName) {
|
||||
if (_.has(network, 'Aliases')) {
|
||||
var aliases = _.filter(network.Aliases, (o) => {
|
||||
return !_.startsWith($scope.fromContainer.Id, o);
|
||||
|
@ -656,11 +560,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
Notifications.error('Failure', err, 'Unable to create container');
|
||||
}
|
||||
|
||||
function validateAccessControl() {
|
||||
var accessControlData = $scope.formValues.AccessControlData;
|
||||
return validateForm(accessControlData, $scope.isAdmin);
|
||||
}
|
||||
|
||||
async function onSuccess() {
|
||||
await sendAnalytics();
|
||||
Notifications.success('Success', 'Container successfully created');
|
||||
|
@ -680,6 +579,20 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
|||
return endpoint.SecuritySettings.allowContainerCapabilitiesForRegularUsers || Authentication.isAdmin();
|
||||
}
|
||||
|
||||
async function getRegistryModel() {
|
||||
const image = $scope.formValues.image;
|
||||
const registries = await EndpointService.registries(endpoint.Id);
|
||||
return {
|
||||
Image: image.image,
|
||||
UseRegistry: image.useRegistry,
|
||||
Registry: registries.find((registry) => registry.Id === image.registryId) || {
|
||||
Id: 0,
|
||||
Name: 'Docker Hub',
|
||||
Type: RegistryTypes.ANONYMOUS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -14,172 +14,13 @@
|
|||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" autocomplete="off">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<div class="col-sm-12 form-section-title"> Image configuration </div>
|
||||
<div ng-if="!formValues.RegistryModel.Registry && fromContainer">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
<span class="small text-danger" style="margin-left: 5px">
|
||||
The Docker registry for the <code>{{ config.Image }}</code> image is not registered inside Portainer, you will not be able to create a container. Please register that
|
||||
registry first.
|
||||
</span>
|
||||
</div>
|
||||
<div ng-if="formValues.RegistryModel.Registry || !fromContainer">
|
||||
<!-- image-and-registry -->
|
||||
<por-image-registry
|
||||
model="formValues.RegistryModel"
|
||||
ng-if="formValues.RegistryModel.Registry"
|
||||
auto-complete="true"
|
||||
endpoint="endpoint"
|
||||
is-admin="isAdmin"
|
||||
check-rate-limits="formValues.alwaysPull"
|
||||
on-image-change="onImageNameChange()"
|
||||
set-validity="setPullImageValidity"
|
||||
>
|
||||
<!-- always-pull -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
name="'alwaysPull'"
|
||||
label-class="'col-sm-2'"
|
||||
checked="formValues.alwaysPull"
|
||||
disabled="!state.pullImageValidity"
|
||||
label="'Always pull the image'"
|
||||
on-change="(onAlwaysPullChange)"
|
||||
tooltip="'When enabled, Portainer will automatically try to pull the specified image before creating the container.'"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !always-pull -->
|
||||
</por-image-registry>
|
||||
<!-- !image-and-registry -->
|
||||
</div>
|
||||
|
||||
<!-- create-webhook -->
|
||||
<div ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
||||
<div class="col-sm-12 form-section-title"> Webhooks </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
feature-id="'container-webhook'"
|
||||
label-class="'col-sm-2'"
|
||||
label="'Create a container webhook'"
|
||||
tooltip="'Create a webhook (or callback URI) to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container.'"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !create-webhook -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Network ports configuration </div>
|
||||
<!-- publish-exposed-ports -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
label-class="'col-sm-2'"
|
||||
checked="config.HostConfig.PublishAllPorts"
|
||||
label="'Publish all exposed network ports to random host ports'"
|
||||
on-change="(handlePublishAllPortsChange)"
|
||||
tooltip="'When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile.'"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !publish-exposed-ports -->
|
||||
<!-- port-mapping -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Manual network port publishing
|
||||
<portainer-tooltip
|
||||
message="'When a range of ports on the host and a single port on the container is specified, Docker will randomly choose a single available port in the defined range and forward that to the container port.'"
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addPortBinding()">
|
||||
<pr-icon icon="'plus'" mode="'alt'"></pr-icon> publish a new network port
|
||||
</span>
|
||||
</div>
|
||||
<!-- port-mapping-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px">
|
||||
<div ng-repeat="portBinding in config.HostConfig.PortBindings" style="margin-top: 2px">
|
||||
<!-- host-port -->
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">host</span>
|
||||
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80, 80-88, ip:80 or ip:80-88 (optional)" />
|
||||
</div>
|
||||
<!-- !host-port -->
|
||||
<span style="margin: 0 10px 0 10px">
|
||||
<pr-icon icon="'arrow-right'"></pr-icon>
|
||||
</span>
|
||||
<!-- container-port -->
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">container</span>
|
||||
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80 or 80-88" />
|
||||
</div>
|
||||
<!-- !container-port -->
|
||||
<!-- protocol-actions -->
|
||||
<div class="input-group col-sm-3 input-group-sm">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<label class="btn btn-light" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
|
||||
<label class="btn btn-light" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
|
||||
</div>
|
||||
<button class="btn btn-light" type="button" ng-click="removePortBinding($index)">
|
||||
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
<!-- !protocol-actions -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !port-mapping-input-list -->
|
||||
</div>
|
||||
<!-- !port-mapping -->
|
||||
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
|
||||
<div class="col-sm-12 form-section-title"> Deployment </div>
|
||||
<!-- node-selection -->
|
||||
<node-selector model="formValues.NodeName" endpoint-id="endpoint.Id"> </node-selector>
|
||||
<!-- !node-selection -->
|
||||
</div>
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="fromContainer"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<!-- autoremove -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
label-class="'col-sm-2'"
|
||||
checked="config.HostConfig.AutoRemove"
|
||||
label="'Auto remove'"
|
||||
on-change="(handleAutoRemoveChange)"
|
||||
tooltip="'When enabled, Portainer will automatically remove the container when it exits. This is useful when you want to use the container only once.'"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !autoremove -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer)
|
||||
|| (fromContainer.IsPortainer && fromContainer.Name === '/' + config.name)"
|
||||
ng-click="create()"
|
||||
button-spinner="state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="state.actionInProgress">Deploy the container</span>
|
||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
<form class="form-horizontal" autocomplete="off" ng-submit="create()">
|
||||
<docker-create-container-base-form
|
||||
is-loading="state.actionInProgress"
|
||||
is-valid="isDuplicateValid"
|
||||
values="formValues"
|
||||
on-change="(onChange)"
|
||||
></docker-create-container-base-form>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
|
||||
import { useApiVersion } from './queries/useApiVersion';
|
||||
import { useAgentNodes } from './queries/useAgentNodes';
|
||||
|
||||
export function NodeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const apiVersionQuery = useApiVersion(environmentId);
|
||||
|
||||
const nodesQuery = useAgentNodes<Array<Option<string>>>(
|
||||
environmentId,
|
||||
apiVersionQuery.data || 1,
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (!value && data.length > 0) {
|
||||
onChange(data[0].value);
|
||||
}
|
||||
},
|
||||
select: (data) =>
|
||||
data.map((node) => ({ label: node.NodeName, value: node.NodeName })),
|
||||
enabled: apiVersionQuery.data !== undefined,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl label="Node" inputId="node-selector">
|
||||
<PortainerSelect
|
||||
inputId="node-selector"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={nodesQuery.data || []}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildUrl } from '../../proxy/queries/build-url';
|
||||
|
||||
export function buildAgentUrl(
|
||||
environmentId: EnvironmentId,
|
||||
apiVersion: number,
|
||||
action: string
|
||||
) {
|
||||
let url = buildUrl(environmentId, '');
|
||||
|
||||
if (apiVersion > 1) {
|
||||
url += `v${apiVersion}/`;
|
||||
}
|
||||
|
||||
url += `${action}`;
|
||||
|
||||
return url;
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import axios from 'axios';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildAgentUrl } from './build-url';
|
||||
|
||||
interface Node {
|
||||
IPAddress: string;
|
||||
NodeName: string;
|
||||
NodeRole: string;
|
||||
}
|
||||
|
||||
export function useAgentNodes<T = Array<Node>>(
|
||||
environmentId: EnvironmentId,
|
||||
apiVersion: number,
|
||||
{
|
||||
select,
|
||||
onSuccess,
|
||||
enabled,
|
||||
}: {
|
||||
select?: (data: Array<Node>) => T;
|
||||
onSuccess?: (data: T) => void;
|
||||
enabled?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(
|
||||
['environment', environmentId, 'agent', 'nodes'],
|
||||
() => getNodes(environmentId, apiVersion),
|
||||
{
|
||||
select,
|
||||
onSuccess,
|
||||
enabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getNodes(environmentId: EnvironmentId, apiVersion: number) {
|
||||
try {
|
||||
const response = await axios.get<Array<Node>>(
|
||||
buildAgentUrl(environmentId, apiVersion, 'agents')
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error, 'Unable to retrieve nodes');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, {
|
||||
isAxiosError,
|
||||
parseAxiosError,
|
||||
} from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildUrl } from '../../proxy/queries/build-url';
|
||||
|
||||
export function useApiVersion(environmentId: EnvironmentId) {
|
||||
return useQuery(['environment', environmentId, 'agent', 'ping'], () =>
|
||||
getApiVersion(environmentId)
|
||||
);
|
||||
}
|
||||
|
||||
async function getApiVersion(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { headers } = await axios.get(buildUrl(environmentId, 'ping'));
|
||||
return parseInt(headers['portainer-agent-api-version'], 10) || 1;
|
||||
} catch (error) {
|
||||
// 404 - agent is up - set version to 1
|
||||
if (isAxiosError(error) && error.response?.status === 404) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
throw parseAxiosError(error as Error, 'Unable to ping agent');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { NodeSelector } from '@/react/docker/agent/NodeSelector';
|
||||
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { ImageConfigFieldset, ImageConfigValues } from '@@/ImageConfigFieldset';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import {
|
||||
PortsMappingField,
|
||||
Values as PortMappingValue,
|
||||
} from './PortsMappingField';
|
||||
|
||||
export interface Values {
|
||||
name: string;
|
||||
enableWebhook: boolean;
|
||||
publishAllPorts: boolean;
|
||||
image: ImageConfigValues;
|
||||
alwaysPull: boolean;
|
||||
ports: PortMappingValue;
|
||||
accessControl: AccessControlFormData;
|
||||
nodeName: string;
|
||||
autoRemove: boolean;
|
||||
}
|
||||
|
||||
function useIsAgentOnSwarm() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
|
||||
const isSwarm = useIsSwarm(environmentId);
|
||||
|
||||
return (
|
||||
!!environmentQuery.data &&
|
||||
isAgentEnvironment(environmentQuery.data?.Type) &&
|
||||
isSwarm
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseForm({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
setFieldError,
|
||||
isValid,
|
||||
isLoading,
|
||||
}: {
|
||||
values: Values;
|
||||
onChange: (values: Values) => void;
|
||||
errors?: FormikErrors<Values>;
|
||||
setFieldError: (field: string, error: string) => void;
|
||||
isValid: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
const isAgentOnSwarm = useIsAgentOnSwarm();
|
||||
if (!environmentQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const environment = environmentQuery.data;
|
||||
|
||||
const canUseWebhook = environment.Type !== EnvironmentType.EdgeAgentOnDocker;
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
<FormControl label="Name" inputId="name-input" errors={errors?.name}>
|
||||
<Input
|
||||
id="name-input"
|
||||
value={values.name}
|
||||
onChange={(e) => onChange({ ...values, name: e.target.value })}
|
||||
placeholder="e.g. myContainer"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormSection title="Image Configuration">
|
||||
<ImageConfigFieldset
|
||||
values={values.image}
|
||||
setValidity={(valid) => setFieldError('image', valid || '')}
|
||||
fieldNamespace="image"
|
||||
autoComplete
|
||||
checkRateLimits={values.alwaysPull}
|
||||
errors={errors?.image}
|
||||
>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Always pull the image"
|
||||
tooltip="When enabled, Portainer will automatically try to pull the specified image before creating the container."
|
||||
checked={values.alwaysPull}
|
||||
onChange={(alwaysPull) => onChange({ ...values, alwaysPull })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ImageConfigFieldset>
|
||||
</FormSection>
|
||||
|
||||
{canUseWebhook && (
|
||||
<Authorized authorizations="PortainerWebhookCreate" adminOnlyCE>
|
||||
<FormSection title="Webhook">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Create a container webhook"
|
||||
tooltip="Create a webhook (or callback URI) to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container."
|
||||
checked={values.enableWebhook}
|
||||
onChange={(enableWebhook) =>
|
||||
onChange({ ...values, enableWebhook })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</Authorized>
|
||||
)}
|
||||
|
||||
<FormSection title="Network ports configuration">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Publish all exposed ports to random host ports"
|
||||
tooltip="When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile."
|
||||
checked={values.publishAllPorts}
|
||||
onChange={(publishAllPorts) =>
|
||||
onChange({ ...values, publishAllPorts })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PortsMappingField
|
||||
value={values.ports}
|
||||
onChange={(ports) => onChange({ ...values, ports })}
|
||||
errors={errors?.ports}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{isAgentOnSwarm && (
|
||||
<FormSection title="Deployment">
|
||||
<NodeSelector
|
||||
value={values.nodeName}
|
||||
onChange={(nodeName) => onChange({ ...values, nodeName })}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
<AccessControlForm
|
||||
onChange={(accessControl) => onChange({ ...values, accessControl })}
|
||||
errors={errors?.accessControl}
|
||||
values={values.accessControl}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Auto remove"
|
||||
tooltip="When enabled, Portainer will automatically remove the container when it exits. This is useful when you want to use the container only once."
|
||||
checked={values.autoRemove}
|
||||
onChange={(autoRemove) => onChange({ ...values, autoRemove })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
loadingText="Deployment in progress..."
|
||||
isLoading={isLoading}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Deploy the container
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
import { PortMap } from 'docker-types/generated/1.41';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { PortMapping, Protocol, Values } from './PortsMappingField';
|
||||
import { Range } from './PortsMappingField.viewModel';
|
||||
|
||||
type PortKey = `${string}/${Protocol}`;
|
||||
|
||||
export function parsePortBindingRequest(portBindings: Values): PortMap {
|
||||
const bindings: Record<
|
||||
PortKey,
|
||||
Array<{ HostIp: string; HostPort: string }>
|
||||
> = {};
|
||||
_.forEach(portBindings, (portBinding) => {
|
||||
if (!portBinding.containerPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
let { hostPort } = portBinding;
|
||||
const containerPortRange = parsePortRange(portBinding.containerPort);
|
||||
if (!isValidPortRange(containerPortRange)) {
|
||||
throw new Error(
|
||||
`Invalid port specification: ${portBinding.containerPort}`
|
||||
);
|
||||
}
|
||||
|
||||
const portInfo = extractPortInfo(portBinding);
|
||||
if (!portInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { endHostPort, endPort, hostIp, startHostPort, startPort } = portInfo;
|
||||
_.range(startPort, endPort + 1).forEach((containerPort) => {
|
||||
const bindKey: PortKey = `${containerPort}/${portBinding.protocol}`;
|
||||
if (!bindings[bindKey]) {
|
||||
bindings[bindKey] = [];
|
||||
}
|
||||
|
||||
if (startHostPort > 0) {
|
||||
hostPort = (startHostPort + containerPort - startPort).toString();
|
||||
}
|
||||
if (startPort === endPort && startHostPort !== endHostPort) {
|
||||
hostPort += `-${endHostPort.toString()}`;
|
||||
}
|
||||
|
||||
bindings[bindKey].push({ HostIp: hostIp, HostPort: hostPort });
|
||||
});
|
||||
});
|
||||
return bindings;
|
||||
}
|
||||
|
||||
function isValidPortRange(portRange: Range) {
|
||||
return portRange.start > 0 && portRange.end >= portRange.start;
|
||||
}
|
||||
|
||||
function parsePortRange(portRange: string | number): Range {
|
||||
// Make sure we have a string
|
||||
const portRangeString = portRange.toString();
|
||||
|
||||
// Split the range and convert to integers
|
||||
const stringPorts = _.split(portRangeString, '-', 2);
|
||||
const intPorts = _.map(stringPorts, parsePort);
|
||||
|
||||
return {
|
||||
start: intPorts[0],
|
||||
end: intPorts[1] || intPorts[0],
|
||||
};
|
||||
}
|
||||
|
||||
const portPattern =
|
||||
/^([1-9]|[1-5]?[0-9]{2,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/m;
|
||||
|
||||
function parsePort(port: string) {
|
||||
if (portPattern.test(port)) {
|
||||
return parseInt(port, 10);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractPortInfo(portBinding: PortMapping) {
|
||||
const containerPortRange = parsePortRange(portBinding.containerPort);
|
||||
if (!isValidPortRange(containerPortRange)) {
|
||||
throw new Error(`Invalid port specification: ${portBinding.containerPort}`);
|
||||
}
|
||||
|
||||
const startPort = containerPortRange.start;
|
||||
const endPort = containerPortRange.end;
|
||||
let hostIp = '';
|
||||
let startHostPort = 0;
|
||||
let endHostPort = 0;
|
||||
let { hostPort } = portBinding;
|
||||
if (!hostPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hostPort.includes('[')) {
|
||||
const hostAndPort = _.split(hostPort, ']:');
|
||||
|
||||
if (hostAndPort.length < 2) {
|
||||
throw new Error(
|
||||
`Invalid port specification: ${portBinding.containerPort}`
|
||||
);
|
||||
}
|
||||
|
||||
hostIp = hostAndPort[0].replace('[', '');
|
||||
[, hostPort] = hostAndPort;
|
||||
} else if (hostPort.includes(':')) {
|
||||
[hostIp, hostPort] = _.split(hostPort, ':');
|
||||
}
|
||||
|
||||
const hostPortRange = parsePortRange(hostPort);
|
||||
if (!isValidPortRange(hostPortRange)) {
|
||||
throw new Error(`Invalid port specification: ${hostPort}`);
|
||||
}
|
||||
|
||||
startHostPort = hostPortRange.start;
|
||||
endHostPort = hostPortRange.end;
|
||||
if (
|
||||
endPort !== startPort &&
|
||||
endPort - startPort !== endHostPort - startHostPort
|
||||
) {
|
||||
throw new Error(`Invalid port specification: ${hostPort}`);
|
||||
}
|
||||
|
||||
return { startPort, endPort, hostIp, startHostPort, endHostPort };
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import { ItemProps } from '@@/form-components/InputList/InputList';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
|
||||
|
||||
export type Protocol = 'tcp' | 'udp';
|
||||
|
||||
export interface PortMapping {
|
||||
hostPort: string;
|
||||
protocol: Protocol;
|
||||
containerPort: string;
|
||||
}
|
||||
|
||||
export type Values = Array<PortMapping>;
|
||||
|
||||
interface Props {
|
||||
value: Values;
|
||||
onChange?(value: Values): void;
|
||||
errors?: FormikErrors<PortMapping>[] | string | string[];
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function PortsMappingField({
|
||||
value,
|
||||
onChange = () => {},
|
||||
errors,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<InputList<PortMapping>
|
||||
label="Port mapping"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
addLabel="map additional port"
|
||||
itemBuilder={() => ({
|
||||
hostPort: '',
|
||||
containerPort: '',
|
||||
protocol: 'tcp',
|
||||
})}
|
||||
item={Item}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
tooltip="When a range of ports on the host and a single port on the container is specified, Docker will randomly choose a single available port in the defined range and forward that to the container port."
|
||||
/>
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
onChange,
|
||||
item,
|
||||
error,
|
||||
disabled,
|
||||
readOnly,
|
||||
index,
|
||||
}: ItemProps<PortMapping>) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<InputLabeled
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
value={item.hostPort}
|
||||
onChange={(e) => handleChange('hostPort', e.target.value)}
|
||||
label="host"
|
||||
placeholder="e.g. 80"
|
||||
className="w-1/2"
|
||||
id={`hostPort-${index}`}
|
||||
/>
|
||||
|
||||
<span className="mx-3">
|
||||
<Icon icon={ArrowRight} />
|
||||
</span>
|
||||
|
||||
<InputLabeled
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
value={item.containerPort}
|
||||
onChange={(e) => handleChange('containerPort', e.target.value)}
|
||||
label="container"
|
||||
placeholder="e.g. 80"
|
||||
className="w-1/2"
|
||||
id={`containerPort-${index}`}
|
||||
/>
|
||||
|
||||
<ButtonSelector<Protocol>
|
||||
onChange={(value) => handleChange('protocol', value)}
|
||||
value={item.protocol}
|
||||
options={[{ value: 'tcp' }, { value: 'udp' }]}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
{!!error && <FormError>{Object.values(error)[0]}</FormError>}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(name: keyof PortMapping, value: string) {
|
||||
onChange({ ...item, [name]: value });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { array, mixed, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { Values } from './PortsMappingField';
|
||||
|
||||
export function validationSchema(): SchemaOf<Values> {
|
||||
return array(
|
||||
object({
|
||||
hostPort: string().required('host is required'),
|
||||
containerPort: string().required('container is required'),
|
||||
protocol: mixed().oneOf(['tcp', 'udp']),
|
||||
})
|
||||
);
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
import { toViewModel } from './PortsMappingField.viewModel';
|
||||
|
||||
test('basic', () => {
|
||||
expect(
|
||||
toViewModel({
|
||||
'22/tcp': [
|
||||
{
|
||||
HostIp: '',
|
||||
HostPort: '222',
|
||||
},
|
||||
],
|
||||
'3000/tcp': [
|
||||
{
|
||||
HostIp: '',
|
||||
HostPort: '3000',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toStrictEqual([
|
||||
{
|
||||
hostPort: '222',
|
||||
containerPort: '22',
|
||||
protocol: 'tcp',
|
||||
},
|
||||
{
|
||||
hostPort: '3000',
|
||||
containerPort: '3000',
|
||||
protocol: 'tcp',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('simple combine ports', () => {
|
||||
expect(
|
||||
toViewModel({
|
||||
'81/tcp': [
|
||||
{
|
||||
HostIp: '',
|
||||
HostPort: '81',
|
||||
},
|
||||
],
|
||||
'82/tcp': [
|
||||
{
|
||||
HostIp: '',
|
||||
HostPort: '82',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toStrictEqual([
|
||||
{
|
||||
hostPort: '81-82',
|
||||
containerPort: '81-82',
|
||||
protocol: 'tcp',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('combine and sort', () => {
|
||||
expect(
|
||||
toViewModel({
|
||||
'3244/tcp': [
|
||||
{
|
||||
HostIp: '',
|
||||
HostPort: '105',
|
||||
},
|
||||
],
|
||||
'3245/tcp': [
|
||||
{
|
||||
HostIp: '',
|
||||
HostPort: '106',
|
||||
},
|
||||
],
|
||||
'81/tcp': [
|
||||
{
|
||||
HostIp: '',
|
||||
HostPort: '81',
|
||||
},
|
||||
],
|
||||
'82/tcp': [
|
||||
{
|
||||
HostIp: '',
|
||||
HostPort: '82',
|
||||
},
|
||||
],
|
||||
'83/tcp': [
|
||||
{
|
||||
HostIp: '0.0.0.0',
|
||||
HostPort: '0',
|
||||
},
|
||||
],
|
||||
'84/tcp': [
|
||||
{
|
||||
HostIp: '0.0.0.0',
|
||||
HostPort: '0',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toStrictEqual([
|
||||
{
|
||||
hostPort: '81-82',
|
||||
containerPort: '81-82',
|
||||
protocol: 'tcp',
|
||||
},
|
||||
{
|
||||
hostPort: '',
|
||||
containerPort: '83',
|
||||
protocol: 'tcp',
|
||||
},
|
||||
{
|
||||
hostPort: '',
|
||||
containerPort: '84',
|
||||
protocol: 'tcp',
|
||||
},
|
||||
{
|
||||
hostPort: '105-106',
|
||||
containerPort: '3244-3245',
|
||||
protocol: 'tcp',
|
||||
},
|
||||
]);
|
||||
});
|
|
@ -0,0 +1,117 @@
|
|||
import { PortMap } from 'docker-types/generated/1.41';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Protocol, Values } from './PortsMappingField';
|
||||
|
||||
export type Range = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export function toViewModel(portBindings: PortMap): Values {
|
||||
const parsedPorts = parsePorts(portBindings);
|
||||
const sortedPorts = sortPorts(parsedPorts);
|
||||
|
||||
return combinePorts(sortedPorts);
|
||||
|
||||
function isProtocol(value: string): value is Protocol {
|
||||
return value === 'tcp' || value === 'udp';
|
||||
}
|
||||
|
||||
function parsePorts(portBindings: PortMap): Array<{
|
||||
hostPort: number;
|
||||
protocol: Protocol;
|
||||
containerPort: number;
|
||||
}> {
|
||||
return Object.entries(portBindings).flatMap(([key, bindings]) => {
|
||||
const [containerPort, protocol] = key.split('/');
|
||||
|
||||
if (!isProtocol(protocol)) {
|
||||
throw new Error(`Invalid protocol: ${protocol}`);
|
||||
}
|
||||
|
||||
if (!bindings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return bindings.map((binding) => ({
|
||||
hostPort: parseInt(binding.HostPort || '0', 10),
|
||||
protocol,
|
||||
containerPort: parseInt(containerPort, 10),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function sortPorts(
|
||||
ports: Array<{
|
||||
hostPort: number;
|
||||
protocol: Protocol;
|
||||
containerPort: number;
|
||||
}>
|
||||
) {
|
||||
return _.sortBy(ports, ['containerPort', 'hostPort', 'protocol']);
|
||||
}
|
||||
|
||||
function combinePorts(
|
||||
ports: Array<{
|
||||
hostPort: number;
|
||||
protocol: Protocol;
|
||||
containerPort: number;
|
||||
}>
|
||||
) {
|
||||
return ports
|
||||
.reduce(
|
||||
(acc, port) => {
|
||||
const lastPort = acc[acc.length - 1];
|
||||
|
||||
if (
|
||||
lastPort &&
|
||||
lastPort.containerPort.end === port.containerPort - 1 &&
|
||||
lastPort.hostPort.end === port.hostPort - 1 &&
|
||||
lastPort.protocol === port.protocol
|
||||
) {
|
||||
lastPort.containerPort.end = port.containerPort;
|
||||
lastPort.hostPort.end = port.hostPort;
|
||||
return acc;
|
||||
}
|
||||
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
hostPort: {
|
||||
start: port.hostPort,
|
||||
end: port.hostPort,
|
||||
},
|
||||
containerPort: {
|
||||
start: port.containerPort,
|
||||
end: port.containerPort,
|
||||
},
|
||||
protocol: port.protocol,
|
||||
},
|
||||
];
|
||||
},
|
||||
[] as Array<{
|
||||
hostPort: Range;
|
||||
containerPort: Range;
|
||||
protocol: Protocol;
|
||||
}>
|
||||
)
|
||||
.map(({ protocol, containerPort, hostPort }) => ({
|
||||
hostPort: getRange(hostPort.start, hostPort.end),
|
||||
containerPort: getRange(containerPort.start, containerPort.end),
|
||||
protocol,
|
||||
}));
|
||||
|
||||
function getRange(start: number, end: number): string {
|
||||
if (start === end) {
|
||||
if (start === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return start.toString();
|
||||
}
|
||||
|
||||
return `${start}-${end}`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { getDefaultViewModel, toViewModel } from './toViewModel';
|
||||
import { toRequest } from './toRequest';
|
||||
import { validation } from './validation';
|
||||
|
||||
export { BaseForm, type Values as BaseFormValues } from './BaseForm';
|
||||
|
||||
export const baseFormUtils = {
|
||||
toRequest,
|
||||
toViewModel,
|
||||
validation,
|
||||
getDefaultViewModel,
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
import { CreateContainerRequest } from '../types';
|
||||
|
||||
import { Values } from './BaseForm';
|
||||
import { parsePortBindingRequest } from './PortsMappingField.requestModel';
|
||||
|
||||
export function toRequest(
|
||||
oldConfig: CreateContainerRequest,
|
||||
values: Values
|
||||
): CreateContainerRequest {
|
||||
const bindings = parsePortBindingRequest(values.ports);
|
||||
|
||||
return {
|
||||
...oldConfig,
|
||||
ExposedPorts: Object.fromEntries(
|
||||
Object.keys(bindings).map((key) => [key, {}])
|
||||
),
|
||||
HostConfig: {
|
||||
...oldConfig.HostConfig,
|
||||
PublishAllPorts: values.publishAllPorts,
|
||||
PortBindings: bindings,
|
||||
AutoRemove: values.autoRemove,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { getDefaultImageConfig } from '@/react/portainer/registries/utils/getImageConfig';
|
||||
|
||||
import { ContainerResponse } from '../../queries/container';
|
||||
|
||||
import { toViewModel as toPortsMappingViewModel } from './PortsMappingField.viewModel';
|
||||
import { Values } from './BaseForm';
|
||||
|
||||
export function toViewModel(
|
||||
config: ContainerResponse,
|
||||
isAdmin: boolean,
|
||||
currentUserId: UserId,
|
||||
nodeName: string,
|
||||
image: Values['image'],
|
||||
enableWebhook: boolean
|
||||
): Values {
|
||||
// accessControl shouldn't be copied to new container
|
||||
|
||||
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
|
||||
|
||||
if (config.Portainer?.ResourceControl?.Public) {
|
||||
accessControl.ownership = ResourceControlOwnership.PUBLIC;
|
||||
}
|
||||
|
||||
return {
|
||||
accessControl,
|
||||
name: config.Name ? config.Name.replace('/', '') : '',
|
||||
alwaysPull: true,
|
||||
autoRemove: config.HostConfig?.AutoRemove || false,
|
||||
ports: toPortsMappingViewModel(config.HostConfig?.PortBindings || {}),
|
||||
publishAllPorts: config.HostConfig?.PublishAllPorts || false,
|
||||
nodeName,
|
||||
image,
|
||||
enableWebhook,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultViewModel(
|
||||
isAdmin: boolean,
|
||||
currentUserId: UserId,
|
||||
nodeName: string
|
||||
): Values {
|
||||
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
|
||||
|
||||
return {
|
||||
nodeName,
|
||||
enableWebhook: false,
|
||||
image: getDefaultImageConfig(),
|
||||
accessControl,
|
||||
name: '',
|
||||
alwaysPull: true,
|
||||
autoRemove: false,
|
||||
ports: [],
|
||||
publishAllPorts: false,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { boolean, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { validationSchema as accessControlSchema } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation';
|
||||
|
||||
import { imageConfigValidation } from '@@/ImageConfigFieldset';
|
||||
|
||||
import { Values } from './BaseForm';
|
||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||
|
||||
export function validation(
|
||||
{
|
||||
isAdmin,
|
||||
isDuplicating,
|
||||
isDuplicatingPortainer,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
isDuplicating: boolean | undefined;
|
||||
isDuplicatingPortainer: boolean | undefined;
|
||||
} = { isAdmin: false, isDuplicating: false, isDuplicatingPortainer: false }
|
||||
): SchemaOf<Values> {
|
||||
return object({
|
||||
name: string()
|
||||
.default('')
|
||||
.test('not-duplicate-portainer', () => !isDuplicatingPortainer),
|
||||
alwaysPull: boolean().default(true),
|
||||
accessControl: accessControlSchema(isAdmin),
|
||||
autoRemove: boolean().default(false),
|
||||
enableWebhook: boolean().default(false),
|
||||
nodeName: string().default(''),
|
||||
ports: portsSchema(),
|
||||
publishAllPorts: boolean().default(false),
|
||||
image: imageConfigValidation().test(
|
||||
'duplicate-must-have-registry',
|
||||
'Duplicate is only possible when registry is selected',
|
||||
(value) => !isDuplicating || typeof value.registryId !== 'undefined'
|
||||
),
|
||||
});
|
||||
}
|
|
@ -1,15 +1,17 @@
|
|||
import { object, string, array, number } from 'yup';
|
||||
import { object, mixed, array, number, SchemaOf } from 'yup';
|
||||
|
||||
import { ResourceControlOwnership } from '../types';
|
||||
import { AccessControlFormData, ResourceControlOwnership } from '../types';
|
||||
|
||||
export function validationSchema(isAdmin: boolean) {
|
||||
export function validationSchema(
|
||||
isAdmin: boolean
|
||||
): SchemaOf<AccessControlFormData> {
|
||||
return object()
|
||||
.shape({
|
||||
ownership: string()
|
||||
ownership: mixed<ResourceControlOwnership>()
|
||||
.oneOf(Object.values(ResourceControlOwnership))
|
||||
.required(),
|
||||
authorizedUsers: array(number()),
|
||||
authorizedTeams: array(number()),
|
||||
authorizedUsers: array(number().default(0)),
|
||||
authorizedTeams: array(number().default(0)),
|
||||
})
|
||||
.test(
|
||||
'user-and-team',
|
||||
|
|
|
@ -71,7 +71,6 @@ export interface Registry {
|
|||
Username: string;
|
||||
Password: string;
|
||||
RegistryAccesses: RegistryAccesses;
|
||||
Checked: boolean;
|
||||
Gitlab: Gitlab;
|
||||
Quay: Quay;
|
||||
Github: Github;
|
||||
|
|
|
@ -17,7 +17,6 @@ function buildTestRegistry(
|
|||
Authentication: false,
|
||||
Password: '',
|
||||
BaseURL: '',
|
||||
Checked: false,
|
||||
Ecr: { Region: '' },
|
||||
Github: { OrganisationName: '', UseOrganisation: false },
|
||||
Quay: { OrganisationName: '', UseOrganisation: false },
|
||||
|
|
Loading…
Reference in New Issue