feat(containers): migrate base form fields to react [EE-5207]

refactor/EE-2307/EE-5207/container-base-form
Chaim Lev-Ari 2023-04-11 14:28:43 +03:00
parent 9cc2f0b582
commit 3b96877616
20 changed files with 1085 additions and 362 deletions

View File

@ -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
);

View File

@ -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();
},
]);

View File

@ -14,172 +14,13 @@
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" autocomplete="off">
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-8">
<input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer" />
</div>
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title"> Image configuration </div>
<div ng-if="!formValues.RegistryModel.Registry && fromContainer">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<span class="small text-danger" style="margin-left: 5px">
The Docker registry for the <code>{{ config.Image }}</code> image is not registered inside Portainer, you will not be able to create a container. Please register that
registry first.
</span>
</div>
<div ng-if="formValues.RegistryModel.Registry || !fromContainer">
<!-- image-and-registry -->
<por-image-registry
model="formValues.RegistryModel"
ng-if="formValues.RegistryModel.Registry"
auto-complete="true"
endpoint="endpoint"
is-admin="isAdmin"
check-rate-limits="formValues.alwaysPull"
on-image-change="onImageNameChange()"
set-validity="setPullImageValidity"
>
<!-- always-pull -->
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
name="'alwaysPull'"
label-class="'col-sm-2'"
checked="formValues.alwaysPull"
disabled="!state.pullImageValidity"
label="'Always pull the image'"
on-change="(onAlwaysPullChange)"
tooltip="'When enabled, Portainer will automatically try to pull the specified image before creating the container.'"
></por-switch-field>
</div>
</div>
<!-- !always-pull -->
</por-image-registry>
<!-- !image-and-registry -->
</div>
<!-- create-webhook -->
<div ng-if="isAdmin && applicationState.endpoint.type !== 4">
<div class="col-sm-12 form-section-title"> Webhooks </div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
feature-id="'container-webhook'"
label-class="'col-sm-2'"
label="'Create a container webhook'"
tooltip="'Create a webhook (or callback URI) to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container.'"
></por-switch-field>
</div>
</div>
</div>
<!-- !create-webhook -->
<div class="col-sm-12 form-section-title"> Network ports configuration </div>
<!-- publish-exposed-ports -->
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label-class="'col-sm-2'"
checked="config.HostConfig.PublishAllPorts"
label="'Publish all exposed network ports to random host ports'"
on-change="(handlePublishAllPortsChange)"
tooltip="'When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile.'"
></por-switch-field>
</div>
</div>
<!-- !publish-exposed-ports -->
<!-- port-mapping -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Manual network port publishing
<portainer-tooltip
message="'When a range of ports on the host and a single port on the container is specified, Docker will randomly choose a single available port in the defined range and forward that to the container port.'"
></portainer-tooltip>
</label>
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addPortBinding()">
<pr-icon icon="'plus'" mode="'alt'"></pr-icon> publish a new network port
</span>
</div>
<!-- port-mapping-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px">
<div ng-repeat="portBinding in config.HostConfig.PortBindings" style="margin-top: 2px">
<!-- host-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80, 80-88, ip:80 or ip:80-88 (optional)" />
</div>
<!-- !host-port -->
<span style="margin: 0 10px 0 10px">
<pr-icon icon="'arrow-right'"></pr-icon>
</span>
<!-- container-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80 or 80-88" />
</div>
<!-- !container-port -->
<!-- protocol-actions -->
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-light" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
<label class="btn btn-light" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
</div>
<button class="btn btn-light" type="button" ng-click="removePortBinding($index)">
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
</button>
</div>
<!-- !protocol-actions -->
</div>
</div>
<!-- !port-mapping-input-list -->
</div>
<!-- !port-mapping -->
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-sm-12 form-section-title"> Deployment </div>
<!-- node-selection -->
<node-selector model="formValues.NodeName" endpoint-id="endpoint.Id"> </node-selector>
<!-- !node-selection -->
</div>
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="fromContainer"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title"> Actions </div>
<!-- autoremove -->
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label-class="'col-sm-2'"
checked="config.HostConfig.AutoRemove"
label="'Auto remove'"
on-change="(handleAutoRemoveChange)"
tooltip="'When enabled, Portainer will automatically remove the container when it exits. This is useful when you want to use the container only once.'"
></por-switch-field>
</div>
</div>
<!-- !autoremove -->
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer)
|| (fromContainer.IsPortainer && fromContainer.Name === '/' + config.name)"
ng-click="create()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Deploy the container</span>
<span ng-show="state.actionInProgress">Deployment in progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->
<form class="form-horizontal" autocomplete="off" ng-submit="create()">
<docker-create-container-base-form
is-loading="state.actionInProgress"
is-valid="isDuplicateValid"
values="formValues"
on-change="(onChange)"
></docker-create-container-base-form>
</form>
</rd-widget-body>
</rd-widget>

