diff --git a/app/docker/react/components/containers.ts b/app/docker/react/components/containers.ts
index 257807eeb..ccf155d74 100644
--- a/app/docker/react/components/containers.ts
+++ b/app/docker/react/components/containers.ts
@@ -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
+);
diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js
index 7e088026d..047afab08 100644
--- a/app/docker/views/containers/create/createContainerController.js
+++ b/app/docker/views/containers/create/createContainerController.js
@@ -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();
},
]);
diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html
index 68b79f36c..9be9ab14d 100644
--- a/app/docker/views/containers/create/createcontainer.html
+++ b/app/docker/views/containers/create/createcontainer.html
@@ -14,172 +14,13 @@
-
diff --git a/app/react/docker/agent/NodeSelector.tsx b/app/react/docker/agent/NodeSelector.tsx
new file mode 100644
index 000000000..a781ab812
--- /dev/null
+++ b/app/react/docker/agent/NodeSelector.tsx
@@ -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
>>(
+ 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 (
+
+
+
+ );
+}
diff --git a/app/react/docker/agent/queries/build-url.ts b/app/react/docker/agent/queries/build-url.ts
new file mode 100644
index 000000000..6698d011b
--- /dev/null
+++ b/app/react/docker/agent/queries/build-url.ts
@@ -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;
+}
diff --git a/app/react/docker/agent/queries/useAgentNodes.ts b/app/react/docker/agent/queries/useAgentNodes.ts
new file mode 100644
index 000000000..8c150cdfb
--- /dev/null
+++ b/app/react/docker/agent/queries/useAgentNodes.ts
@@ -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>(
+ environmentId: EnvironmentId,
+ apiVersion: number,
+ {
+ select,
+ onSuccess,
+ enabled,
+ }: {
+ select?: (data: Array) => 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>(
+ buildAgentUrl(environmentId, apiVersion, 'agents')
+ );
+ return response.data;
+ } catch (error) {
+ throw parseAxiosError(error as Error, 'Unable to retrieve nodes');
+ }
+}
diff --git a/app/react/docker/agent/queries/useApiVersion.ts b/app/react/docker/agent/queries/useApiVersion.ts
new file mode 100644
index 000000000..1154d82f7
--- /dev/null
+++ b/app/react/docker/agent/queries/useApiVersion.ts
@@ -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');
+ }
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx b/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx
new file mode 100644
index 000000000..1e726d1b1
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx
@@ -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;
+ 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 (
+
+
+
+ onChange({ ...values, name: e.target.value })}
+ placeholder="e.g. myContainer"
+ />
+
+
+
+ setFieldError('image', valid || '')}
+ fieldNamespace="image"
+ autoComplete
+ checkRateLimits={values.alwaysPull}
+ errors={errors?.image}
+ >
+
+
+ onChange({ ...values, alwaysPull })}
+ />
+
+
+
+
+
+ {canUseWebhook && (
+
+
+
+
+
+ onChange({ ...values, enableWebhook })
+ }
+ />
+
+
+
+
+ )}
+
+
+
+
+
+ onChange({ ...values, publishAllPorts })
+ }
+ />
+
+
+
+ onChange({ ...values, ports })}
+ errors={errors?.ports}
+ />
+
+
+ {isAgentOnSwarm && (
+
+ onChange({ ...values, nodeName })}
+ />
+
+ )}
+
+ onChange({ ...values, accessControl })}
+ errors={errors?.accessControl}
+ values={values.accessControl}
+ />
+
+
+
+ onChange({ ...values, autoRemove })}
+ />
+
+
+
+
+
+
+ Deploy the container
+
+
+
+
+
+ );
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts
new file mode 100644
index 000000000..57244e2b5
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts
@@ -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 };
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx
new file mode 100644
index 000000000..c13aacf43
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx
@@ -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;
+
+interface Props {
+ value: Values;
+ onChange?(value: Values): void;
+ errors?: FormikErrors[] | string | string[];
+ disabled?: boolean;
+ readOnly?: boolean;
+}
+
+export function PortsMappingField({
+ value,
+ onChange = () => {},
+ errors,
+ disabled,
+ readOnly,
+}: Props) {
+ return (
+ <>
+
+ 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' && (
+
+ {errors}
+
+ )}
+ >
+ );
+}
+
+function Item({
+ onChange,
+ item,
+ error,
+ disabled,
+ readOnly,
+ index,
+}: ItemProps) {
+ return (
+
+
+ handleChange('hostPort', e.target.value)}
+ label="host"
+ placeholder="e.g. 80"
+ className="w-1/2"
+ id={`hostPort-${index}`}
+ />
+
+
+
+
+
+ handleChange('containerPort', e.target.value)}
+ label="container"
+ placeholder="e.g. 80"
+ className="w-1/2"
+ id={`containerPort-${index}`}
+ />
+
+
+ onChange={(value) => handleChange('protocol', value)}
+ value={item.protocol}
+ options={[{ value: 'tcp' }, { value: 'udp' }]}
+ disabled={disabled}
+ readOnly={readOnly}
+ />
+
+ {!!error &&
{Object.values(error)[0]}}
+
+ );
+
+ function handleChange(name: keyof PortMapping, value: string) {
+ onChange({ ...item, [name]: value });
+ }
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts
new file mode 100644
index 000000000..edc66fd50
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts
@@ -0,0 +1,13 @@
+import { array, mixed, object, SchemaOf, string } from 'yup';
+
+import { Values } from './PortsMappingField';
+
+export function validationSchema(): SchemaOf {
+ return array(
+ object({
+ hostPort: string().required('host is required'),
+ containerPort: string().required('container is required'),
+ protocol: mixed().oneOf(['tcp', 'udp']),
+ })
+ );
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.test.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.test.ts
new file mode 100644
index 000000000..c7833a9bd
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.test.ts
@@ -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',
+ },
+ ]);
+});
diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts
new file mode 100644
index 000000000..34b5ce662
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts
@@ -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}`;
+ }
+ }
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/index.ts b/app/react/docker/containers/CreateView/BaseForm/index.ts
new file mode 100644
index 000000000..ecdc9a491
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/index.ts
@@ -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,
+};
diff --git a/app/react/docker/containers/CreateView/BaseForm/toRequest.ts b/app/react/docker/containers/CreateView/BaseForm/toRequest.ts
new file mode 100644
index 000000000..eedda1a39
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/toRequest.ts
@@ -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,
+ },
+ };
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts b/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts
new file mode 100644
index 000000000..e915ddd62
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts
@@ -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,
+ };
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/validation.ts b/app/react/docker/containers/CreateView/BaseForm/validation.ts
new file mode 100644
index 000000000..bb40712ed
--- /dev/null
+++ b/app/react/docker/containers/CreateView/BaseForm/validation.ts
@@ -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 {
+ 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'
+ ),
+ });
+}
diff --git a/app/react/portainer/access-control/AccessControlForm/AccessControlForm.validation.ts b/app/react/portainer/access-control/AccessControlForm/AccessControlForm.validation.ts
index 174367031..d15cd22d3 100644
--- a/app/react/portainer/access-control/AccessControlForm/AccessControlForm.validation.ts
+++ b/app/react/portainer/access-control/AccessControlForm/AccessControlForm.validation.ts
@@ -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 {
return object()
.shape({
- ownership: string()
+ ownership: mixed()
.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',
diff --git a/app/react/portainer/registries/types/registry.ts b/app/react/portainer/registries/types/registry.ts
index c068c559c..13ddfc960 100644
--- a/app/react/portainer/registries/types/registry.ts
+++ b/app/react/portainer/registries/types/registry.ts
@@ -71,7 +71,6 @@ export interface Registry {
Username: string;
Password: string;
RegistryAccesses: RegistryAccesses;
- Checked: boolean;
Gitlab: Gitlab;
Quay: Quay;
Github: Github;
diff --git a/app/react/portainer/registries/utils/findRegistryMatch.test.ts b/app/react/portainer/registries/utils/findRegistryMatch.test.ts
index 992509b49..7b976df44 100644
--- a/app/react/portainer/registries/utils/findRegistryMatch.test.ts
+++ b/app/react/portainer/registries/utils/findRegistryMatch.test.ts
@@ -17,7 +17,6 @@ function buildTestRegistry(
Authentication: false,
Password: '',
BaseURL: '',
- Checked: false,
Ecr: { Region: '' },
Github: { OrganisationName: '', UseOrganisation: false },
Quay: { OrganisationName: '', UseOrganisation: false },