From 6ff4fd3db2b5fe82c08012821e01fffabbdf74fa Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 11 Apr 2024 09:29:30 +0300 Subject: [PATCH] refactor(templates): migrate list view to react [EE-2296] (#10999) --- app/docker/__module.js | 5 +- app/docker/react/components/index.ts | 2 + app/docker/react/components/templates.ts | 17 + app/docker/services/containerService.js | 18 - app/docker/services/volumeService.js | 8 - app/edge/__module.js | 2 +- app/edge/react/views/templates.ts | 17 +- .../create-edge-stack-view.controller.js | 2 +- .../forms/stack-from-template-form/index.js | 16 - .../stackFromTemplateForm.html | 95 ------ app/portainer/helpers/templateHelper.js | 127 ------- .../components/custom-templates/index.ts | 13 - app/portainer/react/views/index.ts | 2 + app/portainer/react/views/templates.ts | 23 ++ app/portainer/services/api/templateService.js | 41 +-- .../customTemplatesView.html | 67 +--- .../customTemplatesViewController.js | 2 + app/portainer/views/templates/templates.html | 292 ---------------- .../views/templates/templatesController.js | 317 ------------------ .../common/stacks/CreateView/NameField.tsx | 58 ++++ app/react/common/stacks/queries/buildUrl.ts | 7 + app/react/common/stacks/queries/query-keys.ts | 3 + .../stacks/queries/useCreateStack/buildUrl.ts | 14 + .../createKubernetesStackFromFileContent.ts | 36 ++ .../createKubernetesStackFromGit.ts | 54 +++ .../createKubernetesStackFromUrl.ts | 32 ++ .../createStandaloneStackFromFile.ts | 44 +++ .../createStandaloneStackFromFileContent.ts | 40 +++ .../createStandaloneStackFromGit.ts | 63 ++++ .../createSwarmStackFromFile.ts | 50 +++ .../createSwarmStackFromFileContent.ts | 43 +++ .../useCreateStack/createSwarmStackFromGit.ts | 65 ++++ .../queries/useCreateStack/useCreateStack.ts | 302 +++++++++++++++++ app/react/common/stacks/queries/useStacks.ts | 23 ++ app/react/common/stacks/types.ts | 4 +- app/react/docker/app-templates/.keep | 0 .../CreateView/BaseForm/BaseForm.tsx | 24 +- .../CreateView/BaseForm/NameField.tsx | 32 ++ .../PortsMappingField.requestModel.ts | 25 +- .../BaseForm/PortsMappingField.validation.ts | 2 +- .../CreateView/BaseForm/validation.ts | 8 +- .../CreateView/CommandsTab/toRequest.ts | 48 +-- .../containers/CreateView/CreateView.tsx | 16 +- .../containers/CreateView/InnerForm.tsx | 5 - .../CreateView/NetworkTab/HostnameField.tsx | 27 ++ .../NetworkTab/HostsFileEntries.tsx | 56 ++++ .../CreateView/NetworkTab/NetworkTab.tsx | 55 +-- .../CreateView/NetworkTab/validation.ts | 8 +- .../ResourcesTab/RuntimeSelector.tsx | 16 +- .../containers/CreateView/VolumesTab/Item.tsx | 3 +- .../CreateView/VolumesTab/VolumeSelector.tsx | 10 +- .../CreateView/VolumesTab/VolumesTab.tsx | 24 +- .../CreateView/VolumesTab/context.ts | 5 +- .../CreateView/useCreateMutation.tsx | 80 ++--- .../docker/containers/ListView/ListView.tsx | 4 +- app/react/docker/containers/utils.ts | 13 +- app/react/docker/proxy/queries/useInfo.ts | 24 +- .../docker/proxy/queries/useServicePlugins.ts | 4 +- app/react/docker/proxy/queries/useSwarm.ts | 38 +++ app/react/docker/proxy/queries/useVersion.ts | 9 +- app/react/docker/stacks/view-models/stack.ts | 4 +- .../DeployForm.tsx | 243 ++++++++++++++ .../StackFromCustomTemplateFormWidget.tsx | 57 ++++ .../TemplateLoadError.tsx | 46 +++ .../index.ts | 1 + .../types.ts | 9 + .../useIsDeployable.ts | 20 ++ .../useValidation.ts | 37 ++ app/react/docker/volumes/queries/build-url.ts | 19 ++ .../docker/volumes/queries/useCreateVolume.ts | 26 ++ .../docker/volumes/queries/useVolumes.ts | 16 +- app/react/edge/edge-stacks/CreateView/.keep | 0 .../TemplateFieldset/AppTemplateFieldset.tsx | 16 +- .../CustomTemplateFieldset.tsx | 2 +- .../TemplateFieldset/TemplateFieldset.tsx | 3 +- .../TemplateFieldset/TemplateNote.test.tsx | 25 -- .../TemplateFieldset/validation.tsx | 16 +- .../AppTemplatesView/AppTemplatesView.tsx | 26 -- .../edge/templates/AppTemplatesView/index.ts | 1 - .../access-control/AccessControlForm/index.ts | 1 + .../app-templates/AppTemplatesView.tsx | 92 +++++ .../DeployFormWidget/AdvancedSettings.tsx | 52 +++ .../ContainerDeployForm.tsx | 168 ++++++++++ .../createContainerConfig.ts | 64 ++++ .../ContainerDeployForm/types.ts | 18 + .../ContainerDeployForm/useCreate.ts | 68 ++++ .../useCreateLocalVolumes.ts | 16 + .../ContainerDeployForm/useValidation.ts | 37 ++ .../DeployFormWidget/DeployFormWidget.tsx | 42 +++ .../EnvVarsFieldset.test.tsx | 22 +- .../DeployFormWidget}/EnvVarsFieldset.tsx | 25 +- .../StackDeployForm/StackDeployForm.tsx | 163 +++++++++ .../DeployFormWidget/StackDeployForm/types.ts | 7 + .../StackDeployForm/useIsDeployable.ts | 19 ++ .../StackDeployForm/useValidation.ts | 33 ++ .../templates/app-templates/types.ts | 6 +- .../templates/app-templates/view-model.ts | 11 +- .../templates/components/DeployWidget.tsx | 40 +++ .../templates/components}/TemplateNote.tsx | 12 +- .../CreateView/CreateForm.tsx | 5 +- .../CreateView/CreateView.tsx | 26 +- .../queries/useCustomTemplates.ts | 2 +- app/react/sidebar/DockerSidebar.tsx | 7 +- 103 files changed, 2628 insertions(+), 1315 deletions(-) create mode 100644 app/docker/react/components/templates.ts delete mode 100644 app/portainer/components/forms/stack-from-template-form/index.js delete mode 100644 app/portainer/components/forms/stack-from-template-form/stackFromTemplateForm.html delete mode 100644 app/portainer/helpers/templateHelper.js create mode 100644 app/portainer/react/views/templates.ts delete mode 100644 app/portainer/views/templates/templates.html delete mode 100644 app/portainer/views/templates/templatesController.js create mode 100644 app/react/common/stacks/CreateView/NameField.tsx create mode 100644 app/react/common/stacks/queries/buildUrl.ts create mode 100644 app/react/common/stacks/queries/query-keys.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/buildUrl.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromFileContent.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromUrl.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromFile.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromFileContent.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createSwarmStackFromFile.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createSwarmStackFromFileContent.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts create mode 100644 app/react/common/stacks/queries/useCreateStack/useCreateStack.ts create mode 100644 app/react/common/stacks/queries/useStacks.ts delete mode 100644 app/react/docker/app-templates/.keep create mode 100644 app/react/docker/containers/CreateView/BaseForm/NameField.tsx create mode 100644 app/react/docker/containers/CreateView/NetworkTab/HostnameField.tsx create mode 100644 app/react/docker/containers/CreateView/NetworkTab/HostsFileEntries.tsx create mode 100644 app/react/docker/proxy/queries/useSwarm.ts create mode 100644 app/react/docker/templates/StackFromCustomTemplateFormWidget/DeployForm.tsx create mode 100644 app/react/docker/templates/StackFromCustomTemplateFormWidget/StackFromCustomTemplateFormWidget.tsx create mode 100644 app/react/docker/templates/StackFromCustomTemplateFormWidget/TemplateLoadError.tsx create mode 100644 app/react/docker/templates/StackFromCustomTemplateFormWidget/index.ts create mode 100644 app/react/docker/templates/StackFromCustomTemplateFormWidget/types.ts create mode 100644 app/react/docker/templates/StackFromCustomTemplateFormWidget/useIsDeployable.ts create mode 100644 app/react/docker/templates/StackFromCustomTemplateFormWidget/useValidation.ts create mode 100644 app/react/docker/volumes/queries/build-url.ts create mode 100644 app/react/docker/volumes/queries/useCreateVolume.ts delete mode 100644 app/react/edge/edge-stacks/CreateView/.keep delete mode 100644 app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateNote.test.tsx delete mode 100644 app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx delete mode 100644 app/react/edge/templates/AppTemplatesView/index.ts create mode 100644 app/react/portainer/templates/app-templates/AppTemplatesView.tsx create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings.tsx create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/ContainerDeployForm.tsx create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/createContainerConfig.ts create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/types.ts create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useCreate.ts create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useCreateLocalVolumes.ts create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/ContainerDeployForm/useValidation.ts create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/DeployFormWidget.tsx rename app/react/{edge/edge-stacks/CreateView/TemplateFieldset => portainer/templates/app-templates/DeployFormWidget}/EnvVarsFieldset.test.tsx (90%) rename app/react/{edge/edge-stacks/CreateView/TemplateFieldset => portainer/templates/app-templates/DeployFormWidget}/EnvVarsFieldset.tsx (82%) create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/StackDeployForm.tsx create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/types.ts create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useIsDeployable.ts create mode 100644 app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useValidation.ts create mode 100644 app/react/portainer/templates/components/DeployWidget.tsx rename app/react/{edge/edge-stacks/CreateView/TemplateFieldset => portainer/templates/components}/TemplateNote.tsx (63%) 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 @@ -
- - - -
- -
-
Information
-
-
-
-
- -
Configuration
- -
- -
- -
-
-
-

- - This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123'). -

-

This field is required.

-
-
-
-
-
- - - -
- -
- - -
-
- - - - - - - -
Actions
-
-
- - -
-
-

{{ $ctrl.state.formValidationError }}

-
-
-
-
-

This template type cannot be deployed on this environment.

-
-
-
-
- -
-
-
-
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 - . -

-
-
- -
-
-
+ - -
- - - - - -
- - - -
- -
-
Information
-
-
-
-
- -
Configuration
- -
- -
- -
-
- - -
- -
- -
-
- - -
- -
- - -
-
- - - - - -
- -
-
- -
-
Portainer will automatically assign a port if you leave the host port empty.
-
- -
- host - -
- - - -
- container - -
- - -
-
- - -
- -
- -
-
-
- - Add map additional port - -
-
-
- - -
-
- -
-
Portainer will automatically create and map a local volume when using the auto option.
-
- -
- -
- container - -
- - -
-
- - - -
- -
- -
- - -
- - -
-
- volume -
- -
-
-
- - -
- host - -
- - -
-
- - -
-
- -
- -
-
-
- - Add map additional volume - -
-
-
- - -
-
- - -
-
-
- value - -
- -
-
-
- - Add additional entry - -
-
-
- - -
-
- - -
-
-
- name - -
-
- value - -
- -
-
- -
- Add label -
-
-
- - -
- -
- -
-
- -
- - -
Actions
-
-
- - - {{ state.formValidationError }} -
-
- -
-
-
-
- -
- - 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 }) => ( +
+ + setFieldValue('name', v)} + errors={errors.name} + /> + + + {isTemplateVariablesEnabled && ( + { + setFieldValue('variables', v); + const newFile = renderTemplate( + templateFile, + v, + template.Variables + ); + setFieldValue('fileContent', newFile); + }} + value={values.variables} + errors={errors.variables} + /> + )} + + advancedSettingsLabel(isOpen, isGit)} + > + { + if (isGit) { + return; + } + setFieldValue('fileContent', value); + }} + yaml + error={errors.fileContent} + placeholder="Define or paste the content of your docker compose file here" + readonly={isGit} + data-cy="custom-template-creation-editor" + > +