View File

@ -0,0 +1,45 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { FormControl } from '@@/form-components/FormControl';
import { useApiVersion } from './queries/useApiVersion';
import { useAgentNodes } from './queries/useAgentNodes';
export function NodeSelector({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const environmentId = useEnvironmentId();
const apiVersionQuery = useApiVersion(environmentId);
const nodesQuery = useAgentNodes<Array<Option<string>>>(
environmentId,
apiVersionQuery.data || 1,
{
onSuccess(data) {
if (!value && data.length > 0) {
onChange(data[0].value);
}
},
select: (data) =>
data.map((node) => ({ label: node.NodeName, value: node.NodeName })),
enabled: apiVersionQuery.data !== undefined,
}
);
return (
<FormControl label="Node" inputId="node-selector">
<PortainerSelect
inputId="node-selector"
value={value}
onChange={onChange}
options={nodesQuery.data || []}
/>
</FormControl>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,48 @@
import axios from 'axios';
import { useQuery } from 'react-query';
import { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildAgentUrl } from './build-url';
interface Node {
IPAddress: string;
NodeName: string;
NodeRole: string;
}
export function useAgentNodes<T = Array<Node>>(
environmentId: EnvironmentId,
apiVersion: number,
{
select,
onSuccess,
enabled,
}: {
select?: (data: Array<Node>) => T;
onSuccess?: (data: T) => void;
enabled?: boolean;
} = {}
) {
return useQuery(
['environment', environmentId, 'agent', 'nodes'],
() => getNodes(environmentId, apiVersion),
{
select,
onSuccess,
enabled,
}
);
}
async function getNodes(environmentId: EnvironmentId, apiVersion: number) {
try {
const response = await axios.get<Array<Node>>(
buildAgentUrl(environmentId, apiVersion, 'agents')
);
return response.data;
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to retrieve nodes');
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,190 @@
import { FormikErrors } from 'formik';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { Authorized } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control';
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { NodeSelector } from '@/react/docker/agent/NodeSelector';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { Input } from '@@/form-components/Input';
import { SwitchField } from '@@/form-components/SwitchField';
import { ImageConfigFieldset, ImageConfigValues } from '@@/ImageConfigFieldset';
import { LoadingButton } from '@@/buttons';
import { Widget } from '@@/Widget';
import {
PortsMappingField,
Values as PortMappingValue,
} from './PortsMappingField';
export interface Values {
name: string;
enableWebhook: boolean;
publishAllPorts: boolean;
image: ImageConfigValues;
alwaysPull: boolean;
ports: PortMappingValue;
accessControl: AccessControlFormData;
nodeName: string;
autoRemove: boolean;
}
function useIsAgentOnSwarm() {
const environmentId = useEnvironmentId();
const environmentQuery = useCurrentEnvironment();
const isSwarm = useIsSwarm(environmentId);
return (
!!environmentQuery.data &&
isAgentEnvironment(environmentQuery.data?.Type) &&
isSwarm
);
}
export function BaseForm({
values,
onChange,
errors,
setFieldError,
isValid,
isLoading,
}: {
values: Values;
onChange: (values: Values) => void;
errors?: FormikErrors<Values>;
setFieldError: (field: string, error: string) => void;
isValid: boolean;
isLoading: boolean;
}) {
const environmentQuery = useCurrentEnvironment();
const isAgentOnSwarm = useIsAgentOnSwarm();
if (!environmentQuery.data) {
return null;
}
const environment = environmentQuery.data;
const canUseWebhook = environment.Type !== EnvironmentType.EdgeAgentOnDocker;
return (
<Widget>
<Widget.Body>
<FormControl label="Name" inputId="name-input" errors={errors?.name}>
<Input
id="name-input"
value={values.name}
onChange={(e) => onChange({ ...values, name: e.target.value })}
placeholder="e.g. myContainer"
/>
</FormControl>
<FormSection title="Image Configuration">
<ImageConfigFieldset
values={values.image}
setValidity={(valid) => setFieldError('image', valid || '')}
fieldNamespace="image"
autoComplete
checkRateLimits={values.alwaysPull}
errors={errors?.image}
>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Always pull the image"
tooltip="When enabled, Portainer will automatically try to pull the specified image before creating the container."
checked={values.alwaysPull}
onChange={(alwaysPull) => onChange({ ...values, alwaysPull })}
/>
</div>
</div>
</ImageConfigFieldset>
</FormSection>
{canUseWebhook && (
<Authorized authorizations="PortainerWebhookCreate" adminOnlyCE>
<FormSection title="Webhook">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Create a container webhook"
tooltip="Create a webhook (or callback URI) to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container."
checked={values.enableWebhook}
onChange={(enableWebhook) =>
onChange({ ...values, enableWebhook })
}
/>
</div>
</div>
</FormSection>
</Authorized>
)}
<FormSection title="Network ports configuration">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Publish all exposed ports to random host ports"
tooltip="When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile."
checked={values.publishAllPorts}
onChange={(publishAllPorts) =>
onChange({ ...values, publishAllPorts })
}
/>
</div>
</div>
<PortsMappingField
value={values.ports}
onChange={(ports) => onChange({ ...values, ports })}
errors={errors?.ports}
/>
</FormSection>
{isAgentOnSwarm && (
<FormSection title="Deployment">
<NodeSelector
value={values.nodeName}
onChange={(nodeName) => onChange({ ...values, nodeName })}
/>
</FormSection>
)}
<AccessControlForm
onChange={(accessControl) => onChange({ ...values, accessControl })}
errors={errors?.accessControl}
values={values.accessControl}
/>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Auto remove"
tooltip="When enabled, Portainer will automatically remove the container when it exits. This is useful when you want to use the container only once."
checked={values.autoRemove}
onChange={(autoRemove) => onChange({ ...values, autoRemove })}
/>
</div>
</div>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="Deployment in progress..."
isLoading={isLoading}
disabled={!isValid}
>
Deploy the container
</LoadingButton>
</div>
</div>
</Widget.Body>
</Widget>
);
}

View File

@ -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 };
}

View File

@ -0,0 +1,117 @@
import { FormikErrors } from 'formik';
import { ArrowRight } from 'lucide-react';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { FormError } from '@@/form-components/FormError';
import { InputList } from '@@/form-components/InputList';
import { ItemProps } from '@@/form-components/InputList/InputList';
import { Icon } from '@@/Icon';
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
export type Protocol = 'tcp' | 'udp';
export interface PortMapping {
hostPort: string;
protocol: Protocol;
containerPort: string;
}
export type Values = Array<PortMapping>;
interface Props {
value: Values;
onChange?(value: Values): void;
errors?: FormikErrors<PortMapping>[] | string | string[];
disabled?: boolean;
readOnly?: boolean;
}
export function PortsMappingField({
value,
onChange = () => {},
errors,
disabled,
readOnly,
}: Props) {
return (
<>
<InputList<PortMapping>
label="Port mapping"
value={value}
onChange={onChange}
addLabel="map additional port"
itemBuilder={() => ({
hostPort: '',
containerPort: '',
protocol: 'tcp',
})}
item={Item}
errors={errors}
disabled={disabled}
readOnly={readOnly}
tooltip="When a range of ports on the host and a single port on the container is specified, Docker will randomly choose a single available port in the defined range and forward that to the container port."
/>
{typeof errors === 'string' && (
<div className="form-group col-md-12">
<FormError>{errors}</FormError>
</div>
)}
</>
);
}
function Item({
onChange,
item,
error,
disabled,
readOnly,
index,
}: ItemProps<PortMapping>) {
return (
<div className="flex flex-col">
<div className="flex items-center gap-2">
<InputLabeled
size="small"
disabled={disabled}
readOnly={readOnly}
value={item.hostPort}
onChange={(e) => handleChange('hostPort', e.target.value)}
label="host"
placeholder="e.g. 80"
className="w-1/2"
id={`hostPort-${index}`}
/>
<span className="mx-3">
<Icon icon={ArrowRight} />
</span>
<InputLabeled
size="small"
disabled={disabled}
readOnly={readOnly}
value={item.containerPort}
onChange={(e) => handleChange('containerPort', e.target.value)}
label="container"
placeholder="e.g. 80"
className="w-1/2"
id={`containerPort-${index}`}
/>
<ButtonSelector<Protocol>
onChange={(value) => handleChange('protocol', value)}
value={item.protocol}
options={[{ value: 'tcp' }, { value: 'udp' }]}
disabled={disabled}
readOnly={readOnly}
/>
</div>
{!!error && <FormError>{Object.values(error)[0]}</FormError>}
</div>
);
function handleChange(name: keyof PortMapping, value: string) {
onChange({ ...item, [name]: value });
}
}

View File

@ -0,0 +1,13 @@
import { array, mixed, object, SchemaOf, string } from 'yup';
import { Values } from './PortsMappingField';
export function validationSchema(): SchemaOf<Values> {
return array(
object({
hostPort: string().required('host is required'),
containerPort: string().required('container is required'),
protocol: mixed().oneOf(['tcp', 'udp']),
})
);
}

View File

@ -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',
},
]);
});

