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,
|
LabelsTab,
|
||||||
labelsTabUtils,
|
labelsTabUtils,
|
||||||
} from '@/react/docker/containers/CreateView/LabelsTab';
|
} from '@/react/docker/containers/CreateView/LabelsTab';
|
||||||
|
import {
|
||||||
|
BaseForm,
|
||||||
|
baseFormUtils,
|
||||||
|
} from '@/react/docker/containers/CreateView/BaseForm';
|
||||||
|
|
||||||
const ngModule = angular
|
const ngModule = angular
|
||||||
.module('portainer.docker.react.components.containers', [])
|
.module('portainer.docker.react.components.containers', [])
|
||||||
|
@ -126,3 +130,11 @@ withFormValidation(
|
||||||
[],
|
[],
|
||||||
labelsTabUtils.validation
|
labelsTabUtils.validation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
withFormValidation(
|
||||||
|
ngModule,
|
||||||
|
withUIRouter(withReactQuery(withCurrentUser(BaseForm))),
|
||||||
|
'dockerCreateContainerBaseForm',
|
||||||
|
['isValid', 'isLoading', 'setFieldError'],
|
||||||
|
baseFormUtils.validation
|
||||||
|
);
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
|
||||||
|
|
||||||
import { confirmDestructive } from '@@/modals/confirm';
|
import { confirmDestructive } from '@@/modals/confirm';
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||||
import { buildConfirmButton } from '@@/modals/utils';
|
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 { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
|
||||||
import { networkTabUtils } from '@/react/docker/containers/CreateView/NetworkTab';
|
import { networkTabUtils } from '@/react/docker/containers/CreateView/NetworkTab';
|
||||||
import { capabilitiesTabUtils } from '@/react/docker/containers/CreateView/CapabilitiesTab';
|
import { capabilitiesTabUtils } from '@/react/docker/containers/CreateView/CapabilitiesTab';
|
||||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
|
||||||
import { ContainerDetailsViewModel } from '@/docker/models/container';
|
import { ContainerDetailsViewModel } from '@/docker/models/container';
|
||||||
import { labelsTabUtils } from '@/react/docker/containers/CreateView/LabelsTab';
|
import { labelsTabUtils } from '@/react/docker/containers/CreateView/LabelsTab';
|
||||||
|
|
||||||
import './createcontainer.css';
|
|
||||||
import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
|
import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
|
||||||
import { getContainers } from '@/react/docker/containers/queries/containers';
|
import { getContainers } from '@/react/docker/containers/queries/containers';
|
||||||
import { resourcesTabUtils } from '@/react/docker/containers/CreateView/ResourcesTab';
|
import { resourcesTabUtils } from '@/react/docker/containers/CreateView/ResourcesTab';
|
||||||
import { restartPolicyTabUtils } from '@/react/docker/containers/CreateView/RestartPolicyTab';
|
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', [
|
angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
'$q',
|
'$q',
|
||||||
|
@ -43,6 +45,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
'SettingsService',
|
'SettingsService',
|
||||||
'HttpRequestHelper',
|
'HttpRequestHelper',
|
||||||
'endpoint',
|
'endpoint',
|
||||||
|
'EndpointService',
|
||||||
|
'WebhookService',
|
||||||
function (
|
function (
|
||||||
$q,
|
$q,
|
||||||
$scope,
|
$scope,
|
||||||
|
@ -65,29 +69,19 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
SystemService,
|
SystemService,
|
||||||
SettingsService,
|
SettingsService,
|
||||||
HttpRequestHelper,
|
HttpRequestHelper,
|
||||||
endpoint
|
endpoint,
|
||||||
|
EndpointService,
|
||||||
|
WebhookService
|
||||||
) {
|
) {
|
||||||
|
const nodeName = $transition$.params().nodeName;
|
||||||
|
|
||||||
$scope.create = create;
|
$scope.create = create;
|
||||||
$scope.endpoint = endpoint;
|
$scope.endpoint = endpoint;
|
||||||
$scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK;
|
$scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK;
|
||||||
$scope.formValues = {
|
$scope.isAdmin = Authentication.isAdmin();
|
||||||
alwaysPull: true,
|
const userDetails = this.Authentication.getUserDetails();
|
||||||
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.formValues = {
|
||||||
commands: commandsTabUtils.getDefaultViewModel(),
|
commands: commandsTabUtils.getDefaultViewModel(),
|
||||||
envVars: envVarsTabUtils.getDefaultViewModel(),
|
envVars: envVarsTabUtils.getDefaultViewModel(),
|
||||||
volumes: volumesTabUtils.getDefaultViewModel(),
|
volumes: volumesTabUtils.getDefaultViewModel(),
|
||||||
|
@ -96,6 +90,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
capabilities: capabilitiesTabUtils.getDefaultViewModel(),
|
capabilities: capabilitiesTabUtils.getDefaultViewModel(),
|
||||||
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
|
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
|
||||||
labels: labelsTabUtils.getDefaultViewModel(),
|
labels: labelsTabUtils.getDefaultViewModel(),
|
||||||
|
...baseFormUtils.getDefaultViewModel($scope.isAdmin, userDetails.ID, nodeName),
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.state = {
|
$scope.state = {
|
||||||
|
@ -107,13 +102,16 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
containerIsLoaded: false,
|
containerIsLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.onAlwaysPullChange = onAlwaysPullChange;
|
|
||||||
$scope.handlePublishAllPortsChange = handlePublishAllPortsChange;
|
|
||||||
$scope.handleAutoRemoveChange = handleAutoRemoveChange;
|
|
||||||
$scope.handlePrivilegedChange = handlePrivilegedChange;
|
|
||||||
$scope.handleInitChange = handleInitChange;
|
|
||||||
$scope.handleCommandsChange = handleCommandsChange;
|
$scope.handleCommandsChange = handleCommandsChange;
|
||||||
$scope.handleEnvVarsChange = handleEnvVarsChange;
|
$scope.handleEnvVarsChange = handleEnvVarsChange;
|
||||||
|
$scope.onChange = onChange;
|
||||||
|
|
||||||
|
function onChange(values) {
|
||||||
|
$scope.formValues = {
|
||||||
|
...$scope.formValues,
|
||||||
|
...values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function handleCommandsChange(commands) {
|
function handleCommandsChange(commands) {
|
||||||
return $scope.$evalAsync(() => {
|
return $scope.$evalAsync(() => {
|
||||||
|
@ -126,6 +124,16 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
$scope.formValues.envVars = value;
|
$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) {
|
$scope.onVolumesChange = function (volumes) {
|
||||||
return $scope.$evalAsync(() => {
|
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 () {
|
$scope.refreshSlider = function () {
|
||||||
$timeout(function () {
|
$timeout(function () {
|
||||||
$scope.$broadcast('rzSliderForceRender');
|
$scope.$broadcast('rzSliderForceRender');
|
||||||
|
@ -248,59 +226,13 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
Labels: {},
|
Labels: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.addPortBinding = function () {
|
async function prepareImageConfig() {
|
||||||
$scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
|
const registryModel = await getRegistryModel();
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removePortBinding = function (index) {
|
return buildImageFullURI(registryModel);
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function preparePortBindings(config) {
|
async function prepareConfiguration() {
|
||||||
const bindings = ContainerHelper.preparePortBindings(config.HostConfig.PortBindings);
|
|
||||||
config.ExposedPorts = {};
|
|
||||||
_.forEach(bindings, (_, key) => (config.ExposedPorts[key] = {}));
|
|
||||||
config.HostConfig.PortBindings = bindings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareConfiguration() {
|
|
||||||
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);
|
||||||
|
@ -310,41 +242,33 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
config = capabilitiesTabUtils.toRequest(config, $scope.formValues.capabilities);
|
config = capabilitiesTabUtils.toRequest(config, $scope.formValues.capabilities);
|
||||||
config = restartPolicyTabUtils.toRequest(config, $scope.formValues.restartPolicy);
|
config = restartPolicyTabUtils.toRequest(config, $scope.formValues.restartPolicy);
|
||||||
config = labelsTabUtils.toRequest(config, $scope.formValues.labels);
|
config = labelsTabUtils.toRequest(config, $scope.formValues.labels);
|
||||||
|
config = baseFormUtils.toRequest(config, $scope.formValues);
|
||||||
|
|
||||||
prepareImageConfig(config);
|
config.name = $scope.formValues.name;
|
||||||
preparePortBindings(config);
|
|
||||||
|
config.Image = await prepareImageConfig(config);
|
||||||
|
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromContainerPortBindings() {
|
async function loadFromContainerWebhook(d) {
|
||||||
const bindings = ContainerHelper.sortAndCombinePorts($scope.config.HostConfig.PortBindings);
|
return $async(async () => {
|
||||||
$scope.config.HostConfig.PortBindings = bindings;
|
if (!isBE) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function loadFromContainerImageConfig() {
|
const data = await WebhookService.webhooks(d.Id, endpoint.Id);
|
||||||
RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id)
|
if (data.webhooks.length > 0) {
|
||||||
.then((model) => {
|
return true;
|
||||||
$scope.formValues.RegistryModel = model;
|
}
|
||||||
})
|
});
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve registry');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromContainerSpec() {
|
function loadFromContainerSpec() {
|
||||||
// Get container
|
// Get container
|
||||||
Container.get({ id: $transition$.params().from })
|
Container.get({ id: $transition$.params().from })
|
||||||
.$promise.then(function success(d) {
|
.$promise.then(async function success(d) {
|
||||||
var fromContainer = new ContainerDetailsViewModel(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.fromContainer = fromContainer;
|
||||||
$scope.state.mode = 'duplicate';
|
$scope.state.mode = 'duplicate';
|
||||||
|
@ -357,11 +281,22 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
$scope.formValues.resources = resourcesTabUtils.toViewModel(d);
|
$scope.formValues.resources = resourcesTabUtils.toViewModel(d);
|
||||||
$scope.formValues.capabilities = capabilitiesTabUtils.toViewModel(d);
|
$scope.formValues.capabilities = capabilitiesTabUtils.toViewModel(d);
|
||||||
$scope.formValues.labels = labelsTabUtils.toViewModel(d);
|
$scope.formValues.labels = labelsTabUtils.toViewModel(d);
|
||||||
|
|
||||||
$scope.formValues.restartPolicy = restartPolicyTabUtils.toViewModel(d);
|
$scope.formValues.restartPolicy = restartPolicyTabUtils.toViewModel(d);
|
||||||
|
|
||||||
loadFromContainerPortBindings(d);
|
const imageModel = await RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id);
|
||||||
loadFromContainerImageConfig(d);
|
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(() => {
|
.then(() => {
|
||||||
$scope.state.containerIsLoaded = true;
|
$scope.state.containerIsLoaded = true;
|
||||||
|
@ -372,11 +307,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initView() {
|
async function initView() {
|
||||||
var nodeName = $transition$.params().nodeName;
|
|
||||||
$scope.formValues.NodeName = nodeName;
|
|
||||||
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
|
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
|
||||||
|
|
||||||
$scope.isAdmin = Authentication.isAdmin();
|
|
||||||
$scope.showDeviceMapping = await shouldShowDevices();
|
$scope.showDeviceMapping = await shouldShowDevices();
|
||||||
$scope.allowSysctl = await shouldShowSysctls();
|
$scope.allowSysctl = await shouldShowSysctls();
|
||||||
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
|
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
|
||||||
|
@ -433,40 +365,9 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
$scope.allowPrivilegedMode = endpoint.SecuritySettings.allowPrivilegedModeForRegularUsers;
|
$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() {
|
function create() {
|
||||||
var oldContainer = null;
|
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);
|
return findCurrentContainer().then(setOldContainer).then(confirmCreateContainer).then(startCreationProcess).catch(notifyOnError).finally(final);
|
||||||
|
|
||||||
function final() {
|
function final() {
|
||||||
|
@ -479,7 +380,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCurrentContainer() {
|
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) {
|
.$promise.then(function onQuerySuccess(containers) {
|
||||||
if (!containers.length) {
|
if (!containers.length) {
|
||||||
return;
|
return;
|
||||||
|
@ -497,9 +398,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return $q.when();
|
return $q.when();
|
||||||
}
|
}
|
||||||
if (!validateAccessControl()) {
|
|
||||||
return $q.when();
|
|
||||||
}
|
|
||||||
$scope.state.actionInProgress = true;
|
$scope.state.actionInProgress = true;
|
||||||
return pullImageIfNeeded()
|
return pullImageIfNeeded()
|
||||||
.then(stopAndRenameContainer)
|
.then(stopAndRenameContainer)
|
||||||
|
@ -507,8 +406,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
.then(applyResourceControl)
|
.then(applyResourceControl)
|
||||||
.then(connectToExtraNetworks)
|
.then(connectToExtraNetworks)
|
||||||
.then(removeOldContainer)
|
.then(removeOldContainer)
|
||||||
.then(onSuccess)
|
.then(onSuccess, onCreationProcessFail);
|
||||||
.catch(onCreationProcessFail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreationProcessFail(error) {
|
function onCreationProcessFail(error) {
|
||||||
|
@ -579,13 +477,18 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
return ContainerService.renameContainer(oldContainer.Id, oldContainer.Names[0] + '-old');
|
return ContainerService.renameContainer(oldContainer.Id, oldContainer.Names[0] + '-old');
|
||||||
}
|
}
|
||||||
|
|
||||||
function pullImageIfNeeded() {
|
async function pullImageIfNeeded() {
|
||||||
return $q.when($scope.formValues.alwaysPull && ImageService.pullImage($scope.formValues.RegistryModel, true));
|
if (!$scope.formValues.alwaysPull) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const registryModel = await getRegistryModel();
|
||||||
|
return ImageService.pullImage(registryModel, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNewContainer() {
|
function createNewContainer() {
|
||||||
return $async(async () => {
|
return $async(async () => {
|
||||||
const config = prepareConfiguration();
|
const config = await prepareConfiguration();
|
||||||
|
|
||||||
return await ContainerService.createAndStartContainer(config);
|
return await ContainerService.createAndStartContainer(config);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -593,7 +496,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
async function sendAnalytics() {
|
async function sendAnalytics() {
|
||||||
const publicSettings = await SettingsService.publicSettings();
|
const publicSettings = await SettingsService.publicSettings();
|
||||||
const analyticsAllowed = publicSettings.EnableTelemetry;
|
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) {
|
if (analyticsAllowed && $scope.formValues.GPU.enabled) {
|
||||||
$analytics.eventTrack('gpuContainerCreated', {
|
$analytics.eventTrack('gpuContainerCreated', {
|
||||||
category: 'docker',
|
category: 'docker',
|
||||||
|
@ -606,7 +510,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
const userId = Authentication.getUserDetails().ID;
|
const userId = Authentication.getUserDetails().ID;
|
||||||
const resourceControl = newContainer.Portainer.ResourceControl;
|
const resourceControl = newContainer.Portainer.ResourceControl;
|
||||||
const containerId = newContainer.Id;
|
const containerId = newContainer.Id;
|
||||||
const accessControlData = $scope.formValues.AccessControlData;
|
const accessControlData = $scope.formValues.accessControl;
|
||||||
|
|
||||||
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl).then(function onApplyResourceControlSuccess() {
|
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl).then(function onApplyResourceControlSuccess() {
|
||||||
return containerId;
|
return containerId;
|
||||||
|
@ -614,11 +518,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectToExtraNetworks(newContainerId) {
|
function connectToExtraNetworks(newContainerId) {
|
||||||
if (!$scope.formValues.network.extraNetworks) {
|
if (!$scope.extraNetworks) {
|
||||||
return $q.when();
|
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')) {
|
if (_.has(network, 'Aliases')) {
|
||||||
var aliases = _.filter(network.Aliases, (o) => {
|
var aliases = _.filter(network.Aliases, (o) => {
|
||||||
return !_.startsWith($scope.fromContainer.Id, 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');
|
Notifications.error('Failure', err, 'Unable to create container');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAccessControl() {
|
|
||||||
var accessControlData = $scope.formValues.AccessControlData;
|
|
||||||
return validateForm(accessControlData, $scope.isAdmin);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSuccess() {
|
async function onSuccess() {
|
||||||
await sendAnalytics();
|
await sendAnalytics();
|
||||||
Notifications.success('Success', 'Container successfully created');
|
Notifications.success('Success', 'Container successfully created');
|
||||||
|
@ -680,6 +579,20 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
return endpoint.SecuritySettings.allowContainerCapabilitiesForRegularUsers || Authentication.isAdmin();
|
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();
|
initView();
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -14,172 +14,13 @@
|
||||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||||
<rd-widget>
|
<rd-widget>
|
||||||
<rd-widget-body>
|
<rd-widget-body>
|
||||||
<form class="form-horizontal" autocomplete="off">
|
<form class="form-horizontal" autocomplete="off" ng-submit="create()">
|
||||||
<!-- name-input -->
|
<docker-create-container-base-form
|
||||||
<div class="form-group">
|
is-loading="state.actionInProgress"
|
||||||
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
|
is-valid="isDuplicateValid"
|
||||||
<div class="col-sm-8">
|
values="formValues"
|
||||||
<input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer" />
|
on-change="(onChange)"
|
||||||
</div>
|
></docker-create-container-base-form>
|
||||||
</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>
|
</form>
|
||||||
</rd-widget-body>
|
</rd-widget-body>
|
||||||
</rd-widget>
|
</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()
|
return object()
|
||||||
.shape({
|
.shape({
|
||||||
ownership: string()
|
ownership: mixed<ResourceControlOwnership>()
|
||||||
.oneOf(Object.values(ResourceControlOwnership))
|
.oneOf(Object.values(ResourceControlOwnership))
|
||||||
.required(),
|
.required(),
|
||||||
authorizedUsers: array(number()),
|
authorizedUsers: array(number().default(0)),
|
||||||
authorizedTeams: array(number()),
|
authorizedTeams: array(number().default(0)),
|
||||||
})
|
})
|
||||||
.test(
|
.test(
|
||||||
'user-and-team',
|
'user-and-team',
|
||||||
|
|
|
@ -71,7 +71,6 @@ export interface Registry {
|
||||||
Username: string;
|
Username: string;
|
||||||
Password: string;
|
Password: string;
|
||||||
RegistryAccesses: RegistryAccesses;
|
RegistryAccesses: RegistryAccesses;
|
||||||
Checked: boolean;
|
|
||||||
Gitlab: Gitlab;
|
Gitlab: Gitlab;
|
||||||
Quay: Quay;
|
Quay: Quay;
|
||||||
Github: Github;
|
Github: Github;
|
||||||
|
|
|
@ -17,7 +17,6 @@ function buildTestRegistry(
|
||||||
Authentication: false,
|
Authentication: false,
|
||||||
Password: '',
|
Password: '',
|
||||||
BaseURL: '',
|
BaseURL: '',
|
||||||
Checked: false,
|
|
||||||
Ecr: { Region: '' },
|
Ecr: { Region: '' },
|
||||||
Github: { OrganisationName: '', UseOrganisation: false },
|
Github: { OrganisationName: '', UseOrganisation: false },
|
||||||
Quay: { OrganisationName: '', UseOrganisation: false },
|
Quay: { OrganisationName: '', UseOrganisation: false },
|
||||||
|
|
Loading…
Reference in New Issue