+ You can get more information about Compose file format in the{' '} + + official documentation + + . +

+
+
+ + setFieldValue('accessControl', values)} + values={values.accessControl} + errors={errors.accessControl} + environmentId={environmentId} + /> + + + + + + )} +
+ ); + + 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 }) => ( +
+ + setFieldValue('name', v)} + error={errors.name} + /> + + + setFieldValue('network', v)} + /> + + + setFieldValue('envVars', values)} + errors={errors.envVars} + options={template.Env || []} + /> + + + setFieldValue('accessControl', values)} + values={values.accessControl} + errors={errors.accessControl} + environmentId={environmentId} + /> + + + isOpen ? 'Hide advanced options' : 'Show advanced options' + } + > + setFieldValue('ports', v)} + errors={errors.ports} + /> + + setFieldValue('volumes', v)} + values={values.volumes} + errors={errors.volumes} + allowAuto + /> + + setFieldValue('hosts', v)} + errors={errors?.hosts} + /> + + setFieldValue('labels', v)} + errors={errors?.labels} + /> + + setFieldValue('hostname', v)} + error={errors.hostname} + /> + + + + + + + )} +
+ ); +} 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 }) => ( + + + setFieldValue('name', v)} + errors={errors.name} + /> + + setFieldValue('envVars', values)} + errors={errors.envVars} + options={template.Env || []} + /> + + + setFieldValue('accessControl', values)} + values={values.accessControl} + errors={errors.accessControl} + environmentId={environmentId} + /> + + + + + + )} + + ); + + 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 ( -
-
Information
+
-
+
); } 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);