View File

@ -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}`;
}
}
}

View File

@ -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,
};

View File

@ -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,
},
};
}

View File

@ -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,
};
}

View File

@ -0,0 +1,38 @@
import { boolean, object, SchemaOf, string } from 'yup';
import { validationSchema as accessControlSchema } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation';
import { imageConfigValidation } from '@@/ImageConfigFieldset';
import { Values } from './BaseForm';
import { validationSchema as portsSchema } from './PortsMappingField.validation';
export function validation(
{
isAdmin,
isDuplicating,
isDuplicatingPortainer,
}: {
isAdmin: boolean;
isDuplicating: boolean | undefined;
isDuplicatingPortainer: boolean | undefined;
} = { isAdmin: false, isDuplicating: false, isDuplicatingPortainer: false }
): SchemaOf<Values> {
return object({
name: string()
.default('')
.test('not-duplicate-portainer', () => !isDuplicatingPortainer),
alwaysPull: boolean().default(true),
accessControl: accessControlSchema(isAdmin),
autoRemove: boolean().default(false),
enableWebhook: boolean().default(false),
nodeName: string().default(''),
ports: portsSchema(),
publishAllPorts: boolean().default(false),
image: imageConfigValidation().test(
'duplicate-must-have-registry',
'Duplicate is only possible when registry is selected',
(value) => !isDuplicating || typeof value.registryId !== 'undefined'
),
});
}

View File

@ -1,15 +1,17 @@
import { object, string, array, number } from 'yup';
import { object, mixed, array, number, SchemaOf } from 'yup';
import { ResourceControlOwnership } from '../types';
import { AccessControlFormData, ResourceControlOwnership } from '../types';
export function validationSchema(isAdmin: boolean) {
export function validationSchema(
isAdmin: boolean
): SchemaOf<AccessControlFormData> {
return object()
.shape({
ownership: string()
ownership: mixed<ResourceControlOwnership>()
.oneOf(Object.values(ResourceControlOwnership))
.required(),
authorizedUsers: array(number()),
authorizedTeams: array(number()),
authorizedUsers: array(number().default(0)),
authorizedTeams: array(number().default(0)),
})
.test(
'user-and-team',

View File

@ -71,7 +71,6 @@ export interface Registry {
Username: string;
Password: string;
RegistryAccesses: RegistryAccesses;
Checked: boolean;
Gitlab: Gitlab;
Quay: Quay;
Github: Github;

View File

@ -17,7 +17,6 @@ function buildTestRegistry(
Authentication: false,
Password: '',
BaseURL: '',
Checked: false,
Ecr: { Region: '' },
Github: { OrganisationName: '', UseOrganisation: false },
Quay: { OrganisationName: '', UseOrganisation: false },