From 3b96877616c03d814a92fdf70097f0c3617dc395 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 11 Apr 2023 14:28:43 +0300 Subject: [PATCH] feat(containers): migrate base form fields to react [EE-5207] --- app/docker/react/components/containers.ts | 12 + .../create/createContainerController.js | 289 ++++++------------ .../containers/create/createcontainer.html | 173 +---------- app/react/docker/agent/NodeSelector.tsx | 45 +++ app/react/docker/agent/queries/build-url.ts | 19 ++ .../docker/agent/queries/useAgentNodes.ts | 48 +++ .../docker/agent/queries/useApiVersion.ts | 29 ++ .../CreateView/BaseForm/BaseForm.tsx | 190 ++++++++++++ .../PortsMappingField.requestModel.ts | 127 ++++++++ .../CreateView/BaseForm/PortsMappingField.tsx | 117 +++++++ .../BaseForm/PortsMappingField.validation.ts | 13 + .../PortsMappingField.viewModel.test.ts | 120 ++++++++ .../BaseForm/PortsMappingField.viewModel.ts | 117 +++++++ .../containers/CreateView/BaseForm/index.ts | 12 + .../CreateView/BaseForm/toRequest.ts | 24 ++ .../CreateView/BaseForm/toViewModel.ts | 58 ++++ .../CreateView/BaseForm/validation.ts | 38 +++ .../AccessControlForm.validation.ts | 14 +- .../portainer/registries/types/registry.ts | 1 - .../utils/findRegistryMatch.test.ts | 1 - 20 files changed, 1085 insertions(+), 362 deletions(-) create mode 100644 app/react/docker/agent/NodeSelector.tsx create mode 100644 app/react/docker/agent/queries/build-url.ts create mode 100644 app/react/docker/agent/queries/useAgentNodes.ts create mode 100644 app/react/docker/agent/queries/useApiVersion.ts create mode 100644 app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx create mode 100644 app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts create mode 100644 app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx create mode 100644 app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts create mode 100644 app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.test.ts create mode 100644 app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts create mode 100644 app/react/docker/containers/CreateView/BaseForm/index.ts create mode 100644 app/react/docker/containers/CreateView/BaseForm/toRequest.ts create mode 100644 app/react/docker/containers/CreateView/BaseForm/toViewModel.ts create mode 100644 app/react/docker/containers/CreateView/BaseForm/validation.ts 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 @@
-
- -
- -
- -
-
- -
Image configuration
-
- - - The Docker registry for the {{ config.Image }} image is not registered inside Portainer, you will not be able to create a container. Please register that - registry first. - -
-
- - - -
-
- -
-
- -
- -
- - -
-
Webhooks
-
-
- -
-
-
- - -
Network ports configuration
- -
-
- -
-
- - -
-
- - - publish a new network port - -
- -
-
- -
- host - -
- - - - - -
- container - -
- - -
-
- - -
- -
- -
-
- -
- -
-
Deployment
- - - -
- - - - -
Actions
- -
-
- -
-
- -
-
- - {{ state.formValidationError }} -
-
- + +
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 },