diff --git a/app/docker/__module.js b/app/docker/__module.js
index 5756fcdf1..291ab683b 100644
--- a/app/docker/__module.js
+++ b/app/docker/__module.js
@@ -510,11 +510,10 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
var templates = {
name: 'docker.templates',
- url: '/templates',
+ url: '/templates?template',
views: {
'content@': {
- templateUrl: '~Portainer/views/templates/templates.html',
- controller: 'TemplatesController',
+ component: 'appTemplatesView',
},
},
data: {
diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts
index 1f1ab5e2f..d1116bda3 100644
--- a/app/docker/react/components/index.ts
+++ b/app/docker/react/components/index.ts
@@ -26,6 +26,7 @@ import { servicesModule } from './services';
import { networksModule } from './networks';
import { swarmModule } from './swarm';
import { volumesModule } from './volumes';
+import { templatesModule } from './templates';
const ngModule = angular
.module('portainer.docker.react.components', [
@@ -34,6 +35,7 @@ const ngModule = angular
networksModule,
swarmModule,
volumesModule,
+ templatesModule,
])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
diff --git a/app/docker/react/components/templates.ts b/app/docker/react/components/templates.ts
new file mode 100644
index 000000000..cbccc3dcb
--- /dev/null
+++ b/app/docker/react/components/templates.ts
@@ -0,0 +1,17 @@
+import angular from 'angular';
+
+import { r2a } from '@/react-tools/react2angular';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+import { StackFromCustomTemplateFormWidget } from '@/react/docker/templates/StackFromCustomTemplateFormWidget';
+
+export const templatesModule = angular
+ .module('portainer.docker.react.components.templates', [])
+
+ .component(
+ 'stackFromCustomTemplateFormWidget',
+ r2a(withUIRouter(withCurrentUser(StackFromCustomTemplateFormWidget)), [
+ 'template',
+ 'unselect',
+ ])
+ ).name;
diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js
index 1dc37359d..739f5b6e8 100644
--- a/app/docker/services/containerService.js
+++ b/app/docker/services/containerService.js
@@ -112,24 +112,6 @@ function ContainerServiceFactory($q, Container, $timeout) {
return deferred.promise;
};
- service.createAndStartContainer = function (environmentId, configuration) {
- var deferred = $q.defer();
- var container;
- service
- .createContainer(environmentId, configuration)
- .then(function success(data) {
- container = data;
- return service.startContainer(environmentId, container.Id);
- })
- .then(function success() {
- deferred.resolve(container);
- })
- .catch(function error(err) {
- deferred.reject(err);
- });
- return deferred.promise;
- };
-
service.createExec = function (environmentId, execConfig) {
var deferred = $q.defer();
diff --git a/app/docker/services/volumeService.js b/app/docker/services/volumeService.js
index a812a86cb..82773756d 100644
--- a/app/docker/services/volumeService.js
+++ b/app/docker/services/volumeService.js
@@ -92,14 +92,6 @@ angular.module('portainer.docker').factory('VolumeService', [
return $q.all(createVolumeQueries);
};
- service.createXAutoGeneratedLocalVolumes = function (x) {
- var createVolumeQueries = [];
- for (var i = 0; i < x; i++) {
- createVolumeQueries.push(service.createVolume({ Driver: 'local' }));
- }
- return $q.all(createVolumeQueries);
- };
-
return service;
},
]);
diff --git a/app/edge/__module.js b/app/edge/__module.js
index 352b912c8..bbe36170a 100644
--- a/app/edge/__module.js
+++ b/app/edge/__module.js
@@ -154,7 +154,7 @@ angular
url: '/templates?template',
views: {
'content@': {
- component: 'edgeAppTemplatesView',
+ component: 'appTemplatesView',
},
},
data: {
diff --git a/app/edge/react/views/templates.ts b/app/edge/react/views/templates.ts
index 570875483..effa45af5 100644
--- a/app/edge/react/views/templates.ts
+++ b/app/edge/react/views/templates.ts
@@ -4,25 +4,10 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/edge/templates/custom-templates/ListView';
-import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView';
-import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView';
-import { EditView } from '@/react/portainer/templates/custom-templates/EditView';
export const templatesModule = angular
- .module('portainer.app.react.components.templates', [])
- .component(
- 'edgeAppTemplatesView',
- r2a(withCurrentUser(withUIRouter(AppTemplatesView)), [])
- )
+ .module('portainer.edge.react.views.templates', [])
.component(
'edgeCustomTemplatesView',
r2a(withCurrentUser(withUIRouter(ListView)), [])
- )
- .component(
- 'createCustomTemplatesView',
- r2a(withCurrentUser(withUIRouter(CreateView)), [])
- )
- .component(
- 'editCustomTemplatesView',
- r2a(withCurrentUser(withUIRouter(EditView)), [])
).name;
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
index 75b122f6c..d80704458 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
+++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
@@ -17,7 +17,7 @@ import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/Te
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
-import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset';
+import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
export default class CreateEdgeStackViewController {
/* @ngInject */
diff --git a/app/portainer/components/forms/stack-from-template-form/index.js b/app/portainer/components/forms/stack-from-template-form/index.js
deleted file mode 100644
index dda50b2bd..000000000
--- a/app/portainer/components/forms/stack-from-template-form/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import angular from 'angular';
-
-angular.module('portainer.app').component('stackFromTemplateForm', {
- templateUrl: './stackFromTemplateForm.html',
- bindings: {
- template: '=',
- formValues: '=',
- state: '=',
- createTemplate: '<',
- unselectTemplate: '<',
- nameRegex: '<',
- },
- transclude: {
- advanced: '?advancedForm',
- },
-});
diff --git a/app/portainer/components/forms/stack-from-template-form/stackFromTemplateForm.html b/app/portainer/components/forms/stack-from-template-form/stackFromTemplateForm.html
deleted file mode 100644
index d77e358c8..000000000
--- a/app/portainer/components/forms/stack-from-template-form/stackFromTemplateForm.html
+++ /dev/null
@@ -1,95 +0,0 @@
-
diff --git a/app/portainer/helpers/templateHelper.js b/app/portainer/helpers/templateHelper.js
deleted file mode 100644
index ed5427eed..000000000
--- a/app/portainer/helpers/templateHelper.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import _ from 'lodash-es';
-
-angular.module('portainer.app').factory('TemplateHelper', [
- function TemplateHelperFactory() {
- 'use strict';
- var helper = {};
-
- helper.getDefaultContainerConfiguration = function () {
- return {
- Env: [],
- OpenStdin: false,
- Tty: false,
- ExposedPorts: {},
- HostConfig: {
- RestartPolicy: {
- Name: 'no',
- },
- PortBindings: {},
- Binds: [],
- Privileged: false,
- ExtraHosts: [],
- },
- Volumes: {},
- Labels: {},
- };
- };
-
- helper.portArrayToPortConfiguration = function (ports) {
- var portConfiguration = {
- bindings: {},
- exposedPorts: {},
- };
- ports.forEach(function (p) {
- if (p.containerPort) {
- var key = p.containerPort + '/' + p.protocol;
- var binding = {};
- if (p.hostPort) {
- binding.HostPort = p.hostPort;
- if (p.hostPort.indexOf(':') > -1) {
- var hostAndPort = p.hostPort.split(':');
- binding.HostIp = hostAndPort[0];
- binding.HostPort = hostAndPort[1];
- }
- }
- portConfiguration.bindings[key] = [binding];
- portConfiguration.exposedPorts[key] = {};
- }
- });
- return portConfiguration;
- };
-
- helper.updateContainerConfigurationWithLabels = function (labelsArray) {
- var labels = {};
- labelsArray.forEach(function (l) {
- if (l.name) {
- if (l.value) {
- labels[l.name] = l.value;
- } else {
- labels[l.name] = '';
- }
- }
- });
- return labels;
- };
-
- helper.EnvToStringArray = function (templateEnvironment) {
- var env = [];
- templateEnvironment.forEach(function (envvar) {
- if (envvar.value || envvar.set) {
- var value = envvar.set ? envvar.set : envvar.value;
- env.push(envvar.name + '=' + value);
- }
- });
- return env;
- };
-
- helper.getConsoleConfiguration = function (interactiveFlag) {
- var consoleConfiguration = {
- openStdin: false,
- tty: false,
- };
- if (interactiveFlag === true) {
- consoleConfiguration.openStdin = true;
- consoleConfiguration.tty = true;
- }
- return consoleConfiguration;
- };
-
- helper.createVolumeBindings = function (volumes, generatedVolumesPile) {
- volumes.forEach(function (volume) {
- if (volume.container) {
- var binding;
- if (volume.type === 'auto') {
- binding = generatedVolumesPile.pop().Id + ':' + volume.container;
- } else if (volume.type !== 'auto' && volume.bind) {
- binding = volume.bind + ':' + volume.container;
- }
- if (volume.readonly) {
- binding += ':ro';
- }
- volume.binding = binding;
- }
- });
- };
-
- helper.determineRequiredGeneratedVolumeCount = function (volumes) {
- var count = 0;
- volumes.forEach(function (volume) {
- if (volume.type === 'auto') {
- ++count;
- }
- });
- return count;
- };
-
- helper.getUniqueCategories = function (templates) {
- var categories = [];
- for (var i = 0; i < templates.length; i++) {
- var template = templates[i];
- categories = categories.concat(template.Categories);
- }
- return _.uniq(categories);
- };
-
- return helper;
- },
-]);
diff --git a/app/portainer/react/components/custom-templates/index.ts b/app/portainer/react/components/custom-templates/index.ts
index 790e1dd27..7474dc08e 100644
--- a/app/portainer/react/components/custom-templates/index.ts
+++ b/app/portainer/react/components/custom-templates/index.ts
@@ -13,7 +13,6 @@ import {
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
import { withFormValidation } from '@/react-tools/withFormValidation';
-import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
import { CustomTemplatesList } from '@/react/portainer/templates/custom-templates/ListView/CustomTemplatesList';
import { VariablesFieldAngular } from './variables-field';
@@ -39,18 +38,6 @@ export const ngModule = angular
'isVariablesNamesFromParent',
])
)
- .component(
- 'appTemplatesList',
- r2a(withUIRouter(withCurrentUser(AppTemplatesList)), [
- 'onSelect',
- 'templates',
- 'selectedId',
- 'disabledTypes',
- 'fixedCategories',
- 'storageKey',
- 'templateLinkParams',
- ])
- )
.component(
'customTemplatesList',
r2a(withUIRouter(withCurrentUser(CustomTemplatesList)), [
diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts
index 0fbba928e..52c994926 100644
--- a/app/portainer/react/views/index.ts
+++ b/app/portainer/react/views/index.ts
@@ -19,6 +19,7 @@ import { updateSchedulesModule } from './update-schedules';
import { environmentGroupModule } from './env-groups';
import { registriesModule } from './registries';
import { activityLogsModule } from './activity-logs';
+import { templatesModule } from './templates';
export const viewsModule = angular
.module('portainer.app.react.views', [
@@ -28,6 +29,7 @@ export const viewsModule = angular
environmentGroupModule,
registriesModule,
activityLogsModule,
+ templatesModule,
])
.component(
'homeView',
diff --git a/app/portainer/react/views/templates.ts b/app/portainer/react/views/templates.ts
new file mode 100644
index 000000000..99c7146e1
--- /dev/null
+++ b/app/portainer/react/views/templates.ts
@@ -0,0 +1,23 @@
+import angular from 'angular';
+
+import { r2a } from '@/react-tools/react2angular';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView';
+import { EditView } from '@/react/portainer/templates/custom-templates/EditView';
+import { AppTemplatesView } from '@/react/portainer/templates/app-templates/AppTemplatesView';
+
+export const templatesModule = angular
+ .module('portainer.app.react.views.templates', [])
+ .component(
+ 'appTemplatesView',
+ r2a(withCurrentUser(withUIRouter(AppTemplatesView)), [])
+ )
+ .component(
+ 'createCustomTemplatesView',
+ r2a(withCurrentUser(withUIRouter(CreateView)), [])
+ )
+ .component(
+ 'editCustomTemplatesView',
+ r2a(withCurrentUser(withUIRouter(EditView)), [])
+ ).name;
diff --git a/app/portainer/services/api/templateService.js b/app/portainer/services/api/templateService.js
index ec96917ca..96a5a792c 100644
--- a/app/portainer/services/api/templateService.js
+++ b/app/portainer/services/api/templateService.js
@@ -1,11 +1,10 @@
-import { commandStringToArray } from '@/docker/helpers/containers';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { DockerHubViewModel } from 'Portainer/models/dockerhub';
angular.module('portainer.app').factory('TemplateService', TemplateServiceFactory);
/* @ngInject */
-function TemplateServiceFactory($q, Templates, TemplateHelper, ImageHelper, ContainerHelper, EndpointService) {
+function TemplateServiceFactory($q, Templates, EndpointService) {
var service = {
templates,
};
@@ -45,43 +44,5 @@ function TemplateServiceFactory($q, Templates, TemplateHelper, ImageHelper, Cont
return Templates.file({ repositoryUrl, composeFilePathInRepository }).$promise;
}
- service.createTemplateConfiguration = function (template, containerName, network) {
- var imageConfiguration = ImageHelper.createImageConfigForContainer(template.RegistryModel);
- var containerConfiguration = createContainerConfiguration(template, containerName, network);
- containerConfiguration.Image = imageConfiguration.fromImage;
- return containerConfiguration;
- };
-
- function createContainerConfiguration(template, containerName, network) {
- var configuration = TemplateHelper.getDefaultContainerConfiguration();
- configuration.HostConfig.NetworkMode = network.Name;
- configuration.HostConfig.Privileged = template.Privileged;
- configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy };
- configuration.HostConfig.ExtraHosts = template.Hosts ? template.Hosts : [];
- configuration.name = containerName;
- configuration.Hostname = template.Hostname;
- configuration.Env = TemplateHelper.EnvToStringArray(template.Env);
- configuration.Cmd = commandStringToArray(template.Command);
- var portConfiguration = TemplateHelper.portArrayToPortConfiguration(template.Ports);
- configuration.HostConfig.PortBindings = portConfiguration.bindings;
- configuration.ExposedPorts = portConfiguration.exposedPorts;
- var consoleConfiguration = TemplateHelper.getConsoleConfiguration(template.Interactive);
- configuration.OpenStdin = consoleConfiguration.openStdin;
- configuration.Tty = consoleConfiguration.tty;
- configuration.Labels = TemplateHelper.updateContainerConfigurationWithLabels(template.Labels);
- return configuration;
- }
-
- service.updateContainerConfigurationWithVolumes = function (configuration, template, generatedVolumesPile) {
- var volumes = template.Volumes;
- TemplateHelper.createVolumeBindings(volumes, generatedVolumesPile);
- volumes.forEach(function (volume) {
- if (volume.binding) {
- configuration.Volumes[volume.container] = {};
- configuration.HostConfig.Binds.push(volume.binding);
- }
- });
- };
-
return service;
}
diff --git a/app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html b/app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html
index d0444a972..172cb4b9e 100644
--- a/app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html
+++ b/app/portainer/views/custom-templates/custom-templates-view/customTemplatesView.html
@@ -1,67 +1,10 @@
-
-
-
-
-
-
-
-
-
- Custom template could not be loaded, please
- click here for configuration.
-
- Custom template could not be loaded, please contact your administrator.
-
-
-
-
-
-
- You can get more information about Compose file format in the
- official documentation
- .
-
-
-
-
-
-
-
+
-
-
-
-
diff --git a/app/portainer/views/templates/templatesController.js b/app/portainer/views/templates/templatesController.js
deleted file mode 100644
index 28d0b2d09..000000000
--- a/app/portainer/views/templates/templatesController.js
+++ /dev/null
@@ -1,317 +0,0 @@
-import _ from 'lodash-es';
-import { TemplateType } from '@/react/portainer/templates/app-templates/types';
-import { AccessControlFormData } from '../../components/accessControlForm/porAccessControlFormModel';
-
-angular.module('portainer.app').controller('TemplatesController', [
- '$scope',
- '$q',
- '$state',
- '$anchorScroll',
- 'ContainerService',
- 'ImageService',
- 'NetworkService',
- 'TemplateService',
- 'TemplateHelper',
- 'VolumeService',
- 'Notifications',
- 'ResourceControlService',
- 'Authentication',
- 'FormValidator',
- 'StackService',
- 'endpoint',
- '$async',
- function (
- $scope,
- $q,
- $state,
- $anchorScroll,
- ContainerService,
- ImageService,
- NetworkService,
- TemplateService,
- TemplateHelper,
- VolumeService,
- Notifications,
- ResourceControlService,
- Authentication,
- FormValidator,
- StackService,
- endpoint,
- $async
- ) {
- const DOCKER_STANDALONE = 'DOCKER_STANDALONE';
- const DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE';
-
- $scope.state = {
- selectedTemplate: null,
- showAdvancedOptions: false,
- formValidationError: '',
- actionInProgress: false,
- };
-
- $scope.enabledTypes = [TemplateType.Container, TemplateType.ComposeStack];
-
- $scope.formValues = {
- network: '',
- name: '',
- AccessControlData: new AccessControlFormData(),
- };
-
- $scope.addVolume = function () {
- $scope.state.selectedTemplate.Volumes.push({ containerPath: '', bind: '', readonly: false, type: 'auto' });
- };
-
- $scope.removeVolume = function (index) {
- $scope.state.selectedTemplate.Volumes.splice(index, 1);
- };
-
- $scope.addPortBinding = function () {
- $scope.state.selectedTemplate.Ports.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
- };
-
- $scope.removePortBinding = function (index) {
- $scope.state.selectedTemplate.Ports.splice(index, 1);
- };
-
- $scope.addExtraHost = function () {
- $scope.state.selectedTemplate.Hosts.push('');
- };
-
- $scope.removeExtraHost = function (index) {
- $scope.state.selectedTemplate.Hosts.splice(index, 1);
- };
-
- $scope.addLabel = function () {
- $scope.state.selectedTemplate.Labels.push({ name: '', value: '' });
- };
-
- $scope.removeLabel = function (index) {
- $scope.state.selectedTemplate.Labels.splice(index, 1);
- };
-
- function validateForm(accessControlData, isAdmin) {
- $scope.state.formValidationError = '';
- var error = '';
- error = FormValidator.validateAccessControl(accessControlData, isAdmin);
-
- if (error) {
- $scope.state.formValidationError = error;
- return false;
- }
- return true;
- }
-
- function createContainerFromTemplate(template, userId, accessControlData) {
- var templateConfiguration = createTemplateConfiguration(template);
- var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes);
- var generatedVolumeIds = [];
- VolumeService.createXAutoGeneratedLocalVolumes(generatedVolumeCount)
- .then(function success(data) {
- angular.forEach(data, function (volume) {
- var volumeId = volume.Id;
- generatedVolumeIds.push(volumeId);
- });
- TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration, template, data);
- return ImageService.pullImage(template.RegistryModel, true);
- })
- .then(function success() {
- return ContainerService.createAndStartContainer(endpoint.Id, templateConfiguration);
- })
- .then(function success(data) {
- const resourceControl = data.Portainer.ResourceControl;
- return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl, generatedVolumeIds);
- })
- .then(function success() {
- Notifications.success('Success', 'Container successfully created');
- $state.go('docker.containers', {}, { reload: true });
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, err.msg);
- })
- .finally(function final() {
- $scope.state.actionInProgress = false;
- });
- }
-
- function createComposeStackFromTemplate(template, userId, accessControlData) {
- var stackName = $scope.formValues.name;
-
- for (var i = 0; i < template.Env.length; i++) {
- var envvar = template.Env[i];
- if (envvar.preset) {
- envvar.value = envvar.default;
- }
- }
-
- var repositoryOptions = {
- RepositoryURL: template.Repository.url,
- ComposeFilePathInRepository: template.Repository.stackfile,
- FromAppTemplate: true,
- };
-
- const endpointId = +$state.params.endpointId;
- StackService.createComposeStackFromGitRepository(stackName, repositoryOptions, template.Env, endpointId)
- .then(function success(data) {
- const resourceControl = data.ResourceControl;
- return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
- })
- .then(function success() {
- Notifications.success('Success', 'Stack successfully deployed');
- $state.go('docker.stacks');
- })
- .catch(function error(err) {
- Notifications.error('Deployment error', err);
- })
- .finally(function final() {
- $scope.state.actionInProgress = false;
- });
- }
-
- function createStackFromTemplate(template, userId, accessControlData) {
- var stackName = $scope.formValues.name;
- var env = _.filter(
- _.map(template.Env, function transformEnvVar(envvar) {
- return {
- name: envvar.name,
- value: envvar.preset || !envvar.value ? envvar.default : envvar.value,
- };
- }),
- function removeUndefinedVars(envvar) {
- return envvar.value && envvar.name;
- }
- );
-
- var repositoryOptions = {
- RepositoryURL: template.Repository.url,
- ComposeFilePathInRepository: template.Repository.stackfile,
- FromAppTemplate: true,
- };
-
- const endpointId = +$state.params.endpointId;
-
- StackService.createSwarmStackFromGitRepository(stackName, repositoryOptions, env, endpointId)
- .then(function success(data) {
- const resourceControl = data.ResourceControl;
- return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
- })
- .then(function success() {
- Notifications.success('Success', 'Stack successfully deployed');
- $state.go('docker.stacks');
- })
- .catch(function error(err) {
- Notifications.error('Deployment error', err);
- })
- .finally(function final() {
- $scope.state.actionInProgress = false;
- });
- }
-
- $scope.createTemplate = function () {
- var userDetails = Authentication.getUserDetails();
- var userId = userDetails.ID;
- var accessControlData = $scope.formValues.AccessControlData;
-
- if (!validateForm(accessControlData, $scope.isAdmin)) {
- return;
- }
-
- var template = $scope.state.selectedTemplate;
-
- $scope.state.actionInProgress = true;
- if (template.Type === 2) {
- createStackFromTemplate(template, userId, accessControlData);
- } else if (template.Type === 3) {
- createComposeStackFromTemplate(template, userId, accessControlData);
- } else {
- createContainerFromTemplate(template, userId, accessControlData);
- }
- };
-
- $scope.unselectTemplate = function () {
- return $async(async () => {
- $scope.state.selectedTemplate = null;
- });
- };
-
- $scope.selectTemplate = function (template) {
- return $async(async () => {
- if ($scope.state.selectedTemplate) {
- await $scope.unselectTemplate($scope.state.selectedTemplate);
- }
-
- if (template.Network) {
- $scope.formValues.network = _.find($scope.availableNetworks, function (o) {
- return o.Name === template.Network;
- });
- } else {
- $scope.formValues.network = _.find($scope.availableNetworks, function (o) {
- return o.Name === 'bridge';
- });
- }
-
- $scope.formValues.name = template.Name ? template.Name : '';
- $scope.state.selectedTemplate = template;
- $scope.state.deployable = isDeployable($scope.applicationState.endpoint, template.Type);
- $anchorScroll('view-top');
- });
- };
-
- function isDeployable(endpoint, templateType) {
- let deployable = false;
- switch (templateType) {
- case 1:
- deployable = endpoint.mode.provider === DOCKER_SWARM_MODE || endpoint.mode.provider === DOCKER_STANDALONE;
- break;
- case 2:
- deployable = endpoint.mode.provider === DOCKER_SWARM_MODE;
- break;
- case 3:
- deployable = endpoint.mode.provider === DOCKER_SWARM_MODE || endpoint.mode.provider === DOCKER_STANDALONE;
- break;
- }
- return deployable;
- }
-
- function createTemplateConfiguration(template) {
- var network = $scope.formValues.network;
- var name = $scope.formValues.name;
- return TemplateService.createTemplateConfiguration(template, name, network);
- }
-
- function initView() {
- $scope.isAdmin = Authentication.isAdmin();
-
- var endpointMode = $scope.applicationState.endpoint.mode;
- var apiVersion = $scope.applicationState.endpoint.apiVersion;
- const endpointId = +$state.params.endpointId;
-
- const showSwarmStacks = endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER' && apiVersion >= 1.25;
-
- $scope.disabledTypes = !showSwarmStacks ? [TemplateType.SwarmStack] : [];
-
- $q.all({
- templates: TemplateService.templates(endpointId),
- volumes: VolumeService.getVolumes(),
- networks: NetworkService.networks(
- endpointMode.provider === 'DOCKER_STANDALONE' || endpointMode.provider === 'DOCKER_SWARM_MODE',
- false,
- endpointMode.provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25
- ),
- })
- .then(function success(data) {
- var templates = data.templates;
- $scope.templates = templates;
- $scope.availableVolumes = _.orderBy(data.volumes.Volumes, [(volume) => volume.Name.toLowerCase()], ['asc']);
- var networks = data.networks;
- $scope.availableNetworks = networks;
- $scope.allowBindMounts = endpoint.SecuritySettings.allowBindMountsForRegularUsers;
- })
- .catch(function error(err) {
- $scope.templates = [];
- Notifications.error('Failure', err, 'An error occurred during apps initialization.');
- });
- }
-
- initView();
- },
-]);
diff --git a/app/react/common/stacks/CreateView/NameField.tsx b/app/react/common/stacks/CreateView/NameField.tsx
new file mode 100644
index 000000000..59f197220
--- /dev/null
+++ b/app/react/common/stacks/CreateView/NameField.tsx
@@ -0,0 +1,58 @@
+import { FormikErrors } from 'formik';
+import { SchemaOf, string } from 'yup';
+import { useMemo } from 'react';
+
+import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { FormControl } from '@@/form-components/FormControl';
+import { Input } from '@@/form-components/Input';
+
+import { useStacks } from '../queries/useStacks';
+
+export function NameField({
+ onChange,
+ value,
+ errors,
+}: {
+ onChange(value: string): void;
+ value: string;
+ errors?: FormikErrors;
+}) {
+ return (
+
+ onChange(e.target.value)}
+ value={value}
+ required
+ data-cy="stack-name-input"
+ />
+
+ );
+}
+
+export function useNameValidation(
+ environmentId: EnvironmentId
+): SchemaOf {
+ const stacksQuery = useStacks();
+
+ return useMemo(
+ () =>
+ string()
+ .required('Name is required')
+ .test(
+ 'unique',
+ 'Name should be unique',
+ (value) =>
+ stacksQuery.data?.every(
+ (s) => s.EndpointId !== environmentId || s.Name !== value
+ ) ?? true
+ )
+ .matches(
+ new RegExp(STACK_NAME_VALIDATION_REGEX),
+ "This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
+ ),
+ [environmentId, stacksQuery.data]
+ );
+}
diff --git a/app/react/common/stacks/queries/buildUrl.ts b/app/react/common/stacks/queries/buildUrl.ts
new file mode 100644
index 000000000..b56a3b929
--- /dev/null
+++ b/app/react/common/stacks/queries/buildUrl.ts
@@ -0,0 +1,7 @@
+import { Stack } from '../types';
+
+export function buildStackUrl(id?: Stack['Id'], action?: string) {
+ const baseUrl = '/stacks';
+ const url = id ? `${baseUrl}/${id}` : baseUrl;
+ return action ? `${url}/${action}` : url;
+}
diff --git a/app/react/common/stacks/queries/query-keys.ts b/app/react/common/stacks/queries/query-keys.ts
new file mode 100644
index 000000000..40a2c5815
--- /dev/null
+++ b/app/react/common/stacks/queries/query-keys.ts
@@ -0,0 +1,3 @@
+export const queryKeys = {
+ base: () => ['stacks'],
+};
diff --git a/app/react/common/stacks/queries/useCreateStack/buildUrl.ts b/app/react/common/stacks/queries/useCreateStack/buildUrl.ts
new file mode 100644
index 000000000..5c7da25f3
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/buildUrl.ts
@@ -0,0 +1,14 @@
+import { buildStackUrl } from '../buildUrl';
+
+export function buildCreateUrl(
+ stackType: 'kubernetes',
+ method: 'repository' | 'url' | 'string'
+): string;
+
+export function buildCreateUrl(
+ stackType: 'swarm' | 'standalone',
+ method: 'repository' | 'string' | 'file'
+): string;
+export function buildCreateUrl(stackType: string, method: string) {
+ return buildStackUrl(undefined, `create/${stackType}/${method}`);
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromFileContent.ts b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromFileContent.ts
new file mode 100644
index 000000000..68122e097
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromFileContent.ts
@@ -0,0 +1,36 @@
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export interface KubernetesFileContentPayload {
+ /** Name of the stack */
+ stackName: string;
+ /** Content of the Stack file */
+ stackFileContent: string;
+ composeFormat: boolean;
+ namespace: string;
+ /** Whether the stack is from an app template */
+ fromAppTemplate?: boolean;
+ environmentId: EnvironmentId;
+}
+
+export async function createKubernetesStackFromFileContent({
+ environmentId,
+ ...payload
+}: KubernetesFileContentPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('kubernetes', 'string'),
+ payload,
+ {
+ params: { endpointId: environmentId },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts
new file mode 100644
index 000000000..0a2ea2f7a
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts
@@ -0,0 +1,54 @@
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { AutoUpdateModel } from '@/react/portainer/gitops/types';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export type KubernetesGitRepositoryPayload = {
+ /** Name of the stack */
+ stackName: string;
+ composeFormat: boolean;
+ namespace: string;
+
+ /** URL of a Git repository hosting the Stack file */
+ repositoryUrl: string;
+ /** Reference name of a Git repository hosting the Stack file */
+ repositoryReferenceName?: string;
+ /** Use basic authentication to clone the Git repository */
+ repositoryAuthentication?: boolean;
+ /** Username used in basic authentication. Required when RepositoryAuthentication is true. */
+ repositoryUsername?: string;
+ /** Password used in basic authentication. Required when RepositoryAuthentication is true. */
+ repositoryPassword?: string;
+ /** GitCredentialID used to identify the binded git credential */
+ repositoryGitCredentialId?: number;
+ /** Path to the Stack file inside the Git repository */
+ manifestFile?: string;
+
+ additionalFiles?: Array;
+ /** TLSSkipVerify skips SSL verification when cloning the Git repository */
+ tlsSkipVerify?: boolean;
+ /** Optional GitOps update configuration */
+ autoUpdate?: AutoUpdateModel;
+ environmentId: EnvironmentId;
+};
+
+export async function createKubernetesStackFromGit({
+ environmentId,
+ ...payload
+}: KubernetesGitRepositoryPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('kubernetes', 'repository'),
+ payload,
+ {
+ params: { endpointId: environmentId },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromUrl.ts b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromUrl.ts
new file mode 100644
index 000000000..0df3baf65
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromUrl.ts
@@ -0,0 +1,32 @@
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export interface KubernetesUrlPayload {
+ stackName: string;
+ composeFormat: boolean;
+ namespace: string;
+ manifestURL: string;
+ environmentId: EnvironmentId;
+}
+
+export async function createKubernetesStackFromUrl({
+ environmentId,
+ ...payload
+}: KubernetesUrlPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('kubernetes', 'url'),
+ payload,
+ {
+ params: { endpointId: environmentId },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromFile.ts b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromFile.ts
new file mode 100644
index 000000000..5f07b9062
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromFile.ts
@@ -0,0 +1,44 @@
+import axios, {
+ json2formData,
+ parseAxiosError,
+} from '@/portainer/services/axios';
+import { Pair } from '@/react/portainer/settings/types';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export type StandaloneFileUploadPayload = {
+ /** Name of the stack */
+ Name: string;
+
+ file: File;
+ /** List of environment variables */
+ Env?: Array;
+
+ /** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
+ Webhook?: string;
+ environmentId: EnvironmentId;
+};
+
+export async function createStandaloneStackFromFile({
+ environmentId,
+ ...payload
+}: StandaloneFileUploadPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('standalone', 'file'),
+ json2formData(payload),
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ params: { endpointId: environmentId },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromFileContent.ts b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromFileContent.ts
new file mode 100644
index 000000000..d53d7e089
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromFileContent.ts
@@ -0,0 +1,40 @@
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { Pair } from '@/react/portainer/settings/types';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export interface StandaloneFileContentPayload {
+ /** Name of the stack */
+ name: string;
+
+ stackFileContent: string;
+ /** List of environment variables */
+ env?: Array;
+
+ /** Whether the stack is from an app template */
+ fromAppTemplate?: boolean;
+ /** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
+ webhook?: string;
+ environmentId: EnvironmentId;
+}
+
+export async function createStandaloneStackFromFileContent({
+ environmentId,
+ ...payload
+}: StandaloneFileContentPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('standalone', 'string'),
+ payload,
+ {
+ params: { endpointId: environmentId },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts
new file mode 100644
index 000000000..d006b9cf7
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts
@@ -0,0 +1,63 @@
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { Pair } from '@/react/portainer/settings/types';
+import { AutoUpdateModel } from '@/react/portainer/gitops/types';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export type StandaloneGitRepositoryPayload = {
+ /** Name of the stack */
+ name: string;
+ /** List of environment variables */
+ env?: Array;
+ /** Whether the stack is from an app template */
+ fromAppTemplate?: boolean;
+
+ /** URL of a Git repository hosting the Stack file */
+ repositoryUrl: string;
+ /** Reference name of a Git repository hosting the Stack file */
+ repositoryReferenceName?: string;
+ /** Use basic authentication to clone the Git repository */
+ repositoryAuthentication?: boolean;
+ /** Username used in basic authentication. Required when RepositoryAuthentication is true. */
+ repositoryUsername?: string;
+ /** Password used in basic authentication. Required when RepositoryAuthentication is true. */
+ repositoryPassword?: string;
+ /** GitCredentialID used to identify the binded git credential */
+ repositoryGitCredentialId?: number;
+ /** Path to the Stack file inside the Git repository */
+ composeFile?: string;
+
+ additionalFiles?: Array;
+
+ /** Optional GitOps update configuration */
+ autoUpdate?: AutoUpdateModel;
+
+ /** Whether the stack supports relative path volume */
+ supportRelativePath?: boolean;
+ /** Local filesystem path */
+ filesystemPath?: string;
+ /** TLSSkipVerify skips SSL verification when cloning the Git repository */
+ tlsSkipVerify?: boolean;
+ environmentId: EnvironmentId;
+};
+
+export async function createStandaloneStackFromGit({
+ environmentId,
+ ...payload
+}: StandaloneGitRepositoryPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('standalone', 'repository'),
+ payload,
+ {
+ params: { endpointId: environmentId },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromFile.ts b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromFile.ts
new file mode 100644
index 000000000..f581615be
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromFile.ts
@@ -0,0 +1,50 @@
+import axios, {
+ json2formData,
+ parseAxiosError,
+} from '@/portainer/services/axios';
+import { Pair } from '@/react/portainer/settings/types';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export type SwarmFileUploadPayload = {
+ /** Name of the stack */
+ Name: string;
+
+ /** List of environment variables */
+ Env?: Array;
+
+ /** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
+ Webhook?: string;
+
+ /** Swarm cluster identifier */
+ SwarmID: string;
+
+ file: File;
+ environmentId: EnvironmentId;
+};
+
+export async function createSwarmStackFromFile({
+ environmentId,
+ ...payload
+}: SwarmFileUploadPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('swarm', 'file'),
+ json2formData(payload),
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ params: {
+ endpointId: environmentId,
+ },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromFileContent.ts b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromFileContent.ts
new file mode 100644
index 000000000..373df3f1a
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromFileContent.ts
@@ -0,0 +1,43 @@
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { Pair } from '@/react/portainer/settings/types';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export interface SwarmFileContentPayload {
+ /** Name of the stack */
+ name: string;
+
+ stackFileContent: string;
+ /** List of environment variables */
+ env?: Array;
+
+ /** Whether the stack is from an app template */
+ fromAppTemplate?: boolean;
+ /** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
+ webhook?: string;
+
+ /** Swarm cluster identifier */
+ swarmID: string;
+ environmentId: EnvironmentId;
+}
+
+export async function createSwarmStackFromFileContent({
+ environmentId,
+ ...payload
+}: SwarmFileContentPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('swarm', 'string'),
+ payload,
+ {
+ params: { endpointId: environmentId },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts
new file mode 100644
index 000000000..e7cddcff8
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts
@@ -0,0 +1,65 @@
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { Pair } from '@/react/portainer/settings/types';
+import { AutoUpdateModel } from '@/react/portainer/gitops/types';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Stack } from '../../types';
+
+import { buildCreateUrl } from './buildUrl';
+
+export type SwarmGitRepositoryPayload = {
+ /** Name of the stack */
+ name: string;
+ /** List of environment variables */
+ env?: Array;
+ /** Whether the stack is from an app template */
+ fromAppTemplate?: boolean;
+ /** Swarm cluster identifier */
+ swarmID: string;
+
+ /** URL of a Git repository hosting the Stack file */
+ repositoryUrl: string;
+ /** Reference name of a Git repository hosting the Stack file */
+ repositoryReferenceName?: string;
+ /** Use basic authentication to clone the Git repository */
+ repositoryAuthentication?: boolean;
+ /** Username used in basic authentication. Required when RepositoryAuthentication is true. */
+ repositoryUsername?: string;
+ /** Password used in basic authentication. Required when RepositoryAuthentication is true. */
+ repositoryPassword?: string;
+ /** GitCredentialID used to identify the binded git credential */
+ repositoryGitCredentialId?: number;
+ /** Path to the Stack file inside the Git repository */
+ composeFile?: string;
+
+ additionalFiles?: Array;
+
+ /** Optional GitOps update configuration */
+ autoUpdate?: AutoUpdateModel;
+
+ /** Whether the stack supports relative path volume */
+ supportRelativePath?: boolean;
+ /** Local filesystem path */
+ filesystemPath?: string;
+ /** TLSSkipVerify skips SSL verification when cloning the Git repository */
+ tlsSkipVerify?: boolean;
+ environmentId: EnvironmentId;
+};
+
+export async function createSwarmStackFromGit({
+ environmentId,
+ ...payload
+}: SwarmGitRepositoryPayload) {
+ try {
+ const { data } = await axios.post(
+ buildCreateUrl('standalone', 'repository'),
+ payload,
+ {
+ params: { endpointId: environmentId },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts
new file mode 100644
index 000000000..7d774f9cd
--- /dev/null
+++ b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts
@@ -0,0 +1,302 @@
+import { useMutation, useQueryClient } from 'react-query';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { Pair } from '@/react/portainer/settings/types';
+import {
+ GitFormModel,
+ RelativePathModel,
+} from '@/react/portainer/gitops/types';
+import { applyResourceControl } from '@/react/portainer/access-control/access-control.service';
+import { AccessControlFormData } from '@/react/portainer/access-control/types';
+import PortainerError from '@/portainer/error';
+import { withError, withInvalidate } from '@/react-tools/react-query';
+
+import { queryKeys } from '../query-keys';
+
+import { createSwarmStackFromFile } from './createSwarmStackFromFile';
+import { createSwarmStackFromGit } from './createSwarmStackFromGit';
+import { createSwarmStackFromFileContent } from './createSwarmStackFromFileContent';
+import { createStandaloneStackFromFile } from './createStandaloneStackFromFile';
+import { createStandaloneStackFromGit } from './createStandaloneStackFromGit';
+import { createStandaloneStackFromFileContent } from './createStandaloneStackFromFileContent';
+import { createKubernetesStackFromUrl } from './createKubernetesStackFromUrl';
+import { createKubernetesStackFromGit } from './createKubernetesStackFromGit';
+import { createKubernetesStackFromFileContent } from './createKubernetesStackFromFileContent';
+
+export function useCreateStack() {
+ const queryClient = useQueryClient();
+ return useMutation(createStack, {
+ ...withError('Failed to create stack'),
+ ...withInvalidate(queryClient, [queryKeys.base()]),
+ });
+}
+
+type BasePayload = {
+ name: string;
+ environmentId: EnvironmentId;
+};
+
+type DockerBasePayload = BasePayload & {
+ env?: Array;
+ accessControl: AccessControlFormData;
+};
+
+type SwarmBasePayload = DockerBasePayload & {
+ swarmId: string;
+};
+
+type KubernetesBasePayload = BasePayload & {
+ namespace: string;
+ composeFormat: boolean;
+};
+
+export type SwarmCreatePayload =
+ | {
+ method: 'file';
+ payload: SwarmBasePayload & {
+ /** File to upload */
+ file: File;
+ /** Optional webhook configuration */
+ webhook?: string;
+ };
+ }
+ | {
+ method: 'string';
+ payload: SwarmBasePayload & {
+ /** Content of the Stack file */
+ fileContent: string;
+ /** Optional webhook configuration */
+ webhook?: string;
+ fromAppTemplate?: boolean;
+ };
+ }
+ | {
+ method: 'git';
+ payload: SwarmBasePayload & {
+ git: GitFormModel;
+ relativePathSettings?: RelativePathModel;
+ fromAppTemplate?: boolean;
+ };
+ };
+
+type StandaloneCreatePayload =
+ | {
+ method: 'file';
+ payload: DockerBasePayload & {
+ /** File to upload */
+ file: File;
+ /** Optional webhook configuration */
+ webhook?: string;
+ };
+ }
+ | {
+ method: 'string';
+ payload: DockerBasePayload & {
+ /** Content of the Stack file */
+ fileContent: string;
+ /** Optional webhook configuration */
+ webhook?: string;
+ fromAppTemplate?: boolean;
+ };
+ }
+ | {
+ method: 'git';
+ payload: DockerBasePayload & {
+ git: GitFormModel;
+ relativePathSettings?: RelativePathModel;
+ fromAppTemplate?: boolean;
+ };
+ };
+
+type KubernetesCreatePayload =
+ | {
+ method: 'string';
+ payload: KubernetesBasePayload & {
+ /** Content of the Stack file */
+ fileContent: string;
+ /** Optional webhook configuration */
+ webhook?: string;
+ };
+ }
+ | {
+ method: 'git';
+ payload: KubernetesBasePayload & {
+ git: GitFormModel;
+ relativePathSettings?: RelativePathModel;
+ };
+ }
+ | {
+ method: 'url';
+ payload: KubernetesBasePayload & {
+ manifestUrl: string;
+ };
+ };
+
+export type CreateStackPayload =
+ | ({ type: 'swarm' } & SwarmCreatePayload)
+ | ({ type: 'standalone' } & StandaloneCreatePayload)
+ | ({ type: 'kubernetes' } & KubernetesCreatePayload);
+
+async function createStack(payload: CreateStackPayload) {
+ const stack = await createActualStack(payload);
+
+ if (payload.type === 'standalone' || payload.type === 'swarm') {
+ const resourceControl = stack.ResourceControl;
+
+ // Portainer will always return a resource control, but since types mark it as optional, we need to check it.
+ // Ignoring the missing value will result with bugs, hence it's better to throw an error
+ if (!resourceControl) {
+ throw new PortainerError('resource control expected after creation');
+ }
+
+ await applyResourceControl(
+ payload.payload.accessControl,
+ resourceControl.Id
+ );
+ }
+}
+
+function createActualStack(payload: CreateStackPayload) {
+ switch (payload.type) {
+ case 'swarm':
+ return createSwarmStack(payload);
+ case 'standalone':
+ return createStandaloneStack(payload);
+ case 'kubernetes':
+ return createKubernetesStack(payload);
+ default:
+ throw new Error('Invalid type');
+ }
+}
+
+function createSwarmStack({ method, payload }: SwarmCreatePayload) {
+ switch (method) {
+ case 'file':
+ return createSwarmStackFromFile({
+ environmentId: payload.environmentId,
+ file: payload.file,
+ Name: payload.name,
+ SwarmID: payload.swarmId,
+ Env: payload.env,
+ Webhook: payload.webhook,
+ });
+ case 'git':
+ return createSwarmStackFromGit({
+ name: payload.name,
+ env: payload.env,
+ repositoryUrl: payload.git.RepositoryURL,
+ repositoryReferenceName: payload.git.RepositoryReferenceName,
+ composeFile: payload.git.ComposeFilePathInRepository,
+ repositoryAuthentication: payload.git.RepositoryAuthentication,
+ repositoryUsername: payload.git.RepositoryUsername,
+ repositoryPassword: payload.git.RepositoryPassword,
+ repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
+ filesystemPath: payload.relativePathSettings?.FilesystemPath,
+ supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
+ tlsSkipVerify: payload.git.TLSSkipVerify,
+ autoUpdate: payload.git.AutoUpdate,
+ environmentId: payload.environmentId,
+ swarmID: payload.swarmId,
+ additionalFiles: payload.git.AdditionalFiles,
+ fromAppTemplate: payload.fromAppTemplate,
+ });
+ case 'string':
+ return createSwarmStackFromFileContent({
+ name: payload.name,
+ env: payload.env,
+ environmentId: payload.environmentId,
+ stackFileContent: payload.fileContent,
+ webhook: payload.webhook,
+ swarmID: payload.swarmId,
+ fromAppTemplate: payload.fromAppTemplate,
+ });
+ default:
+ throw new Error('Invalid method');
+ }
+}
+
+function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
+ switch (method) {
+ case 'file':
+ return createStandaloneStackFromFile({
+ environmentId: payload.environmentId,
+ file: payload.file,
+ Name: payload.name,
+ Env: payload.env,
+ Webhook: payload.webhook,
+ });
+ case 'git':
+ return createStandaloneStackFromGit({
+ name: payload.name,
+ env: payload.env,
+ repositoryUrl: payload.git.RepositoryURL,
+ repositoryReferenceName: payload.git.RepositoryReferenceName,
+ composeFile: payload.git.ComposeFilePathInRepository,
+ repositoryAuthentication: payload.git.RepositoryAuthentication,
+ repositoryUsername: payload.git.RepositoryUsername,
+ repositoryPassword: payload.git.RepositoryPassword,
+ repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
+ filesystemPath: payload.relativePathSettings?.FilesystemPath,
+ supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
+ tlsSkipVerify: payload.git.TLSSkipVerify,
+ autoUpdate: payload.git.AutoUpdate,
+ environmentId: payload.environmentId,
+ additionalFiles: payload.git.AdditionalFiles,
+ fromAppTemplate: payload.fromAppTemplate,
+ });
+ case 'string':
+ return createStandaloneStackFromFileContent({
+ name: payload.name,
+ env: payload.env,
+ environmentId: payload.environmentId,
+ stackFileContent: payload.fileContent,
+ webhook: payload.webhook,
+ fromAppTemplate: payload.fromAppTemplate,
+ });
+ default:
+ throw new Error('Invalid method');
+ }
+}
+
+function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
+ switch (method) {
+ case 'string':
+ return createKubernetesStackFromFileContent({
+ stackName: payload.name,
+
+ environmentId: payload.environmentId,
+ stackFileContent: payload.fileContent,
+ composeFormat: payload.composeFormat,
+ namespace: payload.namespace,
+ });
+ case 'git':
+ return createKubernetesStackFromGit({
+ stackName: payload.name,
+
+ repositoryUrl: payload.git.RepositoryURL,
+ repositoryReferenceName: payload.git.RepositoryReferenceName,
+ manifestFile: payload.git.ComposeFilePathInRepository,
+ repositoryAuthentication: payload.git.RepositoryAuthentication,
+ repositoryUsername: payload.git.RepositoryUsername,
+ repositoryPassword: payload.git.RepositoryPassword,
+ repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
+
+ tlsSkipVerify: payload.git.TLSSkipVerify,
+ autoUpdate: payload.git.AutoUpdate,
+ environmentId: payload.environmentId,
+ additionalFiles: payload.git.AdditionalFiles,
+ composeFormat: payload.composeFormat,
+ namespace: payload.namespace,
+ });
+ case 'url':
+ return createKubernetesStackFromUrl({
+ stackName: payload.name,
+ composeFormat: payload.composeFormat,
+ environmentId: payload.environmentId,
+ manifestURL: payload.manifestUrl,
+ namespace: payload.namespace,
+ });
+ default:
+ throw new Error('Invalid method');
+ }
+}
diff --git a/app/react/common/stacks/queries/useStacks.ts b/app/react/common/stacks/queries/useStacks.ts
new file mode 100644
index 000000000..a347ca49d
--- /dev/null
+++ b/app/react/common/stacks/queries/useStacks.ts
@@ -0,0 +1,23 @@
+import { useQuery } from 'react-query';
+
+import { withError } from '@/react-tools/react-query';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { Stack } from '@/react/common/stacks/types';
+
+import { buildStackUrl } from './buildUrl';
+import { queryKeys } from './query-keys';
+
+export function useStacks() {
+ return useQuery(queryKeys.base(), () => getStacks(), {
+ ...withError('Failed loading stack'),
+ });
+}
+
+export async function getStacks() {
+ try {
+ const { data } = await axios.get(buildStackUrl());
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/common/stacks/types.ts b/app/react/common/stacks/types.ts
index 86051939f..c1245ede1 100644
--- a/app/react/common/stacks/types.ts
+++ b/app/react/common/stacks/types.ts
@@ -30,8 +30,8 @@ export interface Stack {
Id: number;
Name: string;
Type: StackType;
- EndpointID: number;
- SwarmID: string;
+ EndpointId: number;
+ SwarmId: string;
EntryPoint: string;
Env: {
name: string;
diff --git a/app/react/docker/app-templates/.keep b/app/react/docker/app-templates/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx b/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx
index 757cb1073..33e00f9a8 100644
--- a/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx
+++ b/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx
@@ -11,9 +11,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
-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';
@@ -23,6 +21,7 @@ import {
PortsMappingField,
Values as PortMappingValue,
} from './PortsMappingField';
+import { NameField } from './NameField';
export interface Values {
name: string;
@@ -74,19 +73,14 @@ export function BaseForm({
return (
-
- {
- const name = e.target.value;
- onChangeName(name);
- setFieldValue('name', name);
- }}
- placeholder="e.g. myContainer"
- data-cy="container-name-input"
- />
-
+ {
+ setFieldValue('name', name);
+ onChangeName(name);
+ }}
+ error={errors?.name}
+ />
void;
+}) {
+ return (
+
+ {
+ onChange(e.target.value);
+ }}
+ placeholder="e.g. myContainer"
+ data-cy="container-name-input"
+ />
+
+ );
+}
+
+export function nameValidation() {
+ return string().default('');
+}
diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts
index 02f5a77b9..3c540689c 100644
--- a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts
+++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts
@@ -9,7 +9,7 @@ type PortKey = `${string}/${Protocol}`;
export function parsePortBindingRequest(portBindings: Values): PortMap {
const bindings: Record<
PortKey,
- Array<{ HostIp: string; HostPort: string }>
+ Array<{ HostIp?: string; HostPort?: string }>
> = {};
_.forEach(portBindings, (portBinding) => {
if (!portBinding.containerPort) {
@@ -17,9 +17,6 @@ export function parsePortBindingRequest(portBindings: Values): PortMap {
}
const portInfo = extractPortInfo(portBinding);
- if (!portInfo) {
- return;
- }
let { hostPort } = portBinding;
const { endHostPort, endPort, hostIp, startHostPort, startPort } = portInfo;
@@ -36,7 +33,9 @@ export function parsePortBindingRequest(portBindings: Values): PortMap {
hostPort += `-${endHostPort.toString()}`;
}
- bindings[bindKey].push({ HostIp: hostIp, HostPort: hostPort });
+ bindings[bindKey].push(
+ hostIp || hostPort ? { HostIp: hostIp, HostPort: hostPort } : {}
+ );
});
});
return bindings;
@@ -71,7 +70,13 @@ function parsePort(port: string) {
return 0;
}
-function extractPortInfo(portBinding: PortMapping) {
+function extractPortInfo(portBinding: PortMapping): {
+ startPort: number;
+ endPort: number;
+ hostIp: string;
+ startHostPort: number;
+ endHostPort: number;
+} {
const containerPortRange = parsePortRange(portBinding.containerPort);
if (!isValidPortRange(containerPortRange)) {
throw new Error(`Invalid port specification: ${portBinding.containerPort}`);
@@ -82,7 +87,13 @@ function extractPortInfo(portBinding: PortMapping) {
let hostIp = '';
let { hostPort } = portBinding;
if (!hostPort) {
- return null;
+ return {
+ startPort,
+ endPort,
+ hostIp: '',
+ startHostPort: 0,
+ endHostPort: 0,
+ };
}
if (hostPort.includes('[')) {
diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts
index edc66fd50..1529b9cd5 100644
--- a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts
+++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts
@@ -5,7 +5,7 @@ import { Values } from './PortsMappingField';
export function validationSchema(): SchemaOf {
return array(
object({
- hostPort: string().required('host is required'),
+ hostPort: string().default(''),
containerPort: string().required('container is required'),
protocol: mixed().oneOf(['tcp', 'udp']),
})
diff --git a/app/react/docker/containers/CreateView/BaseForm/validation.ts b/app/react/docker/containers/CreateView/BaseForm/validation.ts
index f701eda57..b110e207d 100644
--- a/app/react/docker/containers/CreateView/BaseForm/validation.ts
+++ b/app/react/docker/containers/CreateView/BaseForm/validation.ts
@@ -6,6 +6,7 @@ import { imageConfigValidation } from '@@/ImageConfigFieldset';
import { Values } from './BaseForm';
import { validationSchema as portsSchema } from './PortsMappingField.validation';
+import { nameValidation } from './NameField';
export function validation(
{
@@ -26,9 +27,10 @@ export function validation(
}
): SchemaOf {
return object({
- name: string()
- .default('')
- .test('not-duplicate-portainer', () => !isDuplicatingPortainer),
+ name: nameValidation().test(
+ 'not-duplicate-portainer',
+ () => !isDuplicatingPortainer
+ ),
alwaysPull: boolean()
.default(true)
.test('rate-limits', 'Rate limit exceeded', (alwaysPull: boolean) =>
diff --git a/app/react/docker/containers/CreateView/CommandsTab/toRequest.ts b/app/react/docker/containers/CreateView/CommandsTab/toRequest.ts
index 99a9e311c..7e22809be 100644
--- a/app/react/docker/containers/CreateView/CommandsTab/toRequest.ts
+++ b/app/react/docker/containers/CreateView/CommandsTab/toRequest.ts
@@ -40,30 +40,30 @@ export function toRequest(
}
return config;
+}
- function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
- switch (value) {
- case 'both':
- return { OpenStdin: true, Tty: true };
- case 'interactive':
- return { OpenStdin: true, Tty: false };
- case 'tty':
- return { OpenStdin: false, Tty: true };
- case 'none':
- default:
- return { OpenStdin: false, Tty: false };
- }
- }
-
- function getLogConfig(
- value: LogConfig
- ): CreateContainerRequest['HostConfig']['LogConfig'] {
- return {
- Type: value.type,
- Config: Object.fromEntries(
- value.options.map(({ option, value }) => [option, value])
- ),
- // docker types - requires union while it should allow also custom string for custom plugins
- } as CreateContainerRequest['HostConfig']['LogConfig'];
+export function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
+ switch (value) {
+ case 'both':
+ return { OpenStdin: true, Tty: true };
+ case 'interactive':
+ return { OpenStdin: true, Tty: false };
+ case 'tty':
+ return { OpenStdin: false, Tty: true };
+ case 'none':
+ default:
+ return { OpenStdin: false, Tty: false };
}
}
+
+function getLogConfig(
+ value: LogConfig
+): CreateContainerRequest['HostConfig']['LogConfig'] {
+ return {
+ Type: value.type,
+ Config: Object.fromEntries(
+ value.options.map(({ option, value }) => [option, value])
+ ),
+ // docker types - requires union while it should allow also custom string for custom plugins
+ } as CreateContainerRequest['HostConfig']['LogConfig'];
+}
diff --git a/app/react/docker/containers/CreateView/CreateView.tsx b/app/react/docker/containers/CreateView/CreateView.tsx
index d264d4bc7..d7c0f938e 100644
--- a/app/react/docker/containers/CreateView/CreateView.tsx
+++ b/app/react/docker/containers/CreateView/CreateView.tsx
@@ -145,7 +145,21 @@ function CreateForm() {
const config = toRequest(values, registry, hideCapabilities);
return mutation.mutate(
- { config, environment, values, registry, oldContainer, extraNetworks },
+ {
+ config,
+ environment,
+ values: {
+ accessControl: values.accessControl,
+ imageName: values.image.image,
+ name: values.name,
+ alwaysPull: values.alwaysPull,
+ enableWebhook: values.enableWebhook,
+ nodeName: values.nodeName,
+ },
+ registry,
+ oldContainer,
+ extraNetworks,
+ },
{
onSuccess() {
sendAnalytics(values, registry);
diff --git a/app/react/docker/containers/CreateView/InnerForm.tsx b/app/react/docker/containers/CreateView/InnerForm.tsx
index e39dbc932..9db388f18 100644
--- a/app/react/docker/containers/CreateView/InnerForm.tsx
+++ b/app/react/docker/containers/CreateView/InnerForm.tsx
@@ -101,11 +101,6 @@ export function InnerForm({
setFieldValue('volumes', value)
}
errors={errors.volumes}
- allowBindMounts={
- isEnvironmentAdminQuery.authorized ||
- environment.SecuritySettings
- .allowBindMountsForRegularUsers
- }
/>
),
},
diff --git a/app/react/docker/containers/CreateView/NetworkTab/HostnameField.tsx b/app/react/docker/containers/CreateView/NetworkTab/HostnameField.tsx
new file mode 100644
index 000000000..f0186e77a
--- /dev/null
+++ b/app/react/docker/containers/CreateView/NetworkTab/HostnameField.tsx
@@ -0,0 +1,27 @@
+import { string } from 'yup';
+
+import { FormControl } from '@@/form-components/FormControl';
+import { Input } from '@@/form-components/Input';
+
+export function HostnameField({
+ value,
+ error,
+ onChange,
+}: {
+ value: string;
+ error?: string;
+ onChange: (value: string) => void;
+}) {
+ return (
+
+ onChange(e.target.value)}
+ placeholder="e.g. web01"
+ data-cy="docker-container-hostname-input"
+ />
+
+ );
+}
+
+export const hostnameSchema = string().default('');
diff --git a/app/react/docker/containers/CreateView/NetworkTab/HostsFileEntries.tsx b/app/react/docker/containers/CreateView/NetworkTab/HostsFileEntries.tsx
new file mode 100644
index 000000000..963c1317a
--- /dev/null
+++ b/app/react/docker/containers/CreateView/NetworkTab/HostsFileEntries.tsx
@@ -0,0 +1,56 @@
+import { array, string } from 'yup';
+
+import { FormError } from '@@/form-components/FormError';
+import { InputLabeled } from '@@/form-components/Input/InputLabeled';
+import { ItemProps } from '@@/form-components/InputList';
+import { ArrayError, InputList } from '@@/form-components/InputList/InputList';
+
+export const hostFileSchema = array(
+ string().required('Entry is required')
+).default([]);
+
+export function HostsFileEntries({
+ values,
+ onChange,
+ errors,
+}: {
+ values: string[];
+ onChange: (values: string[]) => void;
+ errors?: ArrayError;
+}) {
+ return (
+ onChange(hostsFileEntries)}
+ errors={errors}
+ item={HostsFileEntryItem}
+ itemBuilder={() => ''}
+ data-cy="hosts-file-entries"
+ />
+ );
+}
+
+function HostsFileEntryItem({
+ item,
+ onChange,
+ disabled,
+ error,
+ readOnly,
+ index,
+}: ItemProps) {
+ return (
+
+ onChange(e.target.value)}
+ disabled={disabled}
+ readOnly={readOnly}
+ data-cy={`hosts-file-entry_${index}`}
+ />
+
+ {error && {error}}
+
+ );
+}
diff --git a/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx b/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx
index 2e94e63bb..771ee9552 100644
--- a/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx
+++ b/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx
@@ -2,14 +2,13 @@ import { FormikErrors } from 'formik';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
-import { InputList, ItemProps } from '@@/form-components/InputList';
-import { InputGroup } from '@@/form-components/InputGroup';
-import { FormError } from '@@/form-components/FormError';
import { NetworkSelector } from '../../components/NetworkSelector';
import { CONTAINER_MODE, Values } from './types';
import { ContainerSelector } from './ContainerSelector';
+import { HostsFileEntries } from './HostsFileEntries';
+import { HostnameField } from './HostnameField';
export function NetworkTab({
values,
@@ -39,14 +38,10 @@ export function NetworkTab({
)}
-
- setFieldValue('hostname', e.target.value)}
- placeholder="e.g. web01"
- data-cy="docker-container-hostname-input"
- />
-
+ setFieldValue('hostname', value)}
+ />
-
- setFieldValue('hostsFileEntries', hostsFileEntries)
- }
+ setFieldValue('hostsFileEntries', v)}
errors={errors?.hostsFileEntries}
- item={HostsFileEntryItem}
- itemBuilder={() => ''}
- data-cy="docker-container-hosts-file-entries"
/>
);
}
-
-function HostsFileEntryItem({
- item,
- onChange,
- disabled,
- error,
- readOnly,
- index,
-}: ItemProps) {
- return (
-
-
- value
- onChange(e.target.value)}
- disabled={disabled}
- readOnly={readOnly}
- data-cy={`docker-container-hosts-file-entry_${index}`}
- />
-
-
- {error && {error}}
-
- );
-}
diff --git a/app/react/docker/containers/CreateView/NetworkTab/validation.ts b/app/react/docker/containers/CreateView/NetworkTab/validation.ts
index df9b9de75..46d776cd8 100644
--- a/app/react/docker/containers/CreateView/NetworkTab/validation.ts
+++ b/app/react/docker/containers/CreateView/NetworkTab/validation.ts
@@ -1,18 +1,20 @@
-import { array, object, SchemaOf, string } from 'yup';
+import { object, SchemaOf, string } from 'yup';
import { Values } from './types';
+import { hostnameSchema } from './HostnameField';
+import { hostFileSchema } from './HostsFileEntries';
export function validation(): SchemaOf {
return object({
networkMode: string().default(''),
- hostname: string().default(''),
+ hostname: hostnameSchema,
domain: string().default(''),
macAddress: string().default(''),
ipv4Address: string().default(''),
ipv6Address: string().default(''),
primaryDns: string().default(''),
secondaryDns: string().default(''),
- hostsFileEntries: array(string().required('Entry is required')).default([]),
+ hostsFileEntries: hostFileSchema,
container: string()
.default('')
.when('network', {
diff --git a/app/react/docker/containers/CreateView/ResourcesTab/RuntimeSelector.tsx b/app/react/docker/containers/CreateView/ResourcesTab/RuntimeSelector.tsx
index 3651503c5..b536308ca 100644
--- a/app/react/docker/containers/CreateView/ResourcesTab/RuntimeSelector.tsx
+++ b/app/react/docker/containers/CreateView/ResourcesTab/RuntimeSelector.tsx
@@ -11,13 +11,15 @@ export function RuntimeSelector({
onChange: (value: string) => void;
}) {
const environmentId = useEnvironmentId();
- const infoQuery = useInfo(environmentId, (info) => [
- { label: 'Default', value: '' },
- ...Object.keys(info?.Runtimes || {}).map((runtime) => ({
- label: runtime,
- value: runtime,
- })),
- ]);
+ const infoQuery = useInfo(environmentId, {
+ select: (info) => [
+ { label: 'Default', value: '' },
+ ...Object.keys(info?.Runtimes || {}).map((runtime) => ({
+ label: runtime,
+ value: runtime,
+ })),
+ ],
+ });
return (
) {
- const allowBindMounts = useInputContext();
+ const { allowBindMounts, allowAuto } = useInputContext();
return (
@@ -61,6 +61,7 @@ export function Item({
value={volume.name}
onChange={(name) => setValue({ name })}
inputId={`volume-${index}`}
+ allowAuto={allowAuto}
/>
)}
diff --git a/app/react/docker/containers/CreateView/VolumesTab/VolumeSelector.tsx b/app/react/docker/containers/CreateView/VolumesTab/VolumeSelector.tsx
index 6f02e0679..c55c1a9b8 100644
--- a/app/react/docker/containers/CreateView/VolumesTab/VolumeSelector.tsx
+++ b/app/react/docker/containers/CreateView/VolumesTab/VolumeSelector.tsx
@@ -8,10 +8,12 @@ export function VolumeSelector({
value,
onChange,
inputId,
+ allowAuto,
}: {
value: string;
onChange: (value?: string) => void;
inputId?: string;
+ allowAuto: boolean;
}) {
const environmentId = useEnvironmentId();
const volumesQuery = useVolumes(environmentId, {
@@ -24,7 +26,9 @@ export function VolumeSelector({
return null;
}
- const volumes = volumesQuery.data;
+ const volumes = allowAuto
+ ? [...volumesQuery.data, { Name: 'auto', Driver: '' }]
+ : volumesQuery.data;
const selectedValue = volumes.find((vol) => vol.Name === value);
@@ -33,7 +37,9 @@ export function VolumeSelector({
placeholder="Select a volume"
options={volumes}
getOptionLabel={(vol) =>
- `${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
+ vol.Name !== 'auto'
+ ? `${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
+ : 'auto'
}
getOptionValue={(vol) => vol.Name}
isMulti={false}
diff --git a/app/react/docker/containers/CreateView/VolumesTab/VolumesTab.tsx b/app/react/docker/containers/CreateView/VolumesTab/VolumesTab.tsx
index 73136c800..4d5ff34e4 100644
--- a/app/react/docker/containers/CreateView/VolumesTab/VolumesTab.tsx
+++ b/app/react/docker/containers/CreateView/VolumesTab/VolumesTab.tsx
@@ -1,3 +1,8 @@
+import { useMemo } from 'react';
+
+import { useIsEnvironmentAdmin } from '@/react/hooks/useUser';
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+
import { InputList } from '@@/form-components/InputList';
import { ArrayError } from '@@/form-components/InputList/InputList';
@@ -8,16 +13,29 @@ import { Item } from './Item';
export function VolumesTab({
onChange,
values,
- allowBindMounts,
errors,
+ allowAuto = false,
}: {
onChange: (values: Values) => void;
values: Values;
- allowBindMounts: boolean;
errors?: ArrayError
;
+ allowAuto?: boolean;
}) {
+ const isEnvironmentAdminQuery = useIsEnvironmentAdmin({ adminOnlyCE: true });
+ const envQuery = useCurrentEnvironment();
+
+ const allowBindMounts = !!(
+ isEnvironmentAdminQuery.authorized ||
+ envQuery.data?.SecuritySettings.allowBindMountsForRegularUsers
+ );
+
+ const inputContext = useMemo(
+ () => ({ allowBindMounts, allowAuto }),
+ [allowAuto, allowBindMounts]
+ );
+
return (
-
+
errors={Array.isArray(errors) ? errors : []}
label="Volume mapping"
diff --git a/app/react/docker/containers/CreateView/VolumesTab/context.ts b/app/react/docker/containers/CreateView/VolumesTab/context.ts
index d0fcfd4c3..52d6f7627 100644
--- a/app/react/docker/containers/CreateView/VolumesTab/context.ts
+++ b/app/react/docker/containers/CreateView/VolumesTab/context.ts
@@ -1,6 +1,9 @@
import { createContext, useContext } from 'react';
-export const InputContext = createContext(null);
+export const InputContext = createContext<{
+ allowAuto: boolean;
+ allowBindMounts: boolean;
+} | null>(null);
export function useInputContext() {
const value = useContext(InputContext);
diff --git a/app/react/docker/containers/CreateView/useCreateMutation.tsx b/app/react/docker/containers/CreateView/useCreateMutation.tsx
index 54c8601c2..664e9a451 100644
--- a/app/react/docker/containers/CreateView/useCreateMutation.tsx
+++ b/app/react/docker/containers/CreateView/useCreateMutation.tsx
@@ -62,7 +62,14 @@ export function useCreateOrReplaceMutation() {
interface CreateOptions {
config: CreateContainerRequest;
- values: Values;
+ values: {
+ name: Values['name'];
+ imageName: string;
+ accessControl: Values['accessControl'];
+ nodeName?: Values['nodeName'];
+ alwaysPull?: Values['alwaysPull'];
+ enableWebhook?: Values['enableWebhook'];
+ };
registry?: Registry;
environment: Environment;
}
@@ -90,14 +97,14 @@ async function create({
}: CreateOptions) {
await pullImageIfNeeded(
environment.Id,
+ values.alwaysPull || false,
+ values.imageName,
values.nodeName,
- values.alwaysPull,
- values.image.image,
registry
);
const containerResponse = await createAndStart(
- environment,
+ environment.Id,
config,
values.name,
values.nodeName
@@ -106,8 +113,8 @@ async function create({
await applyContainerSettings(
containerResponse.Id,
environment,
- values.enableWebhook,
values.accessControl,
+ values.enableWebhook,
containerResponse.Portainer?.ResourceControl,
registry
);
@@ -123,33 +130,34 @@ async function replace({
}: ReplaceOptions) {
await pullImageIfNeeded(
environment.Id,
+ values.alwaysPull || false,
+ values.imageName,
values.nodeName,
- values.alwaysPull,
- values.image.image,
registry
);
const containerResponse = await renameAndCreate(
- environment,
- values,
+ environment.Id,
+ values.name,
oldContainer,
- config
+ config,
+ values.nodeName
);
await applyContainerSettings(
containerResponse.Id,
environment,
- values.enableWebhook,
values.accessControl,
+ values.enableWebhook,
containerResponse.Portainer?.ResourceControl,
registry
);
await connectToExtraNetworks(
environment.Id,
- values.nodeName,
containerResponse.Id,
- extraNetworks
+ extraNetworks,
+ values.nodeName
);
await removeContainer(environment.Id, oldContainer.Id, {
@@ -162,33 +170,29 @@ async function replace({
* on any failure, it will rename the old container to its original name
*/
async function renameAndCreate(
- environment: Environment,
- values: Values,
+ environmentId: EnvironmentId,
+ name: string,
oldContainer: DockerContainer,
- config: CreateContainerRequest
+ config: CreateContainerRequest,
+ nodeName?: string
) {
let renamed = false;
try {
- await stopContainerIfNeeded(environment.Id, values.nodeName, oldContainer);
+ await stopContainerIfNeeded(environmentId, oldContainer, nodeName);
await renameContainer(
- environment.Id,
+ environmentId,
oldContainer.Id,
`${oldContainer.Names[0]}-old`,
- { nodeName: values.nodeName }
+ { nodeName }
);
renamed = true;
- return await createAndStart(
- environment,
- config,
- values.name,
- values.nodeName
- );
+ return await createAndStart(environmentId, config, name, nodeName);
} catch (e) {
if (renamed) {
- await renameContainer(environment.Id, oldContainer.Id, values.name, {
- nodeName: values.nodeName,
+ await renameContainer(environmentId, oldContainer.Id, name, {
+ nodeName,
});
}
throw e;
@@ -201,8 +205,8 @@ async function renameAndCreate(
async function applyContainerSettings(
containerId: string,
environment: Environment,
- enableWebhook: boolean,
accessControl: AccessControlFormData,
+ enableWebhook?: boolean,
resourceControl?: ResourceControlResponse,
registry?: Registry
) {
@@ -224,15 +228,15 @@ async function applyContainerSettings(
* on failure, it will remove the new container
*/
async function createAndStart(
- environment: Environment,
+ environmentId: EnvironmentId,
config: CreateContainerRequest,
name: string,
- nodeName: string
+ nodeName?: string
) {
let containerId = '';
try {
const containerResponse = await createContainer(
- environment.Id,
+ environmentId,
config,
name,
{
@@ -242,11 +246,11 @@ async function createAndStart(
containerId = containerResponse.Id;
- await startContainer(environment.Id, containerResponse.Id, { nodeName });
+ await startContainer(environmentId, containerResponse.Id, { nodeName });
return containerResponse;
} catch (e) {
if (containerId) {
- await removeContainer(environment.Id, containerId, {
+ await removeContainer(environmentId, containerId, {
nodeName,
});
}
@@ -257,9 +261,9 @@ async function createAndStart(
async function pullImageIfNeeded(
environmentId: EnvironmentId,
- nodeName: string,
pull: boolean,
image: string,
+ nodeName?: string,
registry?: Registry
) {
if (!pull) {
@@ -322,9 +326,9 @@ async function createContainerWebhook(
function connectToExtraNetworks(
environmentId: EnvironmentId,
- nodeName: string,
containerId: string,
- extraNetworks: Array
+ extraNetworks: Array,
+ nodeName?: string
) {
if (!extraNetworks) {
return null;
@@ -345,8 +349,8 @@ function connectToExtraNetworks(
function stopContainerIfNeeded(
environmentId: EnvironmentId,
- nodeName: string,
- container: DockerContainer
+ container: DockerContainer,
+ nodeName?: string
) {
if (container.State !== 'running' || !container.Id) {
return null;
diff --git a/app/react/docker/containers/ListView/ListView.tsx b/app/react/docker/containers/ListView/ListView.tsx
index 12dcf32a8..cef7fca63 100644
--- a/app/react/docker/containers/ListView/ListView.tsx
+++ b/app/react/docker/containers/ListView/ListView.tsx
@@ -13,7 +13,9 @@ interface Props {
export function ListView({ endpoint: environment }: Props) {
const isAgent = isAgentEnvironment(environment.Type);
- const envInfoQuery = useInfo(environment.Id, (info) => !!info.Swarm?.NodeID);
+ const envInfoQuery = useInfo(environment.Id, {
+ select: (info) => !!info.Swarm?.NodeID,
+ });
const isSwarmManager = !!envInfoQuery.data;
const isHostColumnVisible = isAgent && isSwarmManager;
diff --git a/app/react/docker/containers/utils.ts b/app/react/docker/containers/utils.ts
index a1dca9974..c5efa387b 100644
--- a/app/react/docker/containers/utils.ts
+++ b/app/react/docker/containers/utils.ts
@@ -2,7 +2,7 @@ import _ from 'lodash';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { EnvironmentId } from '@/react/portainer/environments/types';
-import { useInfo } from '@/react/docker/proxy/queries/useInfo';
+import { useIsStandAlone } from '@/react/docker/proxy/queries/useInfo';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { DockerContainer, ContainerStatus } from './types';
@@ -95,14 +95,11 @@ function createStatus(statusText = ''): ContainerStatus {
return ContainerStatus.Running;
}
-export function useShowGPUsColumn(environmentID: EnvironmentId) {
- const isDockerStandaloneQuery = useInfo(
- environmentID,
- (info) => !(!!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable) // is not a swarm environment, therefore docker standalone
- );
+export function useShowGPUsColumn(environmentId: EnvironmentId) {
+ const isDockerStandalone = useIsStandAlone(environmentId);
const enableGPUManagementQuery = useEnvironment(
- environmentID,
+ environmentId,
(env) => env?.EnableGPUManagement
);
- return isDockerStandaloneQuery.data && enableGPUManagementQuery.data;
+ return isDockerStandalone && enableGPUManagementQuery.data;
}
diff --git a/app/react/docker/proxy/queries/useInfo.ts b/app/react/docker/proxy/queries/useInfo.ts
index 6f318189c..b8e1311d1 100644
--- a/app/react/docker/proxy/queries/useInfo.ts
+++ b/app/react/docker/proxy/queries/useInfo.ts
@@ -18,26 +18,38 @@ export async function getInfo(environmentId: EnvironmentId) {
}
export function useInfo(
- environmentId: EnvironmentId,
- select?: (info: SystemInfo) => TSelect
+ environmentId?: EnvironmentId,
+ {
+ enabled,
+ select,
+ }: { select?: (info: SystemInfo) => TSelect; enabled?: boolean } = {}
) {
return useQuery(
['environment', environmentId, 'docker', 'info'],
- () => getInfo(environmentId),
+ () => getInfo(environmentId!),
{
select,
+ enabled: !!environmentId && enabled,
}
);
}
export function useIsStandAlone(environmentId: EnvironmentId) {
- const query = useInfo(environmentId, (info) => !info.Swarm?.NodeID);
+ const query = useInfo(environmentId, {
+ select: (info) => !info.Swarm?.NodeID,
+ });
return !!query.data;
}
-export function useIsSwarm(environmentId: EnvironmentId) {
- const query = useInfo(environmentId, (info) => !!info.Swarm?.NodeID);
+export function useIsSwarm(
+ environmentId?: EnvironmentId,
+ { enabled }: { enabled?: boolean } = {}
+) {
+ const query = useInfo(environmentId, {
+ select: (info) => !!info.Swarm?.NodeID,
+ enabled,
+ });
return !!query.data;
}
diff --git a/app/react/docker/proxy/queries/useServicePlugins.ts b/app/react/docker/proxy/queries/useServicePlugins.ts
index 0117c0e83..0887de9fb 100644
--- a/app/react/docker/proxy/queries/useServicePlugins.ts
+++ b/app/react/docker/proxy/queries/useServicePlugins.ts
@@ -41,7 +41,9 @@ export function useServicePlugins(
pluginType: keyof PluginsInfo,
pluginVersion: string
) {
- const systemPluginsQuery = useInfo(environmentId, (info) => info.Plugins);
+ const systemPluginsQuery = useInfo(environmentId, {
+ select: (info) => info.Plugins,
+ });
const pluginsQuery = usePlugins(environmentId, { enabled: !systemOnly });
return {
diff --git a/app/react/docker/proxy/queries/useSwarm.ts b/app/react/docker/proxy/queries/useSwarm.ts
new file mode 100644
index 000000000..9fdeb3f8b
--- /dev/null
+++ b/app/react/docker/proxy/queries/useSwarm.ts
@@ -0,0 +1,38 @@
+import { useQuery } from 'react-query';
+import { Swarm } from 'docker-types/generated/1.41';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { queryKeys } from './query-keys';
+import { buildUrl } from './build-url';
+import { useIsSwarm } from './useInfo';
+
+export function useSwarm(
+ environmentId: EnvironmentId,
+ { select }: { select?(value: Swarm): T } = {}
+) {
+ const isSwarm = useIsSwarm(environmentId);
+
+ return useQuery({
+ queryKey: [...queryKeys.base(environmentId), 'swarm'] as const,
+ queryFn: () => getSwarm(environmentId),
+ select,
+ enabled: isSwarm,
+ });
+}
+
+async function getSwarm(environmentId: EnvironmentId) {
+ try {
+ const { data } = await axios.get(buildUrl(environmentId, 'swarm'));
+ return data;
+ } catch (err) {
+ throw parseAxiosError(err, 'Unable to retrieve swarm information');
+ }
+}
+
+export function useSwarmId(environmentId: EnvironmentId) {
+ return useSwarm(environmentId, {
+ select: (swarm) => swarm.ID,
+ });
+}
diff --git a/app/react/docker/proxy/queries/useVersion.ts b/app/react/docker/proxy/queries/useVersion.ts
index e2912b4c1..bcdbc738e 100644
--- a/app/react/docker/proxy/queries/useVersion.ts
+++ b/app/react/docker/proxy/queries/useVersion.ts
@@ -18,19 +18,20 @@ export async function getVersion(environmentId: EnvironmentId) {
}
export function useVersion(
- environmentId: EnvironmentId,
+ environmentId?: EnvironmentId,
select?: (info: SystemVersion) => TSelect
) {
return useQuery(
- ['environment', environmentId, 'docker', 'version'],
- () => getVersion(environmentId),
+ ['environment', environmentId!, 'docker', 'version'],
+ () => getVersion(environmentId!),
{
select,
+ enabled: !!environmentId,
}
);
}
-export function useApiVersion(environmentId: EnvironmentId) {
+export function useApiVersion(environmentId?: EnvironmentId) {
const query = useVersion(environmentId, (info) => info.ApiVersion);
return query.data ? parseFloat(query.data) : 0;
}
diff --git a/app/react/docker/stacks/view-models/stack.ts b/app/react/docker/stacks/view-models/stack.ts
index 63770900d..5a36e0eaa 100644
--- a/app/react/docker/stacks/view-models/stack.ts
+++ b/app/react/docker/stacks/view-models/stack.ts
@@ -67,8 +67,8 @@ export class StackViewModel implements IResource {
this.Id = stack.Id;
this.Type = stack.Type;
this.Name = stack.Name;
- this.EndpointId = stack.EndpointID;
- this.SwarmId = stack.SwarmID;
+ this.EndpointId = stack.EndpointId;
+ this.SwarmId = stack.SwarmId;
this.Env = stack.Env ? stack.Env : [];
this.Option = stack.Option;
this.IsComposeFormat = stack.IsComposeFormat;
diff --git a/app/react/docker/templates/StackFromCustomTemplateFormWidget/DeployForm.tsx b/app/react/docker/templates/StackFromCustomTemplateFormWidget/DeployForm.tsx
new file mode 100644
index 000000000..4218e65b0
--- /dev/null
+++ b/app/react/docker/templates/StackFromCustomTemplateFormWidget/DeployForm.tsx
@@ -0,0 +1,243 @@
+import { useRouter } from '@uirouter/react';
+import { Formik, Form } from 'formik';
+
+import { notifySuccess } from '@/portainer/services/notifications';
+import {
+ CreateStackPayload,
+ useCreateStack,
+} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
+import { AccessControlForm } from '@/react/portainer/access-control';
+import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
+import { NameField } from '@/react/common/stacks/CreateView/NameField';
+import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
+import {
+ isTemplateVariablesEnabled,
+ renderTemplate,
+} from '@/react/portainer/custom-templates/components/utils';
+import {
+ CustomTemplatesVariablesField,
+ getVariablesFieldDefaultValues,
+} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+import { StackType } from '@/react/common/stacks/types';
+import { toGitFormModel } from '@/react/portainer/gitops/types';
+import { AdvancedSettings } from '@/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings';
+
+import { Button } from '@@/buttons';
+import { FormActions } from '@@/form-components/FormActions';
+import { FormSection } from '@@/form-components/FormSection';
+import { WebEditorForm } from '@@/WebEditorForm';
+
+import { useSwarmId } from '../../proxy/queries/useSwarm';
+
+import { FormValues } from './types';
+import { useValidation } from './useValidation';
+
+export function DeployForm({
+ template,
+ unselect,
+ templateFile,
+ isDeployable,
+}: {
+ template: CustomTemplate;
+ templateFile: string;
+ unselect: () => void;
+ isDeployable: boolean;
+}) {
+ const router = useRouter();
+ const { user } = useCurrentUser();
+ const isEdgeAdminQuery = useIsEdgeAdmin();
+ const environmentId = useEnvironmentId();
+ const swarmIdQuery = useSwarmId(environmentId);
+ const mutation = useCreateStack();
+ const validation = useValidation({
+ isDeployable,
+ variableDefs: template.Variables,
+ isAdmin: isEdgeAdminQuery.isAdmin,
+ environmentId,
+ });
+
+ if (isEdgeAdminQuery.isLoading) {
+ return null;
+ }
+
+ const isGit = !!template.GitConfig;
+
+ const initialValues: FormValues = {
+ name: template.Title || '',
+ variables: getVariablesFieldDefaultValues(template.Variables),
+ accessControl: parseAccessControlFormData(
+ isEdgeAdminQuery.isAdmin,
+ user.Id
+ ),
+ fileContent: templateFile,
+ };
+
+ return (
+
+ {({ values, errors, setFieldValue, isValid }) => (
+
+ )}
+
+ );
+
+ function handleSubmit(values: FormValues) {
+ const payload = getPayload(values);
+
+ return mutation.mutate(payload, {
+ onSuccess() {
+ notifySuccess('Success', 'Stack created');
+ router.stateService.go('docker.stacks');
+ },
+ });
+ }
+
+ function getPayload(values: FormValues): CreateStackPayload {
+ const type =
+ template.Type === StackType.DockerCompose ? 'standalone' : 'swarm';
+ const isGit = !!template.GitConfig;
+ if (isGit) {
+ return type === 'standalone'
+ ? {
+ type,
+ method: 'git',
+ payload: {
+ name: values.name,
+ environmentId,
+ git: toGitFormModel(template.GitConfig),
+ accessControl: values.accessControl,
+ },
+ }
+ : {
+ type,
+ method: 'git',
+ payload: {
+ name: values.name,
+ environmentId,
+ swarmId: swarmIdQuery.data || '',
+ git: toGitFormModel(template.GitConfig),
+ accessControl: values.accessControl,
+ },
+ };
+ }
+
+ return type === 'standalone'
+ ? {
+ type,
+ method: 'string',
+ payload: {
+ name: values.name,
+ environmentId,
+ fileContent: values.fileContent,
+ accessControl: values.accessControl,
+ },
+ }
+ : {
+ type,
+ method: 'string',
+ payload: {
+ name: values.name,
+ environmentId,
+ swarmId: swarmIdQuery.data || '',
+ fileContent: values.fileContent,
+ accessControl: values.accessControl,
+ },
+ };
+ }
+}
+
+function advancedSettingsLabel(isOpen: boolean, isGit: boolean) {
+ if (isGit) {
+ return isOpen ? 'Hide stack' : 'View stack';
+ }
+
+ return isOpen ? 'Hide custom stack' : 'Customize stack';
+}
diff --git a/app/react/docker/templates/StackFromCustomTemplateFormWidget/StackFromCustomTemplateFormWidget.tsx b/app/react/docker/templates/StackFromCustomTemplateFormWidget/StackFromCustomTemplateFormWidget.tsx
new file mode 100644
index 000000000..4f4dc0aab
--- /dev/null
+++ b/app/react/docker/templates/StackFromCustomTemplateFormWidget/StackFromCustomTemplateFormWidget.tsx
@@ -0,0 +1,57 @@
+import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
+import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
+import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
+
+import { TextTip } from '@@/Tip/TextTip';
+
+import { useIsDeployable } from './useIsDeployable';
+import { DeployForm } from './DeployForm';
+import { TemplateLoadError } from './TemplateLoadError';
+
+export function StackFromCustomTemplateFormWidget({
+ template,
+ unselect,
+}: {
+ template: CustomTemplate;
+ unselect: () => void;
+}) {
+ const isDeployable = useIsDeployable(template.Type);
+ const fileQuery = useCustomTemplateFile(template.Id);
+
+ if (fileQuery.isLoading) {
+ return null;
+ }
+
+ return (
+
+ {fileQuery.isError && (
+
+ )}
+
+ {!isDeployable && (
+
+
+
+ This template type cannot be deployed on this environment.
+
+
+
+ )}
+ {fileQuery.isSuccess && isDeployable && (
+
+ )}
+
+ );
+}
diff --git a/app/react/docker/templates/StackFromCustomTemplateFormWidget/TemplateLoadError.tsx b/app/react/docker/templates/StackFromCustomTemplateFormWidget/TemplateLoadError.tsx
new file mode 100644
index 000000000..97d20fcd6
--- /dev/null
+++ b/app/react/docker/templates/StackFromCustomTemplateFormWidget/TemplateLoadError.tsx
@@ -0,0 +1,46 @@
+import { UserId } from '@/portainer/users/types';
+import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
+import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
+
+import { Link } from '@@/Link';
+import { FormError } from '@@/form-components/FormError';
+
+export function TemplateLoadError({
+ templateId,
+ creatorId,
+}: {
+ templateId: CustomTemplate['Id'];
+ creatorId: UserId;
+}) {
+ const { user } = useCurrentUser();
+ const isEdgeAdminQuery = useIsEdgeAdmin();
+
+ if (isEdgeAdminQuery.isLoading) {
+ return null;
+ }
+
+ const isAdminOrWriter = isEdgeAdminQuery.isAdmin || user.Id === creatorId;
+
+ return (
+
+ {isAdminOrWriter ? (
+ <>
+ Custom template could not be loaded, please{' '}
+
+ click here
+ {' '}
+ for configuration
+ >
+ ) : (
+ <>
+ Custom template could not be loaded, please contact your
+ administrator.
+ >
+ )}
+
+ );
+}
diff --git a/app/react/docker/templates/StackFromCustomTemplateFormWidget/index.ts b/app/react/docker/templates/StackFromCustomTemplateFormWidget/index.ts
new file mode 100644
index 000000000..cc5eac7c4
--- /dev/null
+++ b/app/react/docker/templates/StackFromCustomTemplateFormWidget/index.ts
@@ -0,0 +1 @@
+export { StackFromCustomTemplateFormWidget } from './StackFromCustomTemplateFormWidget';
diff --git a/app/react/docker/templates/StackFromCustomTemplateFormWidget/types.ts b/app/react/docker/templates/StackFromCustomTemplateFormWidget/types.ts
new file mode 100644
index 000000000..27ec78ae9
--- /dev/null
+++ b/app/react/docker/templates/StackFromCustomTemplateFormWidget/types.ts
@@ -0,0 +1,9 @@
+import { AccessControlFormData } from '@/react/portainer/access-control/types';
+import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+
+export interface FormValues {
+ name: string;
+ variables: VariablesFieldValue;
+ accessControl: AccessControlFormData;
+ fileContent: string;
+}
diff --git a/app/react/docker/templates/StackFromCustomTemplateFormWidget/useIsDeployable.ts b/app/react/docker/templates/StackFromCustomTemplateFormWidget/useIsDeployable.ts
new file mode 100644
index 000000000..2bb1aad67
--- /dev/null
+++ b/app/react/docker/templates/StackFromCustomTemplateFormWidget/useIsDeployable.ts
@@ -0,0 +1,20 @@
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { StackType } from '@/react/common/stacks/types';
+
+import { useIsSwarm } from '../../proxy/queries/useInfo';
+
+export function useIsDeployable(type: StackType) {
+ const environmentId = useEnvironmentId();
+
+ const isSwarm = useIsSwarm(environmentId);
+
+ switch (type) {
+ case StackType.DockerCompose:
+ return !isSwarm;
+ case StackType.DockerSwarm:
+ return isSwarm;
+ case StackType.Kubernetes:
+ default:
+ return false;
+ }
+}
diff --git a/app/react/docker/templates/StackFromCustomTemplateFormWidget/useValidation.ts b/app/react/docker/templates/StackFromCustomTemplateFormWidget/useValidation.ts
new file mode 100644
index 000000000..7fb55a7e4
--- /dev/null
+++ b/app/react/docker/templates/StackFromCustomTemplateFormWidget/useValidation.ts
@@ -0,0 +1,37 @@
+import { useMemo } from 'react';
+import { object, string } from 'yup';
+
+import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
+import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
+import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+export function useValidation({
+ environmentId,
+ isAdmin,
+ variableDefs,
+ isDeployable,
+}: {
+ variableDefs: Array;
+ isAdmin: boolean;
+ environmentId: EnvironmentId;
+ isDeployable: boolean;
+}) {
+ const name = useNameValidation(environmentId);
+
+ return useMemo(
+ () =>
+ object({
+ name: name.test({
+ name: 'is-deployable',
+ message: 'This template cannot be deployed on this environment',
+ test: () => isDeployable,
+ }),
+ accessControl: accessControlFormValidation(isAdmin),
+ fileContent: string().required('Required'),
+ variables: variablesFieldValidation(variableDefs),
+ }),
+ [isAdmin, isDeployable, name, variableDefs]
+ );
+}
diff --git a/app/react/docker/volumes/queries/build-url.ts b/app/react/docker/volumes/queries/build-url.ts
new file mode 100644
index 000000000..8d0e27093
--- /dev/null
+++ b/app/react/docker/volumes/queries/build-url.ts
@@ -0,0 +1,19 @@
+import { buildUrl as buildProxyUrl } from '@/react/docker/proxy/queries/build-url';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+export function buildUrl(
+ environmentId: EnvironmentId,
+ { action, id }: { id?: string; action?: string } = {}
+) {
+ let url = buildProxyUrl(environmentId, 'volumes');
+
+ if (id) {
+ url += `/${id}`;
+ }
+
+ if (action) {
+ url += `/${action}`;
+ }
+
+ return url;
+}
diff --git a/app/react/docker/volumes/queries/useCreateVolume.ts b/app/react/docker/volumes/queries/useCreateVolume.ts
new file mode 100644
index 000000000..53384303b
--- /dev/null
+++ b/app/react/docker/volumes/queries/useCreateVolume.ts
@@ -0,0 +1,26 @@
+import { Volume, VolumeCreateOptions } from 'docker-types/generated/1.41';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { buildUrl } from './build-url';
+
+export async function createVolume(
+ environmentId: EnvironmentId,
+ volume: VolumeCreateOptions
+) {
+ try {
+ const { data } = await axios.post(
+ buildUrl(environmentId, { action: 'create' }),
+ volume,
+ {
+ headers: {
+ 'X-Portainer-VolumeName': volume.Name || '',
+ },
+ }
+ );
+ return data;
+ } catch (error) {
+ throw parseAxiosError(error);
+ }
+}
diff --git a/app/react/docker/volumes/queries/useVolumes.ts b/app/react/docker/volumes/queries/useVolumes.ts
index 92cac3026..edc3e1911 100644
--- a/app/react/docker/volumes/queries/useVolumes.ts
+++ b/app/react/docker/volumes/queries/useVolumes.ts
@@ -2,10 +2,10 @@ import { useQuery } from 'react-query';
import { Volume } from 'docker-types/generated/1.41';
import axios, { parseAxiosError } from '@/portainer/services/axios';
-import { buildUrl as buildDockerUrl } from '@/react/docker/proxy/queries/build-url';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
+import { buildUrl } from './build-url';
export function useVolumes(
environmentId: EnvironmentId,
@@ -24,22 +24,10 @@ interface VolumesResponse {
export async function getVolumes(environmentId: EnvironmentId) {
try {
- const { data } = await axios.get(
- buildUrl(environmentId, 'volumes')
- );
+ const { data } = await axios.get(buildUrl(environmentId));
return data.Volumes;
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to retrieve volumes');
}
}
-
-function buildUrl(environmentId: EnvironmentId, action: string, id?: string) {
- let url = buildDockerUrl(environmentId, action);
-
- if (id) {
- url += `/${id}`;
- }
-
- return url;
-}
diff --git a/app/react/edge/edge-stacks/CreateView/.keep b/app/react/edge/edge-stacks/CreateView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx
index 184aeb614..69bdfc9d1 100644
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/AppTemplateFieldset.tsx
@@ -1,9 +1,11 @@
import { FormikErrors } from 'formik';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
-
-import { EnvVarsFieldset } from './EnvVarsFieldset';
-import { TemplateNote } from './TemplateNote';
+import {
+ EnvVarsFieldset,
+ EnvVarsValue,
+} from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
+import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
export function AppTemplateFieldset({
template,
@@ -12,16 +14,16 @@ export function AppTemplateFieldset({
errors,
}: {
template: TemplateViewModel;
- values: Record;
- onChange: (value: Record) => void;
- errors?: FormikErrors>;
+ values: EnvVarsValue;
+ onChange: (value: EnvVarsValue) => void;
+ errors?: FormikErrors;
}) {
return (
<>
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx
index 12bc0c847..8b85a0825 100644
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/CustomTemplateFieldset.tsx
@@ -1,10 +1,10 @@
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
+import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { Values } from './types';
-import { TemplateNote } from './TemplateNote';
export function CustomTemplateFieldset({
errors,
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx
index a48991558..d39ec9cbb 100644
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx
@@ -3,7 +3,8 @@ import { FormikErrors } from 'formik';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
-import { getDefaultValues as getAppVariablesDefaultValues } from './EnvVarsFieldset';
+import { getDefaultValues as getAppVariablesDefaultValues } from '../../../../portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
+
import { TemplateSelector } from './TemplateSelector';
import { SelectedTemplateValue, Values } from './types';
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx
deleted file mode 100644
index bc4ae926c..000000000
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-
-import { TemplateNote } from './TemplateNote';
-
-vi.mock('sanitize-html', () => ({
- default: (note: string) => note, // Mock the sanitize-html library to return the input as is
-}));
-
-test('renders template note', async () => {
- render();
-
- const templateNoteElement = screen.getByText(/Information/);
- expect(templateNoteElement).toBeInTheDocument();
-
- const noteElement = screen.getByText(/Test note/);
- expect(noteElement).toBeInTheDocument();
-});
-
-test('does not render template note when note is undefined', async () => {
- render();
-
- const templateNoteElement = screen.queryByText(/Information/);
- expect(templateNoteElement).not.toBeInTheDocument();
-});
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx
index eeabded15..e48d27566 100644
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx
+++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/validation.tsx
@@ -2,17 +2,19 @@ import { mixed, object, SchemaOf, string } from 'yup';
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
+import { envVarsFieldsetValidation } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
+import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
-import { envVarsFieldsetValidation } from './EnvVarsFieldset';
-
-export function validation({
- definitions,
+function validation({
+ customVariablesDefinitions,
+ envVarDefinitions,
}: {
- definitions: VariableDefinition[];
+ customVariablesDefinitions: VariableDefinition[];
+ envVarDefinitions: Array;
}) {
return object({
type: string().oneOf(['custom', 'app']).required(),
- envVars: envVarsFieldsetValidation()
+ envVars: envVarsFieldsetValidation(envVarDefinitions)
.optional()
.when('type', {
is: 'app',
@@ -20,7 +22,7 @@ export function validation({
}),
file: mixed().optional(),
template: object().optional().default(null),
- variables: variablesFieldValidation(definitions)
+ variables: variablesFieldValidation(customVariablesDefinitions)
.optional()
.when('type', {
is: 'custom',
diff --git a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx
deleted file mode 100644
index 8c6a84785..000000000
--- a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
-import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
-import { TemplateType } from '@/react/portainer/templates/app-templates/types';
-
-import { PageHeader } from '@@/PageHeader';
-
-export function AppTemplatesView() {
- const templatesQuery = useAppTemplates();
-
- return (
- <>
-
-
- ({
- to: 'edge.stacks.new',
- params: { templateId: template.Id, templateType: 'app' },
- })}
- disabledTypes={[TemplateType.Container]}
- fixedCategories={['edge']}
- storageKey="edge-app-templates"
- />
- >
- );
-}
diff --git a/app/react/edge/templates/AppTemplatesView/index.ts b/app/react/edge/templates/AppTemplatesView/index.ts
deleted file mode 100644
index d1b36c57b..000000000
--- a/app/react/edge/templates/AppTemplatesView/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { AppTemplatesView } from './AppTemplatesView';
diff --git a/app/react/portainer/access-control/AccessControlForm/index.ts b/app/react/portainer/access-control/AccessControlForm/index.ts
index 616459ecb..e36066ee9 100644
--- a/app/react/portainer/access-control/AccessControlForm/index.ts
+++ b/app/react/portainer/access-control/AccessControlForm/index.ts
@@ -1 +1,2 @@
export { AccessControlForm } from './AccessControlForm';
+export { validationSchema as accessControlFormValidation } from './AccessControlForm.validation';
diff --git a/app/react/portainer/templates/app-templates/AppTemplatesView.tsx b/app/react/portainer/templates/app-templates/AppTemplatesView.tsx
new file mode 100644
index 000000000..91d363ca4
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/AppTemplatesView.tsx
@@ -0,0 +1,92 @@
+import { useParamState } from '@/react/hooks/useParamState';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useInfo } from '@/react/docker/proxy/queries/useInfo';
+import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
+import { useAuthorizations } from '@/react/hooks/useUser';
+
+import { PageHeader } from '@@/PageHeader';
+
+import { TemplateType } from './types';
+import { useAppTemplates } from './queries/useAppTemplates';
+import { AppTemplatesList } from './AppTemplatesList';
+import { DeployForm } from './DeployFormWidget/DeployFormWidget';
+
+export function AppTemplatesView() {
+ const envId = useEnvironmentId(false);
+
+ const hasCreateAuthQuery = useAuthorizations([
+ 'DockerContainerCreate',
+ 'PortainerStackCreate',
+ ]);
+ const [selectedTemplateId, setSelectedTemplateId] = useParamState(
+ 'template',
+ (param) => (param ? parseInt(param, 10) : 0)
+ );
+ const templatesQuery = useAppTemplates();
+ const selectedTemplate = selectedTemplateId
+ ? templatesQuery.data?.find(
+ (template) => template.Id === selectedTemplateId
+ )
+ : undefined;
+
+ const { disabledTypes, fixedCategories, tableKey } = useViewFilter(envId);
+
+ return (
+ <>
+
+ {selectedTemplate && (
+ setSelectedTemplateId()}
+ />
+ )}
+
+ setSelectedTemplateId(template.Id)
+ : undefined
+ }
+ disabledTypes={disabledTypes}
+ fixedCategories={fixedCategories}
+ storageKey={tableKey}
+ templateLinkParams={
+ !envId
+ ? (template) => ({
+ to: 'edge.stacks.new',
+ params: { templateId: template.Id, templateType: 'app' },
+ })
+ : undefined
+ }
+ />
+ >
+ );
+}
+
+function useViewFilter(envId: number | undefined) {
+ const envInfoQuery = useInfo(envId);
+ const apiVersion = useApiVersion(envId);
+
+ if (!envId) {
+ // edge
+ return {
+ disabledTypes: [TemplateType.Container],
+ fixedCategories: ['edge'],
+ tableKey: 'edge-app-templates',
+ };
+ }
+
+ const showSwarmStacks =
+ apiVersion >= 1.25 &&
+ envInfoQuery.data &&
+ envInfoQuery.data.Swarm &&
+ envInfoQuery.data.Swarm.NodeID &&
+ envInfoQuery.data.Swarm.ControlAvailable;
+
+ return {
+ disabledTypes: !showSwarmStacks ? [TemplateType.SwarmStack] : [],
+ tableKey: 'docker-app-templates',
+ };
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings.tsx
new file mode 100644
index 000000000..ac9093182
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings.tsx
@@ -0,0 +1,52 @@
+import { Minus, Plus } from 'lucide-react';
+import { PropsWithChildren, ReactNode, useState } from 'react';
+
+import { Icon } from '@@/Icon';
+import { Button } from '@@/buttons';
+
+export function AdvancedSettings({
+ children,
+ label,
+}: PropsWithChildren<{
+ label: (isOpen: boolean) => ReactNode;
+}>) {
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+ <>
+ setIsOpen((value) => !value)}
+ label={label(isOpen)}
+ />
+
+ {isOpen ? children : null}
+ >
+ );
+}
+
+function AdvancedSettingsToggle({
+ label,
+ onClick,
+ isOpen,
+}: {
+ isOpen: boolean;
+ onClick: () => void;
+ label: ReactNode;
+}) {
+ const icon = isOpen ? Minus : Plus;
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/ContainerDeployForm.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/ContainerDeployForm.tsx
new file mode 100644
index 000000000..97794e578
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/ContainerDeployForm.tsx
@@ -0,0 +1,168 @@
+import { Formik, Form } from 'formik';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
+import { AccessControlForm } from '@/react/portainer/access-control';
+import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
+import { NameField } from '@/react/docker/containers/CreateView/BaseForm/NameField';
+import { NetworkSelector } from '@/react/docker/containers/components/NetworkSelector';
+import { PortsMappingField } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField';
+import { VolumesTab } from '@/react/docker/containers/CreateView/VolumesTab';
+import { HostsFileEntries } from '@/react/docker/containers/CreateView/NetworkTab/HostsFileEntries';
+import { LabelsTab } from '@/react/docker/containers/CreateView/LabelsTab';
+import { HostnameField } from '@/react/docker/containers/CreateView/NetworkTab/HostnameField';
+
+import { FormControl } from '@@/form-components/FormControl';
+import { FormSection } from '@@/form-components/FormSection';
+import { FormActions } from '@@/form-components/FormActions';
+import { Button } from '@@/buttons';
+
+import { TemplateViewModel } from '../../view-model';
+import { AdvancedSettings } from '../AdvancedSettings';
+import { EnvVarsFieldset } from '../EnvVarsFieldset';
+
+import { useValidation } from './useValidation';
+import { FormValues } from './types';
+import { useCreate } from './useCreate';
+
+export function ContainerDeployForm({
+ template,
+ unselect,
+}: {
+ template: TemplateViewModel;
+ unselect: () => void;
+}) {
+ const { user } = useCurrentUser();
+ const isEdgeAdminQuery = useIsEdgeAdmin();
+ const environmentId = useEnvironmentId();
+
+ const validation = useValidation({
+ isAdmin: isEdgeAdminQuery.isAdmin,
+ envVarDefinitions: template.Env,
+ });
+
+ const createMutation = useCreate(template);
+
+ if (!createMutation || isEdgeAdminQuery.isLoading) {
+ return null;
+ }
+
+ const initialValues: FormValues = {
+ name: template.Name || '',
+ envVars:
+ Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
+ {},
+ accessControl: parseAccessControlFormData(
+ isEdgeAdminQuery.isAdmin,
+ user.Id
+ ),
+ hostname: '',
+ hosts: [],
+ labels: [],
+ network: '',
+ ports: template.Ports.map((p) => ({ ...p, hostPort: p.hostPort || '' })),
+ volumes: template.Volumes.map((v) => ({
+ containerPath: v.container,
+ type: v.type === 'bind' ? 'bind' : 'volume',
+ readOnly: v.readonly,
+ name: v.type === 'bind' ? v.bind || '' : 'auto',
+ })),
+ };
+
+ return (
+
+ {({ values, errors, setFieldValue, isValid }) => (
+
+ )}
+
+ );
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/createContainerConfig.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/createContainerConfig.ts
new file mode 100644
index 000000000..d0e3832a5
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/createContainerConfig.ts
@@ -0,0 +1,64 @@
+import { commandStringToArray } from '@/docker/helpers/containers';
+import { parsePortBindingRequest } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel';
+import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
+import { CreateContainerRequest } from '@/react/docker/containers/CreateView/types';
+
+import { TemplateViewModel } from '../../view-model';
+
+import { FormValues } from './types';
+
+export function createContainerConfiguration(
+ template: TemplateViewModel,
+ values: FormValues
+): CreateContainerRequest {
+ let configuration: CreateContainerRequest = {
+ Env: [],
+ OpenStdin: false,
+ Tty: false,
+ ExposedPorts: {},
+ HostConfig: {
+ RestartPolicy: {
+ Name: 'no',
+ },
+ PortBindings: {},
+ Binds: [],
+ Privileged: false,
+ ExtraHosts: [],
+ },
+ Volumes: {},
+ Labels: {},
+ NetworkingConfig: {},
+ };
+
+ configuration = volumesTabUtils.toRequest(configuration, values.volumes);
+
+ configuration.HostConfig.NetworkMode = values.network;
+ configuration.HostConfig.Privileged = template.Privileged;
+ configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy };
+ configuration.HostConfig.ExtraHosts = values.hosts ? values.hosts : [];
+ configuration.Hostname = values.hostname;
+ configuration.Env = Object.entries(values.envVars).map(
+ ([name, value]) => `${name}=${value}`
+ );
+ configuration.Cmd = commandStringToArray(template.Command);
+ const portBindings = parsePortBindingRequest(values.ports);
+ configuration.HostConfig.PortBindings = portBindings;
+ configuration.ExposedPorts = Object.fromEntries(
+ Object.keys(portBindings).map((key) => [key, {}])
+ );
+ const consoleConfiguration = getConsoleConfiguration(template.Interactive);
+ configuration.OpenStdin = consoleConfiguration.openStdin;
+ configuration.Tty = consoleConfiguration.tty;
+ configuration.Labels = Object.fromEntries(
+ values.labels.filter((l) => !!l.name).map((l) => [l.name, l.value])
+ );
+ configuration.Image = template.RegistryModel.Image;
+ return configuration;
+}
+
+function getConsoleConfiguration(interactiveFlag: boolean) {
+ return {
+ openStdin: interactiveFlag,
+ tty: interactiveFlag,
+ };
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/types.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/types.ts
new file mode 100644
index 000000000..245d88bff
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/types.ts
@@ -0,0 +1,18 @@
+import { AccessControlFormData } from '@/react/portainer/access-control/types';
+import { PortMapping } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField';
+import { VolumesTabValues } from '@/react/docker/containers/CreateView/VolumesTab';
+import { LabelsTabValues } from '@/react/docker/containers/CreateView/LabelsTab';
+
+import { EnvVarsValue } from '../EnvVarsFieldset';
+
+export interface FormValues {
+ name: string;
+ network: string;
+ accessControl: AccessControlFormData;
+ ports: Array;
+ volumes: VolumesTabValues;
+ hosts: Array;
+ labels: LabelsTabValues;
+ hostname: string;
+ envVars: EnvVarsValue;
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useCreate.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useCreate.ts
new file mode 100644
index 000000000..13d519dcb
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useCreate.ts
@@ -0,0 +1,68 @@
+import { useRouter } from '@uirouter/react';
+
+import { notifySuccess } from '@/portainer/services/notifications';
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+import { useCreateOrReplaceMutation } from '@/react/docker/containers/CreateView/useCreateMutation';
+
+import { TemplateViewModel } from '../../view-model';
+
+import { FormValues } from './types';
+import { createContainerConfiguration } from './createContainerConfig';
+import { useCreateLocalVolumes } from './useCreateLocalVolumes';
+
+export function useCreate(template: TemplateViewModel) {
+ const router = useRouter();
+ const createVolumesMutation = useCreateLocalVolumes();
+ const createContainerMutation = useCreateOrReplaceMutation();
+ const environmentQuery = useCurrentEnvironment();
+
+ if (!environmentQuery.data) {
+ return null;
+ }
+
+ const environment = environmentQuery.data;
+
+ return {
+ onSubmit,
+ isLoading:
+ createVolumesMutation.isLoading || createContainerMutation.isLoading,
+ };
+
+ function onSubmit(values: FormValues) {
+ const autoVolumesCount = values.volumes.filter(
+ (v) => v.type === 'volume' && v.name === 'auto'
+ ).length;
+ createVolumesMutation.mutate(autoVolumesCount, {
+ onSuccess(autoVolumes) {
+ let index = 0;
+ const volumes = values.volumes.map((v) =>
+ v.type === 'volume' && v.name === 'auto'
+ ? { ...v, name: autoVolumes[index++].Name }
+ : v
+ );
+
+ createContainerMutation.mutate(
+ {
+ config: createContainerConfiguration(template, {
+ ...values,
+ volumes,
+ }),
+ values: {
+ name: values.name,
+ accessControl: values.accessControl,
+ imageName: template.RegistryModel.Image,
+ alwaysPull: true,
+ },
+ environment,
+ },
+ {
+ onSuccess() {
+ notifySuccess('Success', 'Container successfully created');
+ router.stateService.go('docker.containers');
+ },
+ }
+ );
+ },
+ });
+ }
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useCreateLocalVolumes.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useCreateLocalVolumes.ts
new file mode 100644
index 000000000..deae99f1b
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useCreateLocalVolumes.ts
@@ -0,0 +1,16 @@
+import { useMutation } from 'react-query';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { createVolume } from '@/react/docker/volumes/queries/useCreateVolume';
+
+export function useCreateLocalVolumes() {
+ const environmentId = useEnvironmentId();
+
+ return useMutation(async (count: number) =>
+ Promise.all(
+ Array.from({ length: count }).map(() =>
+ createVolume(environmentId, { Driver: 'local' })
+ )
+ )
+ );
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useValidation.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useValidation.ts
new file mode 100644
index 000000000..ce5552af0
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useValidation.ts
@@ -0,0 +1,37 @@
+import { object, string } from 'yup';
+import { useMemo } from 'react';
+
+import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
+import { hostnameSchema } from '@/react/docker/containers/CreateView/NetworkTab/HostnameField';
+import { hostFileSchema } from '@/react/docker/containers/CreateView/NetworkTab/HostsFileEntries';
+import { labelsTabUtils } from '@/react/docker/containers/CreateView/LabelsTab';
+import { nameValidation } from '@/react/docker/containers/CreateView/BaseForm/NameField';
+import { validationSchema as portSchema } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation';
+import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
+
+import { envVarsFieldsetValidation } from '../EnvVarsFieldset';
+import { TemplateEnv } from '../../types';
+
+export function useValidation({
+ isAdmin,
+ envVarDefinitions,
+}: {
+ isAdmin: boolean;
+ envVarDefinitions: Array;
+}) {
+ return useMemo(
+ () =>
+ object({
+ accessControl: accessControlFormValidation(isAdmin),
+ envVars: envVarsFieldsetValidation(envVarDefinitions),
+ hostname: hostnameSchema,
+ hosts: hostFileSchema,
+ labels: labelsTabUtils.validation(),
+ name: nameValidation(),
+ network: string().default(''),
+ ports: portSchema(),
+ volumes: volumesTabUtils.validation(),
+ }),
+ [envVarDefinitions, isAdmin]
+ );
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/DeployFormWidget.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/DeployFormWidget.tsx
new file mode 100644
index 000000000..f47f76445
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/DeployFormWidget.tsx
@@ -0,0 +1,42 @@
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { TemplateType } from '@/react/portainer/templates/app-templates/types';
+import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
+import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
+
+import { ContainerDeployForm } from './ContainerDeployForm/ContainerDeployForm';
+import { StackDeployForm } from './StackDeployForm/StackDeployForm';
+
+export function DeployForm({
+ template,
+ unselect,
+}: {
+ template: TemplateViewModel;
+ unselect: () => void;
+}) {
+ const Form = useForm(template);
+
+ return (
+
+
+
+ );
+}
+
+function useForm(template: TemplateViewModel) {
+ const envId = useEnvironmentId(false);
+
+ if (!envId) {
+ // for edge templates, return empty form
+ return () => null;
+ }
+
+ if (template.Type === TemplateType.Container) {
+ return ContainerDeployForm;
+ }
+
+ return StackDeployForm;
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.test.tsx
similarity index 90%
rename from app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx
rename to app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.test.tsx
index 2dceaa901..d5dbb2633 100644
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.test.tsx
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.test.tsx
@@ -15,14 +15,13 @@ test('renders EnvVarsFieldset component', () => {
{ name: 'VAR2', label: 'Variable 2', preset: false },
] as const;
const value = { VAR1: 'Value 1', VAR2: 'Value 2' };
- const errors = {};
render(
);
@@ -40,14 +39,13 @@ test('calls onChange when input value changes', async () => {
const onChange = vi.fn();
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
const value = { VAR1: 'Value 1' };
- const errors = {};
render(
);
@@ -63,15 +61,14 @@ test('calls onChange when input value changes', async () => {
test('renders error message when there are errors', () => {
const onChange = vi.fn();
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
- const value = { VAR1: 'Value 1' };
- const errors = { VAR1: 'Required' };
+ const value = { VAR1: '' };
render(
);
@@ -104,7 +101,10 @@ test('returns default values', () => {
});
test('validates env vars fieldset', () => {
- const schema = envVarsFieldsetValidation();
+ const schema = envVarsFieldsetValidation([
+ { name: 'VAR1' },
+ { name: 'VAR2' },
+ ]);
const validData = { VAR1: 'Value 1', VAR2: 'Value 2' };
const invalidData = { VAR1: '', VAR2: 'Value 2' };
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx
similarity index 82%
rename from app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx
rename to app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx
index f6506c2bd..dad33af07 100644
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset.tsx
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset.tsx
@@ -1,5 +1,5 @@
import { FormikErrors } from 'formik';
-import { SchemaOf, array, string } from 'yup';
+import { SchemaOf, object, string } from 'yup';
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
@@ -8,15 +8,17 @@ import { Input, Select } from '@@/form-components/Input';
type Value = Record;
+export { type Value as EnvVarsValue };
+
export function EnvVarsFieldset({
onChange,
options,
- value,
+ values,
errors,
}: {
options: Array;
onChange: (value: Value) => void;
- value: Value;
+ values: Value;
errors?: FormikErrors;
}) {
return (
@@ -25,7 +27,7 @@ export function EnvVarsFieldset({
- handleChange(env.name, value)}
errors={errors?.[env.name]}
/>
@@ -34,7 +36,7 @@ export function EnvVarsFieldset({
);
function handleChange(name: string, envValue: string) {
- onChange({ ...value, [name]: envValue });
+ onChange({ ...values, [name]: envValue });
}
}
@@ -94,11 +96,12 @@ export function getDefaultValues(definitions: Array): Value {
);
}
-export function envVarsFieldsetValidation(): SchemaOf {
- return (
- array()
- .transform((_, orig) => Object.values(orig))
- // casting to return the correct type - validation works as expected
- .of(string().required('Required')) as unknown as SchemaOf
+export function envVarsFieldsetValidation(
+ definitions: Array
+): SchemaOf {
+ return object(
+ Object.fromEntries(
+ definitions.map((v) => [v.name, string().required('Required')])
+ )
);
}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/StackDeployForm.tsx b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/StackDeployForm.tsx
new file mode 100644
index 000000000..e81e5d34e
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/StackDeployForm.tsx
@@ -0,0 +1,163 @@
+import { useRouter } from '@uirouter/react';
+import { Formik, Form } from 'formik';
+
+import { notifySuccess } from '@/portainer/services/notifications';
+import {
+ SwarmCreatePayload,
+ useCreateStack,
+} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
+import { AccessControlForm } from '@/react/portainer/access-control';
+import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
+import { TemplateType } from '@/react/portainer/templates/app-templates/types';
+import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
+import { NameField } from '@/react/common/stacks/CreateView/NameField';
+import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm';
+
+import { Button } from '@@/buttons';
+import { FormActions } from '@@/form-components/FormActions';
+import { FormSection } from '@@/form-components/FormSection';
+import { TextTip } from '@@/Tip/TextTip';
+
+import { EnvVarsFieldset } from '../EnvVarsFieldset';
+
+import { FormValues } from './types';
+import { useValidation } from './useValidation';
+import { useIsDeployable } from './useIsDeployable';
+
+export function StackDeployForm({
+ template,
+ unselect,
+}: {
+ template: TemplateViewModel;
+ unselect: () => void;
+}) {
+ const isDeployable = useIsDeployable(template.Type);
+
+ const router = useRouter();
+ const isEdgeAdminQuery = useIsEdgeAdmin();
+
+ const { user } = useCurrentUser();
+ const environmentId = useEnvironmentId();
+ const swarmIdQuery = useSwarmId(environmentId);
+ const mutation = useCreateStack();
+ const validation = useValidation({
+ isAdmin: isEdgeAdminQuery.isAdmin,
+ environmentId,
+ envVarDefinitions: template.Env,
+ });
+
+ if (isEdgeAdminQuery.isLoading) {
+ return null;
+ }
+
+ const initialValues: FormValues = {
+ name: template.Name || '',
+ envVars:
+ Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
+ {},
+ accessControl: parseAccessControlFormData(
+ isEdgeAdminQuery.isAdmin,
+ user.Id
+ ),
+ };
+
+ if (!isDeployable) {
+ return (
+
+
+ This template type cannot be deployed on this environment.
+
+
+ );
+ }
+
+ return (
+
+ {({ values, errors, setFieldValue, isValid }) => (
+
+ )}
+
+ );
+
+ function handleSubmit(values: FormValues) {
+ const type =
+ template.Type === TemplateType.ComposeStack ? 'standalone' : 'swarm';
+ const payload: SwarmCreatePayload['payload'] = {
+ name: values.name,
+ environmentId,
+
+ env: Object.entries(values.envVars).map(([name, value]) => ({
+ name,
+ value,
+ })),
+ swarmId: swarmIdQuery.data || '',
+ git: {
+ RepositoryURL: template.Repository.url,
+ ComposeFilePathInRepository: template.Repository.stackfile,
+ },
+ fromAppTemplate: true,
+ accessControl: values.accessControl,
+ };
+
+ return mutation.mutate(
+ {
+ type,
+ method: 'git',
+ payload,
+ },
+ {
+ onSuccess() {
+ notifySuccess('Success', 'Stack created');
+ router.stateService.go('docker.stacks');
+ },
+ }
+ );
+ }
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts
new file mode 100644
index 000000000..d94a2412e
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts
@@ -0,0 +1,7 @@
+import { AccessControlFormData } from '@/react/portainer/access-control/types';
+
+export interface FormValues {
+ name: string;
+ envVars: Record;
+ accessControl: AccessControlFormData;
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useIsDeployable.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useIsDeployable.ts
new file mode 100644
index 000000000..8e0ea976f
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useIsDeployable.ts
@@ -0,0 +1,19 @@
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { TemplateType } from '@/react/portainer/templates/app-templates/types';
+import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
+
+export function useIsDeployable(type: TemplateType) {
+ const environmentId = useEnvironmentId();
+
+ const isSwarm = useIsSwarm(environmentId);
+
+ switch (type) {
+ case TemplateType.ComposeStack:
+ case TemplateType.Container:
+ return true;
+ case TemplateType.SwarmStack:
+ return isSwarm;
+ default:
+ return false;
+ }
+}
diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useValidation.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useValidation.ts
new file mode 100644
index 000000000..f8417a0b2
--- /dev/null
+++ b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useValidation.ts
@@ -0,0 +1,33 @@
+import { useMemo } from 'react';
+import { SchemaOf, object } from 'yup';
+
+import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
+import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { envVarsFieldsetValidation } from '../EnvVarsFieldset';
+import { TemplateEnv } from '../../types';
+
+import { FormValues } from './types';
+
+export function useValidation({
+ environmentId,
+ isAdmin,
+ envVarDefinitions,
+}: {
+ isAdmin: boolean;
+ environmentId: EnvironmentId;
+ envVarDefinitions: Array;
+}): SchemaOf {
+ const name = useNameValidation(environmentId);
+
+ return useMemo(
+ () =>
+ object({
+ name,
+ accessControl: accessControlFormValidation(isAdmin),
+ envVars: envVarsFieldsetValidation(envVarDefinitions),
+ }),
+ [envVarDefinitions, isAdmin, name]
+ );
+}
diff --git a/app/react/portainer/templates/app-templates/types.ts b/app/react/portainer/templates/app-templates/types.ts
index b0433ad3c..f63d97435 100644
--- a/app/react/portainer/templates/app-templates/types.ts
+++ b/app/react/portainer/templates/app-templates/types.ts
@@ -1,3 +1,5 @@
+import { RestartPolicy } from 'docker-types/generated/1.41';
+
import { BasicTableSettings } from '@@/datatables/types';
import { Pair } from '../../settings/types';
@@ -152,7 +154,7 @@ export interface AppTemplate {
* Container restart policy.
* @example "on-failure"
*/
- restart_policy?: string;
+ restart_policy?: RestartPolicy['Name'];
/**
* Container hostname.
@@ -181,7 +183,7 @@ export interface TemplateRepository {
/**
* TemplateVolume represents a template volume configuration.
*/
-interface TemplateVolume {
+export interface TemplateVolume {
/**
* Path inside the container.
* @example "/data"
diff --git a/app/react/portainer/templates/app-templates/view-model.ts b/app/react/portainer/templates/app-templates/view-model.ts
index 1fa9a6802..94ea24bc3 100644
--- a/app/react/portainer/templates/app-templates/view-model.ts
+++ b/app/react/portainer/templates/app-templates/view-model.ts
@@ -1,4 +1,5 @@
import _ from 'lodash';
+import { RestartPolicy } from 'docker-types/generated/1.41';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
@@ -47,7 +48,7 @@ export class TemplateViewModel {
Interactive!: boolean;
- RestartPolicy!: string;
+ RestartPolicy!: RestartPolicy['Name'];
Hosts!: string[];
@@ -58,14 +59,14 @@ export class TemplateViewModel {
Volumes!: {
container: string;
readonly: boolean;
- type: string;
+ type: 'bind' | 'auto';
bind: string | null;
}[];
Ports!: {
hostPort: string | undefined;
containerPort: string;
- protocol: string;
+ protocol: 'tcp' | 'udp';
}[];
constructor(template: AppTemplate, version: string) {
@@ -134,7 +135,7 @@ function templatePorts(data: AppTemplate) {
hostAndContainerPort.length > 1
? hostAndContainerPort[1]
: hostAndContainerPort[0],
- protocol: portAndProtocol[1],
+ protocol: portAndProtocol[1] as 'tcp' | 'udp',
};
}) || []
);
@@ -145,7 +146,7 @@ function templateVolumes(data: AppTemplate) {
data.volumes?.map((v) => ({
container: v.container,
readonly: v.readonly || false,
- type: v.bind ? 'bind' : 'auto',
+ type: (v.bind ? 'bind' : 'auto') as 'bind' | 'auto',
bind: v.bind ? v.bind : null,
})) || []
);
diff --git a/app/react/portainer/templates/components/DeployWidget.tsx b/app/react/portainer/templates/components/DeployWidget.tsx
new file mode 100644
index 000000000..16654dd52
--- /dev/null
+++ b/app/react/portainer/templates/components/DeployWidget.tsx
@@ -0,0 +1,40 @@
+import { Rocket } from 'lucide-react';
+import { PropsWithChildren } from 'react';
+
+import { FallbackImage } from '@@/FallbackImage';
+import { Icon } from '@@/Icon';
+import { Widget } from '@@/Widget';
+
+import { TemplateNote } from './TemplateNote';
+
+export function DeployWidget({
+ logo,
+ note,
+ title,
+ children,
+}: PropsWithChildren<{
+ logo?: string;
+ note?: string;
+ title: string;
+}>) {
+ return (
+
+
+
+ } />
+ }
+ title={title}
+ />
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx b/app/react/portainer/templates/components/TemplateNote.tsx
similarity index 63%
rename from app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx
rename to app/react/portainer/templates/components/TemplateNote.tsx
index 49ead6eb3..ff7821518 100644
--- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.tsx
+++ b/app/react/portainer/templates/components/TemplateNote.tsx
@@ -1,16 +1,18 @@
import sanitize from 'sanitize-html';
-export function TemplateNote({ note }: { note: string | undefined }) {
+import { FormSection } from '@@/form-components/FormSection';
+
+export function TemplateNote({ note }: { note?: string }) {
if (!note) {
return null;
}
+
return (
-
+
);
}
diff --git a/app/react/portainer/templates/custom-templates/CreateView/CreateForm.tsx b/app/react/portainer/templates/custom-templates/CreateView/CreateForm.tsx
index 2d7a5e9da..2da7636af 100644
--- a/app/react/portainer/templates/custom-templates/CreateView/CreateForm.tsx
+++ b/app/react/portainer/templates/custom-templates/CreateView/CreateForm.tsx
@@ -17,9 +17,11 @@ import { InnerForm } from './InnerForm';
export function CreateForm({
environmentId,
viewType,
+ defaultType,
}: {
environmentId?: EnvironmentId;
viewType: 'kube' | 'docker' | 'edge';
+ defaultType: StackType;
}) {
const isEdge = !environmentId;
const router = useRouter();
@@ -28,8 +30,7 @@ export function CreateForm({
const buildMethods = useBuildMethods();
const initialValues = useInitialValues({
- defaultType:
- viewType === 'kube' ? StackType.Kubernetes : StackType.DockerCompose,
+ defaultType,
isEdge,
buildMethods: buildMethods.map((method) => method.value),
});
diff --git a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
index 3e61a67e8..8320c6ec1 100644
--- a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
+++ b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
@@ -1,15 +1,19 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
+import { StackType } from '@/react/common/stacks/types';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
-import { useViewType } from '../useViewType';
+import { TemplateViewType, useViewType } from '../useViewType';
import { CreateForm } from './CreateForm';
export function CreateView() {
const viewType = useViewType();
const environmentId = useEnvironmentId(false);
+ const isSwarm = useIsSwarm(environmentId, { enabled: viewType === 'docker' });
+ const defaultType = getDefaultType(viewType, isSwarm);
return (
@@ -25,7 +29,11 @@ export function CreateView() {
-
+
@@ -33,3 +41,17 @@ export function CreateView() {
);
}
+
+function getDefaultType(
+ viewType: TemplateViewType,
+ isSwarm: boolean
+): StackType {
+ switch (viewType) {
+ case 'docker':
+ return isSwarm ? StackType.DockerSwarm : StackType.DockerCompose;
+ case 'kube':
+ return StackType.Kubernetes;
+ default:
+ return StackType.DockerCompose;
+ }
+}
diff --git a/app/react/portainer/templates/custom-templates/queries/useCustomTemplates.ts b/app/react/portainer/templates/custom-templates/queries/useCustomTemplates.ts
index a5d457f5a..bf7a7ae44 100644
--- a/app/react/portainer/templates/custom-templates/queries/useCustomTemplates.ts
+++ b/app/react/portainer/templates/custom-templates/queries/useCustomTemplates.ts
@@ -30,7 +30,7 @@ export function useCustomTemplates>({
});
}
-async function getCustomTemplates({ type, edge = false }: Params = {}) {
+async function getCustomTemplates({ type, edge }: Params = {}) {
try {
const { data } = await axios.get(buildUrl(), {
params: {
diff --git a/app/react/sidebar/DockerSidebar.tsx b/app/react/sidebar/DockerSidebar.tsx
index 06b295f8a..e8be67f74 100644
--- a/app/react/sidebar/DockerSidebar.tsx
+++ b/app/react/sidebar/DockerSidebar.tsx
@@ -38,10 +38,9 @@ export function DockerSidebar({ environmentId, environment }: Props) {
isEnvironmentAdmin ||
environment.SecuritySettings.allowStackManagementForRegularUsers;
- const envInfoQuery = useInfo(
- environmentId,
- (info) => !!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable
- );
+ const envInfoQuery = useInfo(environmentId, {
+ select: (info) => !!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable,
+ });
const apiVersion = useApiVersion(environmentId);