diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index 99a95a068..9c5687f67 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -115,7 +115,6 @@ --bg-md-checkbox-color: var(--grey-12); --bg-form-control-disabled-color: var(--grey-11); --bg-modal-content-color: var(--white-color); - --bg-nav-container-color: var(--ui-gray-2); --bg-navtabs-hover-color: var(--grey-16); --bg-nav-tab-active-color: var(--ui-gray-4); --bg-table-selected-color: var(--grey-14); @@ -232,7 +231,6 @@ --border-blocklist: var(--ui-gray-5); --border-blocklist-item-selected-color: var(--grey-46); --border-widget: var(--ui-gray-5); - --border-nav-container-color: var(--ui-gray-5); --border-stepper-color: var(--ui-gray-4); --shadow-box-color: 0 3px 10px -2px var(--grey-50); @@ -287,7 +285,7 @@ --bg-md-checkbox-color: var(--grey-31); --bg-form-control-disabled-color: var(--grey-3); --bg-modal-content-color: var(--grey-1); - --bg-nav-container-color: var(--ui-gray-iron-10); + --bg-navtabs-hover-color: var(--grey-3); --bg-nav-tab-active-color: var(--grey-2); --bg-table-selected-color: var(--grey-3); @@ -405,7 +403,6 @@ --border-bootbox: var(--ui-gray-9); --border-widget: var(--grey-3); --border-pagination-color: var(--grey-1); - --border-nav-container-color: var(--ui-gray-neutral-8); --border-stepper-color: var(--ui-gray-warm-9); --blue-color: var(--blue-2); @@ -481,7 +478,7 @@ --bg-tooltip-color: var(--black-color); --bg-table-selected-color: var(--grey-3); --bg-pre-color: var(--grey-2); - --bg-nav-container-color: var(--ui-black); + --bg-navtabs-hover-color: var(--grey-3); --bg-nav-tab-active-color: var(--ui-black); --bg-btn-default-color: var(--black-color); @@ -567,7 +564,6 @@ --border-bootbox: var(--black-color); --border-blocklist: var(--white-color); --border-widget: var(--white-color); - --border-nav-container-color: var(--ui-white); --border-stepper-color: var(--ui-gray-warm-9); --shadow-box-color: none; diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index fc85bf7ba..ed7b120ff 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -87,13 +87,6 @@ code { background-color: var(--bg-code-color); } -.nav-container { - border: 1px solid var(--border-nav-container-color); - background-color: var(--bg-nav-container-color); - border-radius: 8px; - padding: 10px; -} - .nav-tabs { border-bottom: 1px solid var(--border-navtabs-color); } diff --git a/app/docker/react/components/containers.ts b/app/docker/react/components/containers.ts index 257807eeb..e4fd64690 100644 --- a/app/docker/react/components/containers.ts +++ b/app/docker/react/components/containers.ts @@ -1,47 +1,9 @@ import angular from 'angular'; -import { ComponentProps } from 'react'; -import { withUIRouter } from '@/react-tools/withUIRouter'; -import { withReactQuery } from '@/react-tools/withReactQuery'; -import { withFormValidation } from '@/react-tools/withFormValidation'; -import { r2a } from '@/react-tools/react2angular'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { r2a } from '@/react-tools/react2angular'; import { ContainerNetworksDatatable } from '@/react/docker/containers/ItemView/ContainerNetworksDatatable'; -import { - CommandsTab, - CommandsTabValues, - commandsTabValidation, -} from '@/react/docker/containers/CreateView/CommandsTab'; -import { - EnvVarsTab, - envVarsTabUtils, -} from '@/react/docker/containers/CreateView/EnvVarsTab'; -import { - VolumesTab, - volumesTabUtils, -} from '@/react/docker/containers/CreateView/VolumesTab'; -import { - networkTabUtils, - NetworkTab, - type NetworkTabValues, -} from '@/react/docker/containers/CreateView/NetworkTab'; -import { - ResourcesTab, - resourcesTabUtils, - type ResourcesTabValues, -} from '@/react/docker/containers/CreateView/ResourcesTab'; -import { - CapabilitiesTab, - capabilitiesTabUtils, -} from '@/react/docker/containers/CreateView/CapabilitiesTab'; -import { - RestartPolicyTab, - restartPolicyTabUtils, -} from '@/react/docker/containers/CreateView/RestartPolicyTab'; -import { - LabelsTab, - labelsTabUtils, -} from '@/react/docker/containers/CreateView/LabelsTab'; const ngModule = angular .module('portainer.docker.react.components.containers', []) @@ -55,74 +17,3 @@ const ngModule = angular ); export const containersModule = ngModule.name; - -withFormValidation, CommandsTabValues>( - ngModule, - withUIRouter(withReactQuery(CommandsTab)), - 'dockerCreateContainerCommandsTab', - ['apiVersion'], - commandsTabValidation -); - -withFormValidation( - ngModule, - withUIRouter(withReactQuery(EnvVarsTab)), - 'dockerCreateContainerEnvVarsTab', - [], - envVarsTabUtils.validation -); - -withFormValidation( - ngModule, - withUIRouter(withReactQuery(VolumesTab)), - 'dockerCreateContainerVolumesTab', - ['allowBindMounts'], - volumesTabUtils.validation -); - -withFormValidation, NetworkTabValues>( - ngModule, - withUIRouter(withReactQuery(NetworkTab)), - 'dockerCreateContainerNetworkTab', - [], - networkTabUtils.validation -); - -withFormValidation, ResourcesTabValues>( - ngModule, - withUIRouter(withReactQuery(ResourcesTab)), - 'dockerCreateContainerResourcesTab', - [ - 'allowPrivilegedMode', - 'isDevicesFieldVisible', - 'isInitFieldVisible', - 'isSysctlFieldVisible', - 'isDuplicate', - 'isImageInvalid', - 'redeploy', - ], - resourcesTabUtils.validation -); - -withFormValidation( - ngModule, - CapabilitiesTab, - 'dockerCreateContainerCapabilitiesTab', - [], - capabilitiesTabUtils.validation -); -withFormValidation( - ngModule, - RestartPolicyTab, - 'dockerCreateContainerRestartPolicyTab', - [], - restartPolicyTabUtils.validation -); - -withFormValidation( - ngModule, - withUIRouter(withReactQuery(LabelsTab)), - 'dockerCreateContainerLabelsTab', - [], - labelsTabUtils.validation -); diff --git a/app/docker/react/views/containers.ts b/app/docker/react/views/containers.ts index f48ca5e59..453f49fd9 100644 --- a/app/docker/react/views/containers.ts +++ b/app/docker/react/views/containers.ts @@ -7,9 +7,14 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { LogView } from '@/react/docker/containers/LogView'; +import { CreateView } from '@/react/docker/containers/CreateView'; export const containersModule = angular .module('portainer.docker.react.views.containers', []) + .component( + 'createContainerView', + r2a(withUIRouter(withCurrentUser(CreateView)), []) + ) .component( 'containersView', r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), ['endpoint']) @@ -77,8 +82,7 @@ function config($stateRegistryProvider: StateRegistry) { url: '/new?nodeName&from', views: { 'content@': { - templateUrl: '~@/docker/views/containers/create/createcontainer.html', - controller: 'CreateContainerController', + component: 'createContainerView', }, }, }); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js deleted file mode 100644 index 0c939508a..000000000 --- a/app/docker/views/containers/create/createContainerController.js +++ /dev/null @@ -1,686 +0,0 @@ -import _ from 'lodash-es'; - -import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; - -import { confirmDestructive } from '@@/modals/confirm'; -import { FeatureId } from '@/react/portainer/feature-flags/enums'; -import { buildConfirmButton } from '@@/modals/utils'; - -import { commandsTabUtils } from '@/react/docker/containers/CreateView/CommandsTab'; -import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab'; -import { networkTabUtils } from '@/react/docker/containers/CreateView/NetworkTab'; -import { capabilitiesTabUtils } from '@/react/docker/containers/CreateView/CapabilitiesTab'; -import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; -import { ContainerDetailsViewModel } from '@/docker/models/container'; -import { labelsTabUtils } from '@/react/docker/containers/CreateView/LabelsTab'; - -import './createcontainer.css'; -import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab'; -import { getContainers } from '@/react/docker/containers/queries/containers'; -import { resourcesTabUtils } from '@/react/docker/containers/CreateView/ResourcesTab'; -import { restartPolicyTabUtils } from '@/react/docker/containers/CreateView/RestartPolicyTab'; - -angular.module('portainer.docker').controller('CreateContainerController', [ - '$q', - '$scope', - '$async', - '$state', - '$timeout', - '$transition$', - '$analytics', - 'Container', - 'ContainerHelper', - 'ImageHelper', - 'NetworkService', - 'ResourceControlService', - 'Authentication', - 'Notifications', - 'ContainerService', - 'ImageService', - 'FormValidator', - 'RegistryService', - 'SystemService', - 'SettingsService', - 'HttpRequestHelper', - 'endpoint', - function ( - $q, - $scope, - $async, - $state, - $timeout, - $transition$, - $analytics, - Container, - ContainerHelper, - ImageHelper, - NetworkService, - ResourceControlService, - Authentication, - Notifications, - ContainerService, - ImageService, - FormValidator, - RegistryService, - SystemService, - SettingsService, - HttpRequestHelper, - endpoint - ) { - $scope.create = create; - $scope.endpoint = endpoint; - $scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK; - $scope.formValues = { - alwaysPull: true, - GPU: { - enabled: false, - useSpecific: false, - selectedGPUs: ['all'], - capabilities: ['compute', 'utility'], - }, - ExtraHosts: [], - MacAddress: '', - IPv4: '', - IPv6: '', - DnsPrimary: '', - DnsSecondary: '', - AccessControlData: new AccessControlFormData(), - NodeName: null, - RegistryModel: new PorImageRegistryModel(), - - commands: commandsTabUtils.getDefaultViewModel(), - envVars: envVarsTabUtils.getDefaultViewModel(), - volumes: volumesTabUtils.getDefaultViewModel(), - network: networkTabUtils.getDefaultViewModel(), - resources: resourcesTabUtils.getDefaultViewModel(), - capabilities: capabilitiesTabUtils.getDefaultViewModel(), - restartPolicy: restartPolicyTabUtils.getDefaultViewModel(), - labels: labelsTabUtils.getDefaultViewModel(), - }; - - $scope.state = { - formValidationError: '', - actionInProgress: false, - mode: '', - pullImageValidity: true, - settingUnlimitedResources: false, - containerIsLoaded: false, - }; - - $scope.onAlwaysPullChange = onAlwaysPullChange; - $scope.handlePublishAllPortsChange = handlePublishAllPortsChange; - $scope.handleAutoRemoveChange = handleAutoRemoveChange; - $scope.handlePrivilegedChange = handlePrivilegedChange; - $scope.handleInitChange = handleInitChange; - $scope.handleCommandsChange = handleCommandsChange; - $scope.handleEnvVarsChange = handleEnvVarsChange; - - function handleCommandsChange(commands) { - return $scope.$evalAsync(() => { - $scope.formValues.commands = commands; - }); - } - - function handleEnvVarsChange(value) { - return $scope.$evalAsync(() => { - $scope.formValues.envVars = value; - }); - } - - $scope.onVolumesChange = function (volumes) { - return $scope.$evalAsync(() => { - $scope.formValues.volumes = volumes; - }); - }; - $scope.onNetworkChange = function (network) { - return $scope.$evalAsync(() => { - $scope.formValues.network = network; - }); - }; - $scope.onLabelsChange = function (labels) { - return $scope.$evalAsync(() => { - $scope.formValues.labels = labels; - }); - }; - - $scope.onResourcesChange = function (resources) { - return $scope.$evalAsync(() => { - $scope.formValues.resources = resources; - }); - }; - - $scope.onCapabilitiesChange = function (capabilities) { - return $scope.$evalAsync(() => { - $scope.formValues.capabilities = capabilities; - }); - }; - - $scope.onRestartPolicyChange = function (restartPolicy) { - return $scope.$evalAsync(() => { - $scope.formValues.restartPolicy = restartPolicy; - }); - }; - - function onAlwaysPullChange(checked) { - return $scope.$evalAsync(() => { - $scope.formValues.alwaysPull = checked; - }); - } - - function handlePublishAllPortsChange(checked) { - return $scope.$evalAsync(() => { - $scope.config.HostConfig.PublishAllPorts = checked; - }); - } - - function handleAutoRemoveChange(checked) { - return $scope.$evalAsync(() => { - $scope.config.HostConfig.AutoRemove = checked; - }); - } - - function handlePrivilegedChange(checked) { - return $scope.$evalAsync(() => { - $scope.config.HostConfig.Privileged = checked; - }); - } - - function handleInitChange(checked) { - return $scope.$evalAsync(() => { - $scope.config.HostConfig.Init = checked; - }); - } - - $scope.refreshSlider = function () { - $timeout(function () { - $scope.$broadcast('rzSliderForceRender'); - }); - }; - - $scope.onImageNameChange = function () { - $scope.formValues.CmdMode = 'default'; - $scope.formValues.EntrypointMode = 'default'; - }; - - $scope.setPullImageValidity = setPullImageValidity; - function setPullImageValidity(validity) { - if (!validity) { - $scope.formValues.alwaysPull = false; - } - $scope.state.pullImageValidity = validity; - } - - $scope.config = { - Image: '', - Env: [], - Cmd: null, - MacAddress: '', - ExposedPorts: {}, - Entrypoint: null, - WorkingDir: '', - User: '', - HostConfig: { - RestartPolicy: { - Name: 'no', - }, - PortBindings: [], - PublishAllPorts: false, - Binds: [], - AutoRemove: false, - NetworkMode: 'bridge', - Privileged: false, - Init: false, - Runtime: null, - ExtraHosts: [], - Devices: [], - DeviceRequests: [], - CapAdd: [], - CapDrop: [], - Sysctls: {}, - LogConfig: { - Type: '', - Config: {}, - }, - }, - NetworkingConfig: { - EndpointsConfig: {}, - }, - Labels: {}, - }; - - $scope.addPortBinding = function () { - $scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' }); - }; - - $scope.removePortBinding = function (index) { - $scope.config.HostConfig.PortBindings.splice(index, 1); - }; - - $scope.addExtraHost = function () { - $scope.formValues.ExtraHosts.push({ value: '' }); - }; - - $scope.removeExtraHost = function (index) { - $scope.formValues.ExtraHosts.splice(index, 1); - }; - - $scope.addDevice = function () { - $scope.config.HostConfig.Devices.push({ pathOnHost: '', pathInContainer: '' }); - }; - - $scope.removeDevice = function (index) { - $scope.config.HostConfig.Devices.splice(index, 1); - }; - - $scope.onGpuChange = function (values) { - return $async(async () => { - $scope.formValues.GPU = values; - }); - }; - - $scope.addSysctl = function () { - $scope.formValues.Sysctls.push({ name: '', value: '' }); - }; - - $scope.removeSysctl = function (index) { - $scope.formValues.Sysctls.splice(index, 1); - }; - - $scope.fromContainerMultipleNetworks = false; - - function prepareImageConfig(config) { - const imageConfig = ImageHelper.createImageConfigForContainer($scope.formValues.RegistryModel); - config.Image = imageConfig.fromImage; - } - - function preparePortBindings(config) { - const bindings = ContainerHelper.preparePortBindings(config.HostConfig.PortBindings); - config.ExposedPorts = {}; - _.forEach(bindings, (_, key) => (config.ExposedPorts[key] = {})); - config.HostConfig.PortBindings = bindings; - } - - function prepareConfiguration() { - var config = angular.copy($scope.config); - config = commandsTabUtils.toRequest(config, $scope.formValues.commands); - config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars); - config = volumesTabUtils.toRequest(config, $scope.formValues.volumes); - config = networkTabUtils.toRequest(config, $scope.formValues.network, $scope.fromContainer.Id); - config = resourcesTabUtils.toRequest(config, $scope.formValues.resources); - config = capabilitiesTabUtils.toRequest(config, $scope.formValues.capabilities); - config = restartPolicyTabUtils.toRequest(config, $scope.formValues.restartPolicy); - config = labelsTabUtils.toRequest(config, $scope.formValues.labels); - - prepareImageConfig(config); - preparePortBindings(config); - return config; - } - - function loadFromContainerPortBindings() { - const bindings = ContainerHelper.sortAndCombinePorts($scope.config.HostConfig.PortBindings); - $scope.config.HostConfig.PortBindings = bindings; - } - - function loadFromContainerImageConfig() { - RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id) - .then((model) => { - $scope.formValues.RegistryModel = model; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve registry'); - }); - } - - function loadFromContainerSpec() { - // Get container - Container.get({ id: $transition$.params().from }) - .$promise.then(function success(d) { - var fromContainer = new ContainerDetailsViewModel(d); - if (fromContainer.ResourceControl) { - if (fromContainer.ResourceControl.Public) { - $scope.formValues.AccessControlData.AccessControlEnabled = false; - } - - // When the container is create by duplicate/edit, the access permission - // shouldn't be copied - fromContainer.ResourceControl.UserAccesses = []; - fromContainer.ResourceControl.TeamAccesses = []; - } - - $scope.fromContainer = fromContainer; - $scope.state.mode = 'duplicate'; - $scope.config = ContainerHelper.configFromContainer(angular.copy(d)); - - $scope.formValues.commands = commandsTabUtils.toViewModel(d); - $scope.formValues.envVars = envVarsTabUtils.toViewModel(d); - $scope.formValues.volumes = volumesTabUtils.toViewModel(d); - $scope.formValues.network = networkTabUtils.toViewModel(d, $scope.availableNetworks, $scope.runningContainers); - $scope.formValues.resources = resourcesTabUtils.toViewModel(d); - $scope.formValues.capabilities = capabilitiesTabUtils.toViewModel(d); - $scope.formValues.labels = labelsTabUtils.toViewModel(d); - - $scope.formValues.restartPolicy = restartPolicyTabUtils.toViewModel(d); - - loadFromContainerPortBindings(d); - loadFromContainerImageConfig(d); - }) - .then(() => { - $scope.state.containerIsLoaded = true; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve container'); - }); - } - - async function initView() { - var nodeName = $transition$.params().nodeName; - $scope.formValues.NodeName = nodeName; - HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); - - $scope.isAdmin = Authentication.isAdmin(); - $scope.showDeviceMapping = await shouldShowDevices(); - $scope.allowSysctl = await shouldShowSysctls(); - $scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled(); - $scope.isAdminOrEndpointAdmin = Authentication.isAdmin(); - - var provider = $scope.applicationState.endpoint.mode.provider; - var apiVersion = $scope.applicationState.endpoint.apiVersion; - NetworkService.networks(provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', false, provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25) - .then(function success(networks) { - networks.push({ Name: 'container' }); - $scope.availableNetworks = networks.sort((a, b) => a.Name.localeCompare(b.Name)); - - $scope.formValues.network = networkTabUtils.getDefaultViewModel(networks.some((network) => network.Name === 'bridge')); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve networks'); - }); - getContainers(endpoint.Id) - .then((containers) => { - $scope.runningContainers = containers; - $scope.gpuUseAll = _.get($scope, 'endpoint.Snapshots[0].GpuUseAll', false); - $scope.gpuUseList = _.get($scope, 'endpoint.Snapshots[0].GpuUseList', []); - if ($transition$.params().from) { - loadFromContainerSpec(); - } else { - $scope.state.containerIsLoaded = true; - $scope.fromContainer = {}; - if ($scope.areContainerCapabilitiesEnabled) { - $scope.formValues.capabilities = capabilitiesTabUtils.getDefaultViewModel(); - } - } - }) - .catch((e) => { - Notifications.error('Failure', e, 'Unable to retrieve running containers'); - }); - - SystemService.info() - .then(function success(data) { - $scope.availableRuntimes = data.Runtimes ? Object.keys(data.Runtimes) : []; - $scope.state.sliderMaxCpu = 32; - if (data.NCPU) { - $scope.state.sliderMaxCpu = data.NCPU; - } - $scope.state.sliderMaxMemory = 32768; - if (data.MemTotal) { - $scope.state.sliderMaxMemory = Math.floor(data.MemTotal / 1000 / 1000); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve engine details'); - }); - - $scope.allowBindMounts = $scope.isAdminOrEndpointAdmin || endpoint.SecuritySettings.allowBindMountsForRegularUsers; - $scope.allowPrivilegedMode = endpoint.SecuritySettings.allowPrivilegedModeForRegularUsers; - } - - function validateForm(accessControlData, isAdmin) { - $scope.state.formValidationError = ''; - var error = ''; - error = FormValidator.validateAccessControl(accessControlData, isAdmin); - - if (error) { - $scope.state.formValidationError = error; - return false; - } - return true; - } - - $scope.handleResourceChange = handleResourceChange; - function handleResourceChange() { - $scope.state.settingUnlimitedResources = false; - if ( - ($scope.config.HostConfig.Memory > 0 && $scope.formValues.MemoryLimit === 0) || - ($scope.config.HostConfig.MemoryReservation > 0 && $scope.formValues.MemoryReservation === 0) || - ($scope.config.HostConfig.NanoCpus > 0 && $scope.formValues.CpuLimit === 0) - ) { - $scope.state.settingUnlimitedResources = true; - } - } - - $scope.redeployUnlimitedResources = function (resources) { - return $async(async () => { - $scope.formValues.resources = resources; - return create(); - }); - }; - - function create() { - var oldContainer = null; - HttpRequestHelper.setPortainerAgentTargetHeader($scope.formValues.NodeName); - return findCurrentContainer().then(setOldContainer).then(confirmCreateContainer).then(startCreationProcess).catch(notifyOnError).finally(final); - - function final() { - $scope.state.actionInProgress = false; - } - - function setOldContainer(container) { - oldContainer = container; - return container; - } - - function findCurrentContainer() { - return Container.query({ all: 1, filters: { name: ['^/' + $scope.config.name + '$'] } }) - .$promise.then(function onQuerySuccess(containers) { - if (!containers.length) { - return; - } - return containers[0]; - }) - .catch(notifyOnError); - - function notifyOnError(err) { - Notifications.error('Failure', err, 'Unable to retrieve containers'); - } - } - - function startCreationProcess(confirmed) { - if (!confirmed) { - return $q.when(); - } - if (!validateAccessControl()) { - return $q.when(); - } - $scope.state.actionInProgress = true; - return pullImageIfNeeded() - .then(stopAndRenameContainer) - .then(createNewContainer) - .then(applyResourceControl) - .then(connectToExtraNetworks) - .then(removeOldContainer) - .then(onSuccess) - .catch(onCreationProcessFail); - } - - function onCreationProcessFail(error) { - var deferred = $q.defer(); - removeNewContainer() - .then(restoreOldContainerName) - .then(function () { - deferred.reject(error); - }) - .catch(function (restoreError) { - deferred.reject(restoreError); - }); - return deferred.promise; - } - - function removeNewContainer() { - return findCurrentContainer().then(function onContainerLoaded(container) { - if (container && (!oldContainer || container.Id !== oldContainer.Id)) { - return ContainerService.remove(endpoint.Id, container, true); - } - }); - } - - function restoreOldContainerName() { - if (!oldContainer) { - return; - } - return ContainerService.renameContainer(endpoint.Id, oldContainer.Id, oldContainer.Names[0]); - } - - function confirmCreateContainer(container) { - if (!container) { - return $q.when(true); - } - - return showConfirmationModal(); - - function showConfirmationModal() { - var deferred = $q.defer(); - - confirmDestructive({ - title: 'Are you sure?', - message: 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?', - confirmButton: buildConfirmButton('Replace', 'danger'), - }).then(function onConfirm(confirmed) { - deferred.resolve(confirmed); - }); - - return deferred.promise; - } - } - - function stopAndRenameContainer() { - if (!oldContainer) { - return $q.when(); - } - return stopContainerIfNeeded(oldContainer).then(renameContainer); - } - - function stopContainerIfNeeded(oldContainer) { - if (oldContainer.State !== 'running') { - return $q.when(); - } - return ContainerService.stopContainer(endpoint.Id, oldContainer.Id); - } - - function renameContainer() { - return ContainerService.renameContainer(endpoint.Id, oldContainer.Id, oldContainer.Names[0] + '-old'); - } - - function pullImageIfNeeded() { - return $q.when($scope.formValues.alwaysPull && ImageService.pullImage($scope.formValues.RegistryModel, true)); - } - - function createNewContainer() { - return $async(async () => { - const config = prepareConfiguration(); - return await ContainerService.createAndStartContainer(endpoint.Id, config); - }); - } - - async function sendAnalytics() { - const publicSettings = await SettingsService.publicSettings(); - const analyticsAllowed = publicSettings.EnableTelemetry; - const image = `${$scope.formValues.RegistryModel.Registry.URL}/${$scope.formValues.RegistryModel.Image}`; - if (analyticsAllowed && $scope.formValues.GPU.enabled) { - $analytics.eventTrack('gpuContainerCreated', { - category: 'docker', - metadata: { gpu: $scope.formValues.GPU, containerImage: image }, - }); - } - } - - function applyResourceControl(newContainer) { - const userId = Authentication.getUserDetails().ID; - const resourceControl = newContainer.Portainer.ResourceControl; - const containerId = newContainer.Id; - const accessControlData = $scope.formValues.AccessControlData; - - return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl).then(function onApplyResourceControlSuccess() { - return containerId; - }); - } - - function connectToExtraNetworks(newContainerId) { - if (!$scope.formValues.network.extraNetworks) { - return $q.when(); - } - - var connectionPromises = _.forOwn($scope.formValues.network.extraNetworks, function (network, networkName) { - if (_.has(network, 'Aliases')) { - var aliases = _.filter(network.Aliases, (o) => { - return !_.startsWith($scope.fromContainer.Id, o); - }); - } - return NetworkService.connectContainer(networkName, newContainerId, aliases); - }); - - return $q.all(connectionPromises); - } - - function removeOldContainer() { - var deferred = $q.defer(); - - if (!oldContainer) { - deferred.resolve(); - return; - } - - ContainerService.remove(endpoint.Id, oldContainer, true).then(notifyOnRemoval).catch(notifyOnRemoveError); - - return deferred.promise; - - function notifyOnRemoval() { - Notifications.success('Container Removed', oldContainer.Id); - deferred.resolve(); - } - - function notifyOnRemoveError(err) { - deferred.reject({ msg: 'Unable to remove container', err: err }); - } - } - - function notifyOnError(err) { - Notifications.error('Failure', err, 'Unable to create container'); - } - - function validateAccessControl() { - var accessControlData = $scope.formValues.AccessControlData; - return validateForm(accessControlData, $scope.isAdmin); - } - - async function onSuccess() { - await sendAnalytics(); - Notifications.success('Success', 'Container successfully created'); - $state.go('docker.containers', {}, { reload: true }); - } - } - - async function shouldShowDevices() { - return endpoint.SecuritySettings.allowDeviceMappingForRegularUsers || Authentication.isAdmin(); - } - - async function shouldShowSysctls() { - return endpoint.SecuritySettings.allowSysctlSettingForRegularUsers || Authentication.isAdmin(); - } - - async function checkIfContainerCapabilitiesEnabled() { - return endpoint.SecuritySettings.allowContainerCapabilitiesForRegularUsers || Authentication.isAdmin(); - } - - initView(); - }, -]); diff --git a/app/docker/views/containers/create/createcontainer.css b/app/docker/views/containers/create/createcontainer.css deleted file mode 100644 index 26b8ffec6..000000000 --- a/app/docker/views/containers/create/createcontainer.css +++ /dev/null @@ -1,8 +0,0 @@ -.edit-resources { - padding: 20px; - border: 1px solid var(--border-widget-color); -} - -.widget .edit-resources button { - margin-left: 0; -} diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html deleted file mode 100644 index 68b79f36c..000000000 --- a/app/docker/views/containers/create/createcontainer.html +++ /dev/null @@ -1,273 +0,0 @@ - - - - -

- - The new container may fail to start if the image is changed, and settings from the previous container aren't compatible. Common causes include entrypoint, cmd or - other settings set by an image. -

-
-
- -
-
- - -
- -
- -
- -
-
- -
Image configuration
-
- - - The Docker registry for the {{ config.Image }} image is not registered inside Portainer, you will not be able to create a container. Please register that - registry first. - -
-
- - - -
-
- -
-
- -
- -
- - -
-
Webhooks
-
-
- -
-
-
- - -
Network ports configuration
- -
-
- -
-
- - -
-
- - - publish a new network port - -
- -
-
- -
- host - -
- - - - - -
- container - -
- - -
-
- - -
- -
- -
-
- -
- -
-
Deployment
- - - -
- - - - -
Actions
- -
-
- -
-
- -
-
- - {{ state.formValidationError }} -
-
- -
-
-
-
-
- -
-
- - - - -
- -
- -
- -
- - -
- - -
- -
- -
- -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
-
-
-
-
-
diff --git a/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx b/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx index db8029f77..9796321e7 100644 --- a/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx +++ b/app/react/azure/container-instances/CreateView/useCreateInstanceMutation.tsx @@ -52,7 +52,7 @@ export function useCreateInstanceMutation( } const accessControlData = values.accessControl; - await applyResourceControl(accessControlData, resourceControl); + await applyResourceControl(accessControlData, resourceControl.Id); return queryClient.invalidateQueries( queryKeys.subscriptions(environmentId) ); diff --git a/app/react/components/ImageConfigFieldset/AdvancedForm.tsx b/app/react/components/ImageConfigFieldset/AdvancedForm.tsx index 29998a1fd..48aa70da3 100644 --- a/app/react/components/ImageConfigFieldset/AdvancedForm.tsx +++ b/app/react/components/ImageConfigFieldset/AdvancedForm.tsx @@ -1,4 +1,4 @@ -import { FormikErrors, useFormikContext } from 'formik'; +import { FormikErrors } from 'formik'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; @@ -9,14 +9,14 @@ import { Values } from './types'; export function AdvancedForm({ values, errors, - fieldNamespace, + onChangeImage, + setFieldValue, }: { values: Values; errors?: FormikErrors; - fieldNamespace?: string; + onChangeImage?: (name: string) => void; + setFieldValue: (field: string, value: T) => void; }) { - const { setFieldValue } = useFormikContext(); - return ( <> @@ -27,15 +27,15 @@ export function AdvancedForm({ setFieldValue(namespaced('image'), e.target.value)} + onChange={(e) => { + const { value } = e.target; + setFieldValue('image', value); + onChangeImage?.(value); + }} placeholder="e.g. registry:port/my-image:my-tag" required /> ); - - function namespaced(field: string) { - return fieldNamespace ? `${fieldNamespace}.${field}` : field; - } } diff --git a/app/react/components/ImageConfigFieldset/ImageConfigFieldset.tsx b/app/react/components/ImageConfigFieldset/ImageConfigFieldset.tsx index 8f48fce5c..e77fef1d0 100644 --- a/app/react/components/ImageConfigFieldset/ImageConfigFieldset.tsx +++ b/app/react/components/ImageConfigFieldset/ImageConfigFieldset.tsx @@ -1,5 +1,5 @@ import { Database, Globe } from 'lucide-react'; -import { FormikErrors, useFormikContext } from 'formik'; +import { FormikErrors } from 'formik'; import { PropsWithChildren } from 'react'; import { Button } from '@@/buttons'; @@ -10,32 +10,31 @@ import { AdvancedForm } from './AdvancedForm'; import { RateLimits } from './RateLimits'; export function ImageConfigFieldset({ - checkRateLimits, + onRateLimit, children, autoComplete, - setValidity, - fieldNamespace, values, errors, + onChangeImage, + setFieldValue, }: PropsWithChildren<{ values: Values; errors?: FormikErrors; - fieldNamespace?: string; - checkRateLimits?: boolean; autoComplete?: boolean; - setValidity: (error?: string) => void; + onRateLimit?: (limited?: boolean) => void; + onChangeImage?: (name: string) => void; + setFieldValue: (field: string, value: T) => void; }>) { - const { setFieldValue } = useFormikContext(); - const Component = values.useRegistry ? SimpleForm : AdvancedForm; return (
@@ -46,7 +45,7 @@ export function ImageConfigFieldset({ color="link" icon={Globe} className="!ml-0 p-0 hover:no-underline" - onClick={() => setFieldValue(namespaced('useRegistry'), false)} + onClick={() => setFieldValue('useRegistry', false)} > Advanced mode @@ -56,7 +55,7 @@ export function ImageConfigFieldset({ color="link" icon={Database} className="!ml-0 p-0 hover:no-underline" - onClick={() => setFieldValue(namespaced('useRegistry'), true)} + onClick={() => setFieldValue('useRegistry', true)} > Simple mode @@ -66,13 +65,9 @@ export function ImageConfigFieldset({ {children} - {checkRateLimits && values.useRegistry && ( - + {onRateLimit && values.useRegistry && ( + )}
); - - function namespaced(field: string) { - return fieldNamespace ? `${fieldNamespace}.${field}` : field; - } } diff --git a/app/react/components/ImageConfigFieldset/RateLimits.tsx b/app/react/components/ImageConfigFieldset/RateLimits.tsx index dbb33a2c3..6dd568f8d 100644 --- a/app/react/components/ImageConfigFieldset/RateLimits.tsx +++ b/app/react/components/ImageConfigFieldset/RateLimits.tsx @@ -1,5 +1,5 @@ -import { useEffect } from 'react'; import { useQuery } from 'react-query'; +import { useEffect } from 'react'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; @@ -23,10 +23,10 @@ import { getIsDockerHubRegistry } from './utils'; export function RateLimits({ registryId, - setValidity, + onRateLimit, }: { registryId?: RegistryId; - setValidity: (error?: string) => void; + onRateLimit: (limited?: boolean) => void; }) { const registryQuery = useRegistry(registryId); @@ -48,7 +48,7 @@ export function RateLimits({ ); @@ -57,15 +57,15 @@ export function RateLimits({ function RateLimitsInner({ isAuthenticated = false, registryId = 0, - setValidity, + onRateLimit, environment, }: { isAuthenticated?: boolean; registryId?: RegistryId; - setValidity: (error?: string) => void; + onRateLimit: (limited?: boolean) => void; environment: Environment; }) { - const pullRateLimits = useRateLimits(registryId, environment, setValidity); + const pullRateLimits = useRateLimits(registryId, environment, onRateLimit); const { isAdmin } = useCurrentUser(); if (!pullRateLimits) { @@ -143,7 +143,7 @@ interface PullRateLimits { function useRateLimits( registryId: RegistryId, environment: Environment, - setValidity: (error?: string) => void + onRateLimit: (limited?: boolean) => void ) { const isValidForPull = isAgentEnvironment(environment.Type) || isLocalEnvironment(environment); @@ -153,32 +153,20 @@ function useRateLimits( () => getRateLimits(environment, registryId), { enabled: isValidForPull, - onError(e) { - // eslint-disable-next-line no-console - console.error('Failed loading DockerHub pull rate limits', e); - setValidity(); - }, - onSuccess(data) { - setValidity( - data.limit === 0 || data.remaining >= 0 - ? undefined - : 'Rate limit exceeded' - ); - }, } ); useEffect(() => { - if (!isValidForPull) { - setValidity(); + if (!isValidForPull || query.isError) { + onRateLimit(); } - }); - if (!isValidForPull) { - return null; - } + if (query.data) { + onRateLimit(query.data.limit > 0 && query.data.remaining === 0); + } + }, [isValidForPull, onRateLimit, query.data, query.isError]); - return query.data; + return isValidForPull ? query.data : undefined; } function getRateLimits(environment: Environment, registryId: RegistryId) { diff --git a/app/react/components/ImageConfigFieldset/SimpleForm.tsx b/app/react/components/ImageConfigFieldset/SimpleForm.tsx index 6e13bc223..67105afec 100644 --- a/app/react/components/ImageConfigFieldset/SimpleForm.tsx +++ b/app/react/components/ImageConfigFieldset/SimpleForm.tsx @@ -1,4 +1,4 @@ -import { FormikErrors, useFormikContext } from 'formik'; +import { FormikErrors } from 'formik'; import _ from 'lodash'; import { useMemo } from 'react'; @@ -31,15 +31,15 @@ export function SimpleForm({ autoComplete, values, errors, - fieldNamespace, + onChangeImage, + setFieldValue, }: { autoComplete?: boolean; values: Values; errors?: FormikErrors; - fieldNamespace?: string; + onChangeImage?: (name: string) => void; + setFieldValue: (field: string, value: T) => void; }) { - const { setFieldValue } = useFormikContext(); - const registryQuery = useRegistry(values.registryId); const registry = registryQuery.data; @@ -55,7 +55,7 @@ export function SimpleForm({ errors={errors?.registryId} > setFieldValue(namespaced('registryId'), value)} + onChange={(value) => setFieldValue('registryId', value)} value={values.registryId} inputId="registry-field" /> @@ -66,7 +66,10 @@ export function SimpleForm({ {registryUrl} setFieldValue(namespaced('image'), value)} + onChange={(value) => { + setFieldValue('image', value); + onChangeImage?.(value); + }} value={values.image} registry={registry} autoComplete={autoComplete} @@ -94,10 +97,6 @@ export function SimpleForm({ ); - - function namespaced(field: string) { - return fieldNamespace ? `${fieldNamespace}.${field}` : field; - } } function getImagesForRegistry( diff --git a/app/react/components/ImageConfigFieldset/validation.ts b/app/react/components/ImageConfigFieldset/validation.ts index 550e4ef8d..9b31baf35 100644 --- a/app/react/components/ImageConfigFieldset/validation.ts +++ b/app/react/components/ImageConfigFieldset/validation.ts @@ -2,10 +2,10 @@ import { bool, number, object, SchemaOf, string } from 'yup'; import { Values } from './types'; -export function validation(): SchemaOf { +export function validation(rateLimitExceeded: boolean): SchemaOf { return object({ image: string().required('Image is required'), registryId: number().default(0), useRegistry: bool().default(false), - }); + }).test('rate-limits', 'Rate limit exceeded', () => !rateLimitExceeded); } diff --git a/app/react/components/NavTabs/NavContainer.tsx b/app/react/components/NavTabs/NavContainer.tsx new file mode 100644 index 000000000..5c316b236 --- /dev/null +++ b/app/react/components/NavTabs/NavContainer.tsx @@ -0,0 +1,17 @@ +import clsx from 'clsx'; +import { PropsWithChildren } from 'react'; + +export function NavContainer({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/app/react/components/NavTabs/NavTabs.tsx b/app/react/components/NavTabs/NavTabs.tsx index 87c77363d..d4b5d8900 100644 --- a/app/react/components/NavTabs/NavTabs.tsx +++ b/app/react/components/NavTabs/NavTabs.tsx @@ -14,6 +14,8 @@ interface Props { selectedId?: T; onSelect?(id: T): void; disabled?: boolean; + type?: 'tabs' | 'pills'; + justified?: boolean; } export function NavTabs({ @@ -21,12 +23,16 @@ export function NavTabs({ selectedId, onSelect = () => {}, disabled, + type = 'tabs', + justified = false, }: Props) { const selected = options.find((option) => option.id === selectedId); return ( -
-
    +
    +
      {options.map((option) => (
    • void; +}) { + const environmentId = useEnvironmentId(); + + const apiVersionQuery = useApiVersion(environmentId); + + const nodesQuery = useAgentNodes>>( + environmentId, + apiVersionQuery.data || 1, + { + select: (data) => + data.map((node) => ({ label: node.NodeName, value: node.NodeName })), + enabled: apiVersionQuery.data !== undefined, + } + ); + + useEffect(() => { + if (nodesQuery.data && !value && nodesQuery.data.length > 0) { + onChange(nodesQuery.data[0].value); + } + }, [nodesQuery.data, onChange, value]); + + return ( + + + + ); +} diff --git a/app/react/docker/agent/queries/build-url.ts b/app/react/docker/agent/queries/build-url.ts new file mode 100644 index 000000000..6121497fc --- /dev/null +++ b/app/react/docker/agent/queries/build-url.ts @@ -0,0 +1,17 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export function buildAgentUrl( + environmentId: EnvironmentId, + apiVersion: number, + action: string +) { + let url = `/endpoints/${environmentId}/agent/docker`; + + if (apiVersion > 1) { + url += `/v${apiVersion}`; + } + + url += `/${action}`; + + return url; +} diff --git a/app/react/docker/agent/queries/useAgentNodes.ts b/app/react/docker/agent/queries/useAgentNodes.ts new file mode 100644 index 000000000..a6bc0b7d8 --- /dev/null +++ b/app/react/docker/agent/queries/useAgentNodes.ts @@ -0,0 +1,44 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildAgentUrl } from './build-url'; + +interface Node { + IPAddress: string; + NodeName: string; + NodeRole: string; +} + +export function useAgentNodes>( + environmentId: EnvironmentId, + apiVersion: number, + { + select, + enabled, + }: { + select?: (data: Array) => T; + enabled?: boolean; + } = {} +) { + return useQuery( + ['environment', environmentId, 'agent', 'nodes'], + () => getNodes(environmentId, apiVersion), + { + select, + enabled, + } + ); +} + +async function getNodes(environmentId: EnvironmentId, apiVersion: number) { + try { + const response = await axios.get>( + buildAgentUrl(environmentId, apiVersion, 'agents') + ); + return response.data; + } catch (error) { + throw parseAxiosError(error as Error, 'Unable to retrieve nodes'); + } +} diff --git a/app/react/docker/agent/queries/useApiVersion.ts b/app/react/docker/agent/queries/useApiVersion.ts new file mode 100644 index 000000000..1154d82f7 --- /dev/null +++ b/app/react/docker/agent/queries/useApiVersion.ts @@ -0,0 +1,29 @@ +import { useQuery } from 'react-query'; + +import axios, { + isAxiosError, + parseAxiosError, +} from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildUrl } from '../../proxy/queries/build-url'; + +export function useApiVersion(environmentId: EnvironmentId) { + return useQuery(['environment', environmentId, 'agent', 'ping'], () => + getApiVersion(environmentId) + ); +} + +async function getApiVersion(environmentId: EnvironmentId) { + try { + const { headers } = await axios.get(buildUrl(environmentId, 'ping')); + return parseInt(headers['portainer-agent-api-version'], 10) || 1; + } catch (error) { + // 404 - agent is up - set version to 1 + if (isAxiosError(error) && error.response?.status === 404) { + return 1; + } + + throw parseAxiosError(error as Error, 'Unable to ping agent'); + } +} diff --git a/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx b/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx new file mode 100644 index 000000000..2e191d697 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx @@ -0,0 +1,199 @@ +import { useFormikContext } from 'formik'; + +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { Authorized } from '@/react/hooks/useUser'; +import { AccessControlForm } from '@/react/portainer/access-control'; +import { AccessControlFormData } from '@/react/portainer/access-control/types'; +import { EnvironmentType } from '@/react/portainer/environments/types'; +import { NodeSelector } from '@/react/docker/agent/NodeSelector'; +import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { isAgentEnvironment } from '@/react/portainer/environments/utils'; +import { 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'; +import { Widget } from '@@/Widget'; + +import { + PortsMappingField, + Values as PortMappingValue, +} from './PortsMappingField'; + +export interface Values { + name: string; + enableWebhook: boolean; + publishAllPorts: boolean; + image: ImageConfigValues; + alwaysPull: boolean; + ports: PortMappingValue; + accessControl: AccessControlFormData; + nodeName: string; + autoRemove: boolean; +} + +function useIsAgentOnSwarm() { + const environmentId = useEnvironmentId(); + const environmentQuery = useCurrentEnvironment(); + + const isSwarm = useIsSwarm(environmentId); + + return ( + !!environmentQuery.data && + isAgentEnvironment(environmentQuery.data?.Type) && + isSwarm + ); +} + +export function BaseForm({ + isLoading, + onChangeName, + onChangeImageName, + onRateLimit, +}: { + isLoading: boolean; + onChangeName: (value: string) => void; + onChangeImageName: () => void; + onRateLimit: (limited?: boolean) => void; +}) { + const { setFieldValue, values, errors, isValid } = useFormikContext(); + const environmentQuery = useCurrentEnvironment(); + const isAgentOnSwarm = useIsAgentOnSwarm(); + if (!environmentQuery.data) { + return null; + } + + const environment = environmentQuery.data; + + const canUseWebhook = environment.Type !== EnvironmentType.EdgeAgentOnDocker; + + return ( + + + + { + const name = e.target.value; + onChangeName(name); + setFieldValue('name', name); + }} + placeholder="e.g. myContainer" + /> + + + + + setFieldValue(`image.${field}`, value) + } + autoComplete + onRateLimit={values.alwaysPull ? onRateLimit : undefined} + errors={errors?.image} + onChangeImage={onChangeImageName} + > +
      +
      + + setFieldValue('alwaysPull', alwaysPull) + } + /> +
      +
      +
      +
      + + {canUseWebhook && ( + + +
      +
      + + setFieldValue('enableWebhook', enableWebhook) + } + featureId={FeatureId.CONTAINER_WEBHOOK} + /> +
      +
      +
      +
      + )} + + +
      +
      + + setFieldValue('publishAllPorts', publishAllPorts) + } + /> +
      +
      + + setFieldValue('ports', ports)} + errors={errors?.ports} + /> +
      + + {isAgentOnSwarm && ( + + setFieldValue('nodeName', nodeName)} + /> + + )} + + + setFieldValue('accessControl', accessControl) + } + errors={errors?.accessControl} + values={values.accessControl} + /> + +
      +
      + setFieldValue('autoRemove', autoRemove)} + /> +
      +
      + +
      +
      + + Deploy the container + +
      +
      +
      +
      + ); +} diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts new file mode 100644 index 000000000..02f5a77b9 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel.ts @@ -0,0 +1,117 @@ +import { PortMap } from 'docker-types/generated/1.41'; +import _ from 'lodash'; + +import { PortMapping, Protocol, Values } from './PortsMappingField'; +import { Range } from './PortsMappingField.viewModel'; + +type PortKey = `${string}/${Protocol}`; + +export function parsePortBindingRequest(portBindings: Values): PortMap { + const bindings: Record< + PortKey, + Array<{ HostIp: string; HostPort: string }> + > = {}; + _.forEach(portBindings, (portBinding) => { + if (!portBinding.containerPort) { + return; + } + + const portInfo = extractPortInfo(portBinding); + if (!portInfo) { + return; + } + + let { hostPort } = portBinding; + const { endHostPort, endPort, hostIp, startHostPort, startPort } = portInfo; + _.range(startPort, endPort + 1).forEach((containerPort) => { + const bindKey: PortKey = `${containerPort}/${portBinding.protocol}`; + if (!bindings[bindKey]) { + bindings[bindKey] = []; + } + + if (startHostPort > 0) { + hostPort = (startHostPort + containerPort - startPort).toString(); + } + if (startPort === endPort && startHostPort !== endHostPort) { + hostPort += `-${endHostPort.toString()}`; + } + + bindings[bindKey].push({ HostIp: hostIp, HostPort: hostPort }); + }); + }); + return bindings; +} + +function isValidPortRange(portRange: Range) { + return portRange.start > 0 && portRange.end >= portRange.start; +} + +function parsePortRange(portRange: string | number): Range { + // Make sure we have a string + const portRangeString = portRange.toString(); + + // Split the range and convert to integers + const stringPorts = _.split(portRangeString, '-', 2); + const intPorts = _.map(stringPorts, parsePort); + + return { + start: intPorts[0], + end: intPorts[1] || intPorts[0], + }; +} + +const portPattern = + /^([1-9]|[1-5]?[0-9]{2,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/m; + +function parsePort(port: string) { + if (portPattern.test(port)) { + return parseInt(port, 10); + } + + return 0; +} + +function extractPortInfo(portBinding: PortMapping) { + const containerPortRange = parsePortRange(portBinding.containerPort); + if (!isValidPortRange(containerPortRange)) { + throw new Error(`Invalid port specification: ${portBinding.containerPort}`); + } + + const startPort = containerPortRange.start; + const endPort = containerPortRange.end; + let hostIp = ''; + let { hostPort } = portBinding; + if (!hostPort) { + return null; + } + + if (hostPort.includes('[')) { + const hostAndPort = _.split(hostPort, ']:'); + + if (hostAndPort.length < 2) { + throw new Error( + `Invalid port specification: ${portBinding.containerPort}` + ); + } + + hostIp = hostAndPort[0].replace('[', ''); + [, hostPort] = hostAndPort; + } else if (hostPort.includes(':')) { + [hostIp, hostPort] = _.split(hostPort, ':'); + } + + const hostPortRange = parsePortRange(hostPort); + if (!isValidPortRange(hostPortRange)) { + throw new Error(`Invalid port specification: ${hostPort}`); + } + + const { start: startHostPort, end: endHostPort } = hostPortRange; + if ( + endPort !== startPort && + endPort - startPort !== endHostPort - startHostPort + ) { + throw new Error(`Invalid port specification: ${hostPort}`); + } + + return { startPort, endPort, hostIp, startHostPort, endHostPort }; +} diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx new file mode 100644 index 000000000..c13aacf43 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.tsx @@ -0,0 +1,117 @@ +import { FormikErrors } from 'formik'; +import { ArrowRight } from 'lucide-react'; + +import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector'; +import { FormError } from '@@/form-components/FormError'; +import { InputList } from '@@/form-components/InputList'; +import { ItemProps } from '@@/form-components/InputList/InputList'; +import { Icon } from '@@/Icon'; +import { InputLabeled } from '@@/form-components/Input/InputLabeled'; + +export type Protocol = 'tcp' | 'udp'; + +export interface PortMapping { + hostPort: string; + protocol: Protocol; + containerPort: string; +} + +export type Values = Array; + +interface Props { + value: Values; + onChange?(value: Values): void; + errors?: FormikErrors[] | string | string[]; + disabled?: boolean; + readOnly?: boolean; +} + +export function PortsMappingField({ + value, + onChange = () => {}, + errors, + disabled, + readOnly, +}: Props) { + return ( + <> + + label="Port mapping" + value={value} + onChange={onChange} + addLabel="map additional port" + itemBuilder={() => ({ + hostPort: '', + containerPort: '', + protocol: 'tcp', + })} + item={Item} + errors={errors} + disabled={disabled} + readOnly={readOnly} + tooltip="When a range of ports on the host and a single port on the container is specified, Docker will randomly choose a single available port in the defined range and forward that to the container port." + /> + {typeof errors === 'string' && ( +
      + {errors} +
      + )} + + ); +} + +function Item({ + onChange, + item, + error, + disabled, + readOnly, + index, +}: ItemProps) { + return ( +
      +
      + handleChange('hostPort', e.target.value)} + label="host" + placeholder="e.g. 80" + className="w-1/2" + id={`hostPort-${index}`} + /> + + + + + + handleChange('containerPort', e.target.value)} + label="container" + placeholder="e.g. 80" + className="w-1/2" + id={`containerPort-${index}`} + /> + + + onChange={(value) => handleChange('protocol', value)} + value={item.protocol} + options={[{ value: 'tcp' }, { value: 'udp' }]} + disabled={disabled} + readOnly={readOnly} + /> +
      + {!!error && {Object.values(error)[0]}} +
      + ); + + function handleChange(name: keyof PortMapping, value: string) { + onChange({ ...item, [name]: value }); + } +} diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts new file mode 100644 index 000000000..edc66fd50 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation.ts @@ -0,0 +1,13 @@ +import { array, mixed, object, SchemaOf, string } from 'yup'; + +import { Values } from './PortsMappingField'; + +export function validationSchema(): SchemaOf { + return array( + object({ + hostPort: string().required('host is required'), + containerPort: string().required('container is required'), + protocol: mixed().oneOf(['tcp', 'udp']), + }) + ); +} diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.test.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.test.ts new file mode 100644 index 000000000..2c9b8da1c --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.test.ts @@ -0,0 +1,139 @@ +import { toViewModel } from './PortsMappingField.viewModel'; + +test('basic', () => { + expect( + toViewModel({ + '22/tcp': [ + { + HostIp: '', + HostPort: '222', + }, + ], + '3000/tcp': [ + { + HostIp: '', + HostPort: '3000', + }, + ], + }) + ).toStrictEqual([ + { + hostPort: '222', + containerPort: '22', + protocol: 'tcp', + }, + { + hostPort: '3000', + containerPort: '3000', + protocol: 'tcp', + }, + ]); +}); + +test('already combined', () => { + expect( + toViewModel({ + '80/tcp': [ + { + HostIp: '', + HostPort: '7000-7999', + }, + ], + }) + ).toStrictEqual([ + { + hostPort: '7000-7999', + containerPort: '80', + protocol: 'tcp', + }, + ]); +}); + +test('simple combine ports', () => { + expect( + toViewModel({ + '81/tcp': [ + { + HostIp: '', + HostPort: '81', + }, + ], + '82/tcp': [ + { + HostIp: '', + HostPort: '82', + }, + ], + }) + ).toStrictEqual([ + { + hostPort: '81-82', + containerPort: '81-82', + protocol: 'tcp', + }, + ]); +}); + +test('combine and sort', () => { + expect( + toViewModel({ + '3244/tcp': [ + { + HostIp: '', + HostPort: '105', + }, + ], + '3245/tcp': [ + { + HostIp: '', + HostPort: '106', + }, + ], + '81/tcp': [ + { + HostIp: '', + HostPort: '81', + }, + ], + '82/tcp': [ + { + HostIp: '', + HostPort: '82', + }, + ], + '83/tcp': [ + { + HostIp: '0.0.0.0', + HostPort: '0', + }, + ], + '84/tcp': [ + { + HostIp: '0.0.0.0', + HostPort: '0', + }, + ], + }) + ).toStrictEqual([ + { + hostPort: '81-82', + containerPort: '81-82', + protocol: 'tcp', + }, + { + hostPort: '', + containerPort: '83', + protocol: 'tcp', + }, + { + hostPort: '', + containerPort: '84', + protocol: 'tcp', + }, + { + hostPort: '105-106', + containerPort: '3244-3245', + protocol: 'tcp', + }, + ]); +}); diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts new file mode 100644 index 000000000..fa1cc11c7 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts @@ -0,0 +1,156 @@ +import { PortMap } from 'docker-types/generated/1.41'; +import _ from 'lodash'; + +import { Protocol, Values } from './PortsMappingField'; + +export type Range = { + start: number; + end: number; +}; + +type StringPortBinding = { + hostPort: string; + protocol: Protocol; + containerPort: number; +}; + +type NumericPortBinding = { + hostPort: number; + protocol: Protocol; + containerPort: number; +}; + +type RangePortBinding = { + hostPort: Range; + protocol: Protocol; + containerPort: Range; +}; + +export function toViewModel(portBindings: PortMap): Values { + const parsedPorts = parsePorts(portBindings); + const sortedPorts = sortPorts(parsedPorts); + + return [ + ...sortedPorts.rangePorts.map((port) => ({ + ...port, + containerPort: String(port.containerPort), + })), + ...combinePorts(sortedPorts.nonRangePorts), + ]; + + function isProtocol(value: string): value is Protocol { + return value === 'tcp' || value === 'udp'; + } + + function parsePorts( + portBindings: PortMap + ): Array { + return Object.entries(portBindings).flatMap(([key, bindings]) => { + const [containerPort, protocol] = key.split('/'); + + if (!isProtocol(protocol)) { + throw new Error(`Invalid protocol: ${protocol}`); + } + + if (!bindings) { + return []; + } + + const containerPortNumber = parseInt(containerPort, 10); + + if (Number.isNaN(containerPortNumber)) { + throw new Error(`Invalid container port: ${containerPort}`); + } + + return bindings.map((binding) => { + if (binding.HostPort?.includes('-')) { + return { + hostPort: binding.HostPort, + protocol, + containerPort: containerPortNumber, + }; + } + return { + hostPort: parseInt(binding.HostPort || '0', 10), + protocol, + containerPort: containerPortNumber, + }; + }); + }); + } + + function sortPorts(ports: Array) { + const rangePorts = ports.filter(isStringPortBinding); + const nonRangePorts = ports.filter(isNumericPortBinding); + + return { + rangePorts, + nonRangePorts: _.sortBy(nonRangePorts, [ + 'containerPort', + 'hostPort', + 'protocol', + ]), + }; + } + + function combinePorts(ports: Array) { + return ports + .reduce((acc, port) => { + const lastPort = acc[acc.length - 1]; + if ( + lastPort && + lastPort.containerPort.end === port.containerPort - 1 && + lastPort.hostPort.end === port.hostPort - 1 && + lastPort.protocol === port.protocol + ) { + lastPort.containerPort.end = port.containerPort; + lastPort.hostPort.end = port.hostPort; + return acc; + } + + return [ + ...acc, + { + hostPort: { + start: port.hostPort, + end: port.hostPort, + }, + containerPort: { + start: port.containerPort, + end: port.containerPort, + }, + protocol: port.protocol, + }, + ]; + }, [] as Array) + .map(({ protocol, containerPort, hostPort }) => ({ + hostPort: getRange(hostPort.start, hostPort.end), + containerPort: getRange(containerPort.start, containerPort.end), + protocol, + })); + + function getRange(start: number, end: number): string { + if (start === end) { + if (start === 0) { + return ''; + } + + return start.toString(); + } + + return `${start}-${end}`; + } + } +} + +function isNumericPortBinding( + port: StringPortBinding | NumericPortBinding +): port is NumericPortBinding { + return port.hostPort !== 'string'; +} + +function isStringPortBinding( + port: StringPortBinding | NumericPortBinding +): port is StringPortBinding { + return port.hostPort === 'string'; +} diff --git a/app/react/docker/containers/CreateView/BaseForm/index.ts b/app/react/docker/containers/CreateView/BaseForm/index.ts new file mode 100644 index 000000000..ecdc9a491 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/index.ts @@ -0,0 +1,12 @@ +import { getDefaultViewModel, toViewModel } from './toViewModel'; +import { toRequest } from './toRequest'; +import { validation } from './validation'; + +export { BaseForm, type Values as BaseFormValues } from './BaseForm'; + +export const baseFormUtils = { + toRequest, + toViewModel, + validation, + getDefaultViewModel, +}; diff --git a/app/react/docker/containers/CreateView/BaseForm/toRequest.ts b/app/react/docker/containers/CreateView/BaseForm/toRequest.ts new file mode 100644 index 000000000..eedda1a39 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/toRequest.ts @@ -0,0 +1,24 @@ +import { CreateContainerRequest } from '../types'; + +import { Values } from './BaseForm'; +import { parsePortBindingRequest } from './PortsMappingField.requestModel'; + +export function toRequest( + oldConfig: CreateContainerRequest, + values: Values +): CreateContainerRequest { + const bindings = parsePortBindingRequest(values.ports); + + return { + ...oldConfig, + ExposedPorts: Object.fromEntries( + Object.keys(bindings).map((key) => [key, {}]) + ), + HostConfig: { + ...oldConfig.HostConfig, + PublishAllPorts: values.publishAllPorts, + PortBindings: bindings, + AutoRemove: values.autoRemove, + }, + }; +} diff --git a/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts b/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts new file mode 100644 index 000000000..e915ddd62 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/toViewModel.ts @@ -0,0 +1,58 @@ +import { parseAccessControlFormData } from '@/react/portainer/access-control/utils'; +import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; +import { UserId } from '@/portainer/users/types'; +import { getDefaultImageConfig } from '@/react/portainer/registries/utils/getImageConfig'; + +import { ContainerResponse } from '../../queries/container'; + +import { toViewModel as toPortsMappingViewModel } from './PortsMappingField.viewModel'; +import { Values } from './BaseForm'; + +export function toViewModel( + config: ContainerResponse, + isAdmin: boolean, + currentUserId: UserId, + nodeName: string, + image: Values['image'], + enableWebhook: boolean +): Values { + // accessControl shouldn't be copied to new container + + const accessControl = parseAccessControlFormData(isAdmin, currentUserId); + + if (config.Portainer?.ResourceControl?.Public) { + accessControl.ownership = ResourceControlOwnership.PUBLIC; + } + + return { + accessControl, + name: config.Name ? config.Name.replace('/', '') : '', + alwaysPull: true, + autoRemove: config.HostConfig?.AutoRemove || false, + ports: toPortsMappingViewModel(config.HostConfig?.PortBindings || {}), + publishAllPorts: config.HostConfig?.PublishAllPorts || false, + nodeName, + image, + enableWebhook, + }; +} + +export function getDefaultViewModel( + isAdmin: boolean, + currentUserId: UserId, + nodeName: string +): Values { + const accessControl = parseAccessControlFormData(isAdmin, currentUserId); + + return { + nodeName, + enableWebhook: false, + image: getDefaultImageConfig(), + accessControl, + name: '', + alwaysPull: true, + autoRemove: false, + ports: [], + publishAllPorts: false, + }; +} diff --git a/app/react/docker/containers/CreateView/BaseForm/validation.ts b/app/react/docker/containers/CreateView/BaseForm/validation.ts new file mode 100644 index 000000000..829d165b1 --- /dev/null +++ b/app/react/docker/containers/CreateView/BaseForm/validation.ts @@ -0,0 +1,45 @@ +import { boolean, object, SchemaOf, string } from 'yup'; + +import { validationSchema as accessControlSchema } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation'; + +import { imageConfigValidation } from '@@/ImageConfigFieldset'; + +import { Values } from './BaseForm'; +import { validationSchema as portsSchema } from './PortsMappingField.validation'; + +export function validation( + { + isAdmin, + isDuplicating, + isDuplicatingPortainer, + isDockerhubRateLimited, + }: { + isAdmin: boolean; + isDuplicating: boolean | undefined; + isDuplicatingPortainer: boolean | undefined; + isDockerhubRateLimited: boolean; + } = { + isAdmin: false, + isDuplicating: false, + isDuplicatingPortainer: false, + isDockerhubRateLimited: false, + } +): SchemaOf { + return object({ + name: string() + .default('') + .test('not-duplicate-portainer', () => !isDuplicatingPortainer), + alwaysPull: boolean().default(true), + accessControl: accessControlSchema(isAdmin), + autoRemove: boolean().default(false), + enableWebhook: boolean().default(false), + nodeName: string().default(''), + ports: portsSchema(), + publishAllPorts: boolean().default(false), + image: imageConfigValidation(isDockerhubRateLimited).test( + 'duplicate-must-have-registry', + 'Duplicate is only possible when registry is selected', + (value) => !isDuplicating || typeof value.registryId !== 'undefined' + ), + }); +} diff --git a/app/react/docker/containers/CreateView/CommandsTab/CommandsTab.tsx b/app/react/docker/containers/CreateView/CommandsTab/CommandsTab.tsx index d9cd18484..e9ed94f80 100644 --- a/app/react/docker/containers/CreateView/CommandsTab/CommandsTab.tsx +++ b/app/react/docker/containers/CreateView/CommandsTab/CommandsTab.tsx @@ -1,5 +1,4 @@ import { FormikErrors } from 'formik'; -import { useState } from 'react'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; @@ -12,16 +11,14 @@ import { Values } from './types'; export function CommandsTab({ apiVersion, values, - onChange, + setFieldValue, errors, }: { apiVersion: number; values: Values; - onChange: (values: Values) => void; + setFieldValue: (field: string, value: unknown) => void; errors?: FormikErrors; }) { - const [controlledValues, setControlledValues] = useState(values); - return (
      handleChange({ cmd })} + value={values.cmd} + onChange={(cmd) => setFieldValue('cmd', cmd)} id="command-input" placeholder="e.g. '-logtostderr' '--housekeeping_interval=5s' or /usr/bin/nginx -t -c /mynginx.conf" /> @@ -46,8 +43,8 @@ export function CommandsTab({ errors={errors?.entrypoint} > handleChange({ entrypoint })} + value={values.entrypoint} + onChange={(entrypoint) => setFieldValue('entrypoint', entrypoint)} id="entrypoint-input" placeholder="e.g. /bin/sh -c" /> @@ -61,8 +58,8 @@ export function CommandsTab({ errors={errors?.workingDir} > handleChange({ workingDir: e.target.value })} + value={values.workingDir} + onChange={(e) => setFieldValue('workingDir', e.target.value)} placeholder="e.g. /myapp" /> @@ -73,33 +70,24 @@ export function CommandsTab({ errors={errors?.user} > handleChange({ user: e.target.value })} + value={values.user} + onChange={(e) => setFieldValue('user', e.target.value)} placeholder="e.g. nginx" />
      handleChange({ console })} + value={values.console} + onChange={(console) => setFieldValue('console', console)} /> - handleChange({ - logConfig, - }) - } + value={values.logConfig} + onChange={(logConfig) => setFieldValue('logConfig', logConfig)} errors={errors?.logConfig} />
    ); - - function handleChange(newValues: Partial) { - onChange({ ...values, ...newValues }); - setControlledValues((values) => ({ ...values, ...newValues })); - } } diff --git a/app/react/docker/containers/CreateView/CommandsTab/index.ts b/app/react/docker/containers/CreateView/CommandsTab/index.ts index b0fcb0f93..f2c49cba9 100644 --- a/app/react/docker/containers/CreateView/CommandsTab/index.ts +++ b/app/react/docker/containers/CreateView/CommandsTab/index.ts @@ -3,7 +3,6 @@ import { toRequest } from './toRequest'; import { toViewModel, getDefaultViewModel } from './toViewModel'; export { CommandsTab } from './CommandsTab'; -export { validation as commandsTabValidation } from './validation'; export { type Values as CommandsTabValues } from './types'; export const commandsTabUtils = { diff --git a/app/react/docker/containers/CreateView/CommandsTab/toRequest.ts b/app/react/docker/containers/CreateView/CommandsTab/toRequest.ts index 589a0c440..99a9e311c 100644 --- a/app/react/docker/containers/CreateView/CommandsTab/toRequest.ts +++ b/app/react/docker/containers/CreateView/CommandsTab/toRequest.ts @@ -41,18 +41,6 @@ export function toRequest( return config; - 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']; - } - function getConsoleConfig(value: ConsoleSetting): ConsoleConfig { switch (value) { case 'both': @@ -66,4 +54,16 @@ export function toRequest( 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 new file mode 100644 index 000000000..f6c086612 --- /dev/null +++ b/app/react/docker/containers/CreateView/CreateView.tsx @@ -0,0 +1,196 @@ +import { Formik } from 'formik'; +import { useRouter } from '@uirouter/react'; +import { useEffect, useState } from 'react'; + +import { useCurrentUser } from '@/react/hooks/useUser'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries'; +import { Registry } from '@/react/portainer/registries/types/registry'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { useAnalytics } from '@/react/hooks/useAnalytics'; +import { useDebouncedValue } from '@/react/hooks/useDebouncedValue'; + +import { PageHeader } from '@@/PageHeader'; +import { ImageConfigValues } from '@@/ImageConfigFieldset'; +import { confirmDestructive } from '@@/modals/confirm'; +import { buildConfirmButton } from '@@/modals/utils'; +import { InformationPanel } from '@@/InformationPanel'; +import { TextTip } from '@@/Tip/TextTip'; + +import { useContainers } from '../queries/containers'; +import { useSystemLimits } from '../../proxy/queries/useInfo'; + +import { useCreateOrReplaceMutation } from './useCreateMutation'; +import { useValidation } from './validation'; +import { useInitialValues, Values } from './useInitialValues'; +import { InnerForm } from './InnerForm'; +import { toRequest } from './toRequest'; + +export function CreateView() { + return ( + <> + + + + + ); +} + +function CreateForm() { + const environmentId = useEnvironmentId(); + const router = useRouter(); + const { trackEvent } = useAnalytics(); + const { isAdmin } = useCurrentUser(); + const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false); + + const mutation = useCreateOrReplaceMutation(); + const initialValuesQuery = useInitialValues( + mutation.isLoading || mutation.isSuccess + ); + const registriesQuery = useEnvironmentRegistries(environmentId); + + const { oldContainer, syncName } = useOldContainer( + initialValuesQuery?.initialValues?.name + ); + + const { maxCpu, maxMemory } = useSystemLimits(environmentId); + + const envQuery = useCurrentEnvironment(); + + const validationSchema = useValidation({ + isAdmin, + maxCpu, + maxMemory, + isDuplicating: initialValuesQuery?.isDuplicating, + isDuplicatingPortainer: oldContainer?.IsPortainer, + isDockerhubRateLimited, + }); + + if (!envQuery.data || !initialValuesQuery) { + return null; + } + + const environment = envQuery.data; + + const { + isDuplicating = false, + initialValues, + extraNetworks, + } = initialValuesQuery; + + return ( + <> + {isDuplicating && ( + + + The new container may fail to start if the image is changed, and + settings from the previous container aren't compatible. Common + causes include entrypoint, cmd or + + other settings + {' '} + set by an image. + + + )} + + + setIsDockerhubRateLimited(limited)} + /> + + + ); + + async function handleSubmit(values: Values) { + if (oldContainer) { + const confirmed = await confirmDestructive({ + title: 'Are you sure?', + message: + 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?', + confirmButton: buildConfirmButton('Replace', 'danger'), + }); + + if (!confirmed) { + return; + } + } + + const registry = getRegistry(values.image, registriesQuery.data || []); + const config = toRequest(values, registry); + + mutation.mutate( + { config, environment, values, registry, oldContainer, extraNetworks }, + { + onSuccess() { + sendAnalytics(values, registry); + notifySuccess('Success', 'Container successfully created'); + router.stateService.go('docker.containers'); + }, + } + ); + } + + function sendAnalytics(values: Values, registry?: Registry) { + const containerImage = registry?.URL + ? `${registry?.URL}/${values.image}` + : values.image; + if (values.resources.gpu.enabled) { + trackEvent('gpuContainerCreated', { + category: 'docker', + metadata: { gpu: values.resources.gpu, containerImage }, + }); + } + } +} + +function getRegistry(image: ImageConfigValues, registries: Registry[]) { + return image.useRegistry + ? registries.find((registry) => registry.Id === image.registryId) + : undefined; +} + +function useOldContainer(initialName?: string) { + const environmentId = useEnvironmentId(); + + const [name, setName] = useState(initialName); + const debouncedName = useDebouncedValue(name, 1000); + const oldContainerQuery = useContainers(environmentId, { + enabled: !!debouncedName, + filters: { + name: [`^/${debouncedName}$`], + }, + }); + useEffect(() => { + if (initialName && initialName !== name) { + setName(initialName); + } + }, [initialName, name]); + + return { + syncName: setName, + oldContainer: + oldContainerQuery.data && oldContainerQuery.data.length > 0 + ? oldContainerQuery.data[0] + : undefined, + }; +} diff --git a/app/react/docker/containers/CreateView/EnvVarsTab/EnvVarsTab.tsx b/app/react/docker/containers/CreateView/EnvVarsTab/EnvVarsTab.tsx index 8c5053b40..55d0e81d1 100644 --- a/app/react/docker/containers/CreateView/EnvVarsTab/EnvVarsTab.tsx +++ b/app/react/docker/containers/CreateView/EnvVarsTab/EnvVarsTab.tsx @@ -1,12 +1,10 @@ -import { useState } from 'react'; - import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset'; import { ArrayError } from '@@/form-components/InputList/InputList'; import { Values } from './types'; export function EnvVarsTab({ - values: initialValues, + values, onChange, errors, }: { @@ -14,19 +12,18 @@ export function EnvVarsTab({ onChange(value: Values): void; errors?: ArrayError; }) { - const [values, setControlledValues] = useState(initialValues); - return ( - +
    + +
    ); function handleChange(values: Values) { - setControlledValues(values); onChange(values); } } diff --git a/app/react/docker/containers/CreateView/InnerForm.tsx b/app/react/docker/containers/CreateView/InnerForm.tsx new file mode 100644 index 000000000..2f2556b53 --- /dev/null +++ b/app/react/docker/containers/CreateView/InnerForm.tsx @@ -0,0 +1,224 @@ +import { useFormikContext, Form } from 'formik'; +import { Settings } from 'lucide-react'; +import { useState } from 'react'; + +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useIsEnvironmentAdmin } from '@/react/hooks/useUser'; + +import { NavTabs } from '@@/NavTabs'; +import { Widget } from '@@/Widget'; + +import { useApiVersion } from '../../proxy/queries/useVersion'; + +import { BaseForm } from './BaseForm'; +import { CapabilitiesTab } from './CapabilitiesTab'; +import { CommandsTab } from './CommandsTab'; +import { LabelsTab } from './LabelsTab'; +import { NetworkTab } from './NetworkTab'; +import { ResourcesTab } from './ResourcesTab'; +import { RestartPolicyTab } from './RestartPolicyTab'; +import { VolumesTab } from './VolumesTab'; +import { Values } from './useInitialValues'; +import { EnvVarsTab } from './EnvVarsTab'; +import { EditResourcesForm } from './ResourcesTab/EditResourceForm'; + +export function InnerForm({ + isLoading, + isDuplicate, + onChangeName, + onRateLimit, +}: { + isDuplicate: boolean; + isLoading: boolean; + onChangeName: (value: string) => void; + onRateLimit: (limited?: boolean) => void; +}) { + const { values, setFieldValue, errors, submitForm } = + useFormikContext(); + const environmentId = useEnvironmentId(); + const [tab, setTab] = useState('commands'); + const apiVersion = useApiVersion(environmentId); + const isEnvironmentAdmin = useIsEnvironmentAdmin(); + const envQuery = useCurrentEnvironment(); + + if (!envQuery.data) { + return null; + } + + const environment = envQuery.data; + + return ( +
    +
    +
    +
    + { + setFieldValue('commands.cmd', null); + setFieldValue('commands.entrypoint', null); + }} + isLoading={isLoading} + onRateLimit={onRateLimit} + /> + +
    + + + + + onSelect={setTab} + selectedId={tab} + type="pills" + justified + options={[ + { + id: 'commands', + label: 'Commands & logging', + children: ( + + setFieldValue(`commands.${field}`, value) + } + /> + ), + }, + { + id: 'volumes', + label: 'Volumes', + children: ( + + setFieldValue('volumes', value) + } + errors={errors.volumes} + allowBindMounts={ + isEnvironmentAdmin || + environment.SecuritySettings + .allowBindMountsForRegularUsers + } + /> + ), + }, + { + id: 'network', + label: 'Network', + children: ( + + setFieldValue(`network.${field}`, value) + } + /> + ), + }, + { + id: 'env', + label: 'Env', + children: ( + setFieldValue('env', value)} + errors={errors.env} + /> + ), + }, + { + id: 'labels', + label: 'Labels', + children: ( + setFieldValue('labels', value)} + errors={errors.labels} + /> + ), + }, + { + id: 'restart', + label: 'Restart policy', + children: ( + + setFieldValue('restartPolicy', value) + } + /> + ), + }, + { + id: 'runtime', + label: 'Runtime & resources', + children: ( + + setFieldValue(`resources.${field}`, value) + } + allowPrivilegedMode={ + isEnvironmentAdmin || + environment.SecuritySettings + .allowPrivilegedModeForRegularUsers + } + isDevicesFieldVisible={ + isEnvironmentAdmin || + environment.SecuritySettings + .allowDeviceMappingForRegularUsers + } + isInitFieldVisible={apiVersion >= 1.37} + isSysctlFieldVisible={ + isEnvironmentAdmin || + environment.SecuritySettings + .allowSysctlSettingForRegularUsers + } + renderLimits={ + isDuplicate + ? (values) => ( + { + setFieldValue( + 'resources.resources', + values + ); + return submitForm(); + }} + isImageInvalid={!!errors?.image} + /> + ) + : undefined + } + /> + ), + }, + { + id: 'capabilities', + label: 'Capabilities', + children: ( + + setFieldValue('capabilities', value) + } + /> + ), + }, + ]} + /> + + +
    +
    +
    +
    +
    + ); +} diff --git a/app/react/docker/containers/CreateView/LabelsTab/LabelsTab.tsx b/app/react/docker/containers/CreateView/LabelsTab/LabelsTab.tsx index b6b79f2c5..4499bd551 100644 --- a/app/react/docker/containers/CreateView/LabelsTab/LabelsTab.tsx +++ b/app/react/docker/containers/CreateView/LabelsTab/LabelsTab.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react'; - import { InputList } from '@@/form-components/InputList'; import { ArrayError } from '@@/form-components/InputList/InputList'; @@ -7,7 +5,7 @@ import { Item } from './Item'; import { Values } from './types'; export function LabelsTab({ - values: initialValues, + values, onChange, errors, }: { @@ -15,8 +13,6 @@ export function LabelsTab({ onChange: (values: Values) => void; errors?: ArrayError; }) { - const [values, setControlledValues] = useState(initialValues); - return ( void; errors?: FormikErrors; }) { - const [values, setControlledValues] = useState(initialValues); - return (
    handleChange({ networkMode })} + onChange={(networkMode) => setFieldValue('networkMode', networkMode)} /> @@ -37,7 +34,7 @@ export function NetworkTab({ handleChange({ container })} + onChange={(container) => setFieldValue('container', container)} /> )} @@ -45,7 +42,7 @@ export function NetworkTab({ handleChange({ hostname: e.target.value })} + onChange={(e) => setFieldValue('hostname', e.target.value)} placeholder="e.g. web01" /> @@ -53,7 +50,7 @@ export function NetworkTab({ handleChange({ domain: e.target.value })} + onChange={(e) => setFieldValue('domain', e.target.value)} placeholder="e.g. example.com" /> @@ -61,7 +58,7 @@ export function NetworkTab({ handleChange({ macAddress: e.target.value })} + onChange={(e) => setFieldValue('macAddress', e.target.value)} placeholder="e.g. 12-34-56-78-9a-bc" /> @@ -69,7 +66,7 @@ export function NetworkTab({ handleChange({ ipv4Address: e.target.value })} + onChange={(e) => setFieldValue('ipv4Address', e.target.value)} placeholder="e.g. 172.20.0.7" /> @@ -77,7 +74,7 @@ export function NetworkTab({ handleChange({ ipv6Address: e.target.value })} + onChange={(e) => setFieldValue('ipv6Address', e.target.value)} placeholder="e.g. a:b:c:d::1234" /> @@ -85,7 +82,7 @@ export function NetworkTab({ handleChange({ primaryDns: e.target.value })} + onChange={(e) => setFieldValue('primaryDns', e.target.value)} placeholder="e.g. 1.1.1.1, 2606:4700:4700::1111" /> @@ -93,7 +90,7 @@ export function NetworkTab({ handleChange({ secondaryDns: e.target.value })} + onChange={(e) => setFieldValue('secondaryDns', e.target.value)} placeholder="e.g. 1.0.0.1, 2606:4700:4700::1001" /> @@ -101,17 +98,15 @@ export function NetworkTab({ handleChange({ hostsFileEntries })} + onChange={(hostsFileEntries) => + setFieldValue('hostsFileEntries', hostsFileEntries) + } errors={errors?.hostsFileEntries} item={HostsFileEntryItem} + itemBuilder={() => ''} />
    ); - - function handleChange(newValues: Partial) { - onChange({ ...values, ...newValues }); - setControlledValues((values) => ({ ...values, ...newValues })); - } } function HostsFileEntryItem({ diff --git a/app/react/docker/containers/CreateView/NetworkTab/index.ts b/app/react/docker/containers/CreateView/NetworkTab/index.ts index e13bf2e06..e4ace09b3 100644 --- a/app/react/docker/containers/CreateView/NetworkTab/index.ts +++ b/app/react/docker/containers/CreateView/NetworkTab/index.ts @@ -3,7 +3,6 @@ import { toRequest } from './toRequest'; import { toViewModel, getDefaultViewModel } from './toViewModel'; export { NetworkTab } from './NetworkTab'; - export { type Values as NetworkTabValues } from './types'; export const networkTabUtils = { diff --git a/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts index 41f677acf..710f2992c 100644 --- a/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts +++ b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts @@ -5,9 +5,9 @@ import { DockerContainer } from '../../types'; import { CONTAINER_MODE, Values } from './types'; -export function getDefaultViewModel(hasBridgeNetwork: boolean) { +export function getDefaultViewModel() { return { - networkMode: hasBridgeNetwork ? 'bridge' : 'nat', + networkMode: 'bridge', hostname: '', domain: '', macAddress: '', diff --git a/app/react/docker/containers/CreateView/ResourcesTab/ResourcesTab.tsx b/app/react/docker/containers/CreateView/ResourcesTab/ResourcesTab.tsx index a7f4e04ed..0f304cc4e 100644 --- a/app/react/docker/containers/CreateView/ResourcesTab/ResourcesTab.tsx +++ b/app/react/docker/containers/CreateView/ResourcesTab/ResourcesTab.tsx @@ -1,6 +1,6 @@ import _ from 'lodash'; import { FormikErrors } from 'formik'; -import { useState } from 'react'; +import { ReactNode } from 'react'; import { useIsStandAlone } from '@/react/docker/proxy/queries/useInfo'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; @@ -17,7 +17,6 @@ import { ResourceFieldset, Values as ResourcesValues, } from './ResourcesFieldset'; -import { EditResourcesForm } from './EditResourceForm'; export interface Values { runtime: RuntimeValues; @@ -34,29 +33,24 @@ export interface Values { } export function ResourcesTab({ - values: initialValues, - onChange, + values, + setFieldValue, + errors, allowPrivilegedMode, isInitFieldVisible, isDevicesFieldVisible, isSysctlFieldVisible, - errors, - isDuplicate, - redeploy, - isImageInvalid, + renderLimits, }: { values: Values; - onChange: (values: Values) => void; + setFieldValue: (field: string, value: unknown) => void; + errors?: FormikErrors; allowPrivilegedMode: boolean; isInitFieldVisible: boolean; isDevicesFieldVisible: boolean; isSysctlFieldVisible: boolean; - errors?: FormikErrors; - isDuplicate?: boolean; - redeploy: (values: Values) => Promise; - isImageInvalid: boolean; + renderLimits?: (values: ResourcesValues) => ReactNode; }) { - const [values, setControlledValues] = useState(initialValues); const environmentId = useEnvironmentId(); const environmentQuery = useCurrentEnvironment(); @@ -75,7 +69,7 @@ export function ResourcesTab({
    handleChange({ runtime })} + onChange={(runtime) => setFieldValue('runtime', runtime)} allowPrivilegedMode={allowPrivilegedMode} isInitFieldVisible={isInitFieldVisible} /> @@ -83,14 +77,14 @@ export function ResourcesTab({ {isDevicesFieldVisible && ( handleChange({ devices })} + onChange={(devices) => setFieldValue('devices', devices)} /> )} {isSysctlFieldVisible && ( handleChange({ sysctls })} + onChange={(sysctls) => setFieldValue('sysctls', sysctls)} /> )} @@ -102,7 +96,7 @@ export function ResourcesTab({ min="1" value={values.sharedMemorySize} onChange={(e) => - handleChange({ sharedMemorySize: e.target.valueAsNumber }) + setFieldValue('sharedMemorySize', e.target.valueAsNumber) } className="w-32" /> @@ -115,7 +109,7 @@ export function ResourcesTab({ {isStandalone && ( handleChange({ gpu })} + onChange={(gpu) => setFieldValue('gpu', gpu)} gpus={environment.Gpus} enableGpuManagement={environment.EnableGPUManagement} usedGpus={gpuUseList} @@ -123,26 +117,15 @@ export function ResourcesTab({ /> )} - {isDuplicate ? ( - - redeploy({ ...values, resources: newValues }) - } - isImageInvalid={isImageInvalid} - /> + {renderLimits ? ( + renderLimits(values.resources) ) : ( handleChange({ resources })} + onChange={(resources) => setFieldValue('resources', resources)} errors={errors?.resources} /> )}
    ); - - function handleChange(newValues: Partial) { - onChange({ ...values, ...newValues }); - setControlledValues({ ...values, ...newValues }); - } } diff --git a/app/react/docker/containers/CreateView/VolumesTab/VolumesTab.tsx b/app/react/docker/containers/CreateView/VolumesTab/VolumesTab.tsx index c455265a1..67692e442 100644 --- a/app/react/docker/containers/CreateView/VolumesTab/VolumesTab.tsx +++ b/app/react/docker/containers/CreateView/VolumesTab/VolumesTab.tsx @@ -1,7 +1,5 @@ -import { useState } from 'react'; -import { FormikErrors } from 'formik'; - import { InputList } from '@@/form-components/InputList'; +import { ArrayError } from '@@/form-components/InputList/InputList'; import { Values, Volume } from './types'; import { InputContext } from './context'; @@ -16,17 +14,15 @@ export function VolumesTab({ onChange: (values: Values) => void; values: Values; allowBindMounts: boolean; - errors?: FormikErrors; + errors?: ArrayError; }) { - const [controlledValues, setControlledValues] = useState(values); - return ( errors={Array.isArray(errors) ? errors : []} label="Volume mapping" onChange={(volumes) => handleChange(volumes)} - value={controlledValues} + value={values} addLabel="map additional volume" item={Item} itemBuilder={() => ({ @@ -41,6 +37,5 @@ export function VolumesTab({ function handleChange(newValues: Values) { onChange(newValues); - setControlledValues(() => newValues); } } diff --git a/app/react/docker/containers/CreateView/index.ts b/app/react/docker/containers/CreateView/index.ts new file mode 100644 index 000000000..74e592112 --- /dev/null +++ b/app/react/docker/containers/CreateView/index.ts @@ -0,0 +1 @@ +export { CreateView } from './CreateView'; diff --git a/app/react/docker/containers/CreateView/toRequest.ts b/app/react/docker/containers/CreateView/toRequest.ts new file mode 100644 index 000000000..7e0ebb621 --- /dev/null +++ b/app/react/docker/containers/CreateView/toRequest.ts @@ -0,0 +1,34 @@ +import { Registry } from '@/react/portainer/registries/types/registry'; +import { buildImageFullURI } from '@/react/docker/images/utils'; + +import { baseFormUtils } from './BaseForm'; +import { capabilitiesTabUtils } from './CapabilitiesTab'; +import { commandsTabUtils } from './CommandsTab'; +import { labelsTabUtils } from './LabelsTab'; +import { networkTabUtils } from './NetworkTab'; +import { resourcesTabUtils } from './ResourcesTab'; +import { volumesTabUtils } from './VolumesTab'; +import { CreateContainerRequest } from './types'; +import { restartPolicyTabUtils } from './RestartPolicyTab'; +import { envVarsTabUtils } from './EnvVarsTab'; +import { Values } from './useInitialValues'; + +export function toRequest(values: Values, registry?: Registry) { + let config: CreateContainerRequest = { + HostConfig: {}, + NetworkingConfig: {}, + }; + + config = commandsTabUtils.toRequest(config, values.commands); + config = volumesTabUtils.toRequest(config, values.volumes); + config = networkTabUtils.toRequest(config, values.network, ''); + config = labelsTabUtils.toRequest(config, values.labels); + config = restartPolicyTabUtils.toRequest(config, values.restartPolicy); + config = resourcesTabUtils.toRequest(config, values.resources); + config = capabilitiesTabUtils.toRequest(config, values.capabilities); + config = baseFormUtils.toRequest(config, values); + config = envVarsTabUtils.toRequest(config, values.env); + config.Image = buildImageFullURI(values.image.image, registry); + + return config; +} diff --git a/app/react/docker/containers/CreateView/useCreateMutation.tsx b/app/react/docker/containers/CreateView/useCreateMutation.tsx new file mode 100644 index 000000000..bd37d5b5b --- /dev/null +++ b/app/react/docker/containers/CreateView/useCreateMutation.tsx @@ -0,0 +1,355 @@ +import { useMutation, useQueryClient } from 'react-query'; +import { AxiosRequestHeaders } from 'axios'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { + Environment, + EnvironmentId, + EnvironmentType, +} from '@/react/portainer/environments/types'; +import { + Registry, + RegistryId, +} from '@/react/portainer/registries/types/registry'; +import { createWebhook } from '@/react/portainer/webhooks/createWebhook'; +import { WebhookType } from '@/react/portainer/webhooks/types'; +import { + AccessControlFormData, + ResourceControlResponse, +} from '@/react/portainer/access-control/types'; +import { applyResourceControl } from '@/react/portainer/access-control/access-control.service'; +import PortainerError from '@/portainer/error'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { pullImage } from '../../images/queries/usePullImageMutation'; +import { + removeContainer, + renameContainer, + startContainer, + stopContainer, + urlBuilder, +} from '../containers.service'; +import { PortainerResponse } from '../../types'; +import { connectContainer } from '../../networks/queries/useConnectContainer'; +import { DockerContainer } from '../types'; +import { queryKeys } from '../queries/query-keys'; + +import { CreateContainerRequest } from './types'; +import { Values } from './useInitialValues'; + +interface ExtraNetwork { + networkName: string; + aliases: string[]; +} + +export function useCreateOrReplaceMutation() { + const environmentId = useEnvironmentId(); + const queryClient = useQueryClient(); + + return useMutation( + createOrReplace, + mutationOptions( + withError('Failed to create container'), + withInvalidate(queryClient, [queryKeys.list(environmentId)]) + ) + ); +} + +interface CreateOptions { + config: CreateContainerRequest; + values: Values; + registry?: Registry; + environment: Environment; +} + +interface ReplaceOptions extends CreateOptions { + oldContainer: DockerContainer; + extraNetworks: Array; +} + +function isReplace( + options: ReplaceOptions | CreateOptions +): options is ReplaceOptions { + return 'oldContainer' in options && !!options.oldContainer; +} + +export function createOrReplace(options: ReplaceOptions | CreateOptions) { + return isReplace(options) ? replace(options) : create(options); +} + +async function create({ + config, + values, + registry, + environment, +}: CreateOptions) { + await pullImageIfNeeded( + environment.Id, + values.nodeName, + values.alwaysPull, + values.image.image, + registry + ); + + const containerResponse = await createAndStart( + environment, + config, + values.name, + values.nodeName + ); + + await applyContainerSettings( + containerResponse.Id, + environment, + values.enableWebhook, + values.accessControl, + containerResponse.Portainer?.ResourceControl, + registry + ); +} + +async function replace({ + oldContainer, + config, + values, + registry, + environment, + extraNetworks, +}: ReplaceOptions) { + await pullImageIfNeeded( + environment.Id, + values.nodeName, + values.alwaysPull, + values.image.image, + registry + ); + + const containerResponse = await renameAndCreate( + environment, + values, + oldContainer, + config + ); + + await applyContainerSettings( + containerResponse.Id, + environment, + values.enableWebhook, + values.accessControl, + containerResponse.Portainer?.ResourceControl, + registry + ); + + await connectToExtraNetworks( + environment.Id, + values.nodeName, + containerResponse.Id, + extraNetworks + ); + + await removeContainer(environment.Id, oldContainer.Id, { + nodeName: values.nodeName, + }); +} + +/** + * stop and renames the old container, and creates and stops the new container. + * on any failure, it will rename the old container to its original name + */ +async function renameAndCreate( + environment: Environment, + values: Values, + oldContainer: DockerContainer, + config: CreateContainerRequest +) { + let renamed = false; + try { + await stopContainerIfNeeded(environment.Id, values.nodeName, oldContainer); + + await renameContainer( + environment.Id, + oldContainer.Id, + `${oldContainer.Names[0]}-old`, + { nodeName: values.nodeName } + ); + renamed = true; + + return await createAndStart( + environment, + config, + values.name, + values.nodeName + ); + } catch (e) { + if (renamed) { + await renameContainer(environment.Id, oldContainer.Id, values.name, { + nodeName: values.nodeName, + }); + } + throw e; + } +} + +/** + * creates a webhook if necessary and applies resource control + */ +async function applyContainerSettings( + containerId: string, + environment: Environment, + enableWebhook: boolean, + accessControl: AccessControlFormData, + resourceControl?: ResourceControlResponse, + registry?: Registry +) { + if (enableWebhook) { + await createContainerWebhook(containerId, environment, registry?.Id); + } + + // 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(accessControl, resourceControl.Id); +} + +/** + * creates a new container and starts it. + * on failure, it will remove the new container + */ +async function createAndStart( + environment: Environment, + config: CreateContainerRequest, + name: string, + nodeName: string +) { + let containerId = ''; + try { + const containerResponse = await createContainer( + environment.Id, + config, + name, + { + nodeName, + } + ); + + containerId = containerResponse.Id; + + await startContainer(environment.Id, containerResponse.Id, { nodeName }); + return containerResponse; + } catch (e) { + if (containerId) { + await removeContainer(environment.Id, containerId, { + nodeName, + }); + } + + throw e; + } +} + +async function pullImageIfNeeded( + environmentId: EnvironmentId, + nodeName: string, + pull: boolean, + image: string, + registry?: Registry +) { + if (!pull) { + return null; + } + + return pullImage({ + environmentId, + nodeName, + image, + registry, + ignoreErrors: true, + }); +} + +async function createContainer( + environmentId: EnvironmentId, + config: CreateContainerRequest, + name?: string, + { nodeName }: { nodeName?: string } = {} +) { + try { + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + const { data } = await axios.post< + PortainerResponse<{ Id: string; Warnings: Array }> + >(urlBuilder(environmentId, undefined, 'create'), config, { + headers, + params: { name }, + }); + + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to create container'); + } +} + +async function createContainerWebhook( + containerId: string, + environment: Environment, + registryId?: RegistryId +) { + const isNotEdgeAgentOnDockerEnvironment = + environment.Type !== EnvironmentType.EdgeAgentOnDocker; + if (!isNotEdgeAgentOnDockerEnvironment) { + return; + } + + await createWebhook({ + resourceId: containerId, + environmentId: environment.Id, + registryId, + webhookType: WebhookType.DockerContainer, + }); +} + +function connectToExtraNetworks( + environmentId: EnvironmentId, + nodeName: string, + containerId: string, + extraNetworks: Array +) { + if (!extraNetworks) { + return null; + } + + return Promise.all( + extraNetworks.map(({ networkName, aliases }) => + connectContainer({ + networkId: networkName, + nodeName, + containerId, + environmentId, + aliases, + }) + ) + ); +} + +function stopContainerIfNeeded( + environmentId: EnvironmentId, + nodeName: string, + container: DockerContainer +) { + if (container.State !== 'running' || !container.Id) { + return null; + } + return stopContainer(environmentId, container.Id, { nodeName }); +} diff --git a/app/react/docker/containers/CreateView/useInitialValues.ts b/app/react/docker/containers/CreateView/useInitialValues.ts new file mode 100644 index 000000000..b0b42420f --- /dev/null +++ b/app/react/docker/containers/CreateView/useInitialValues.ts @@ -0,0 +1,167 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + +import { + BaseFormValues, + baseFormUtils, +} from '@/react/docker/containers/CreateView/BaseForm'; +import { + CapabilitiesTabValues, + capabilitiesTabUtils, +} from '@/react/docker/containers/CreateView/CapabilitiesTab'; +import { + CommandsTabValues, + commandsTabUtils, +} from '@/react/docker/containers/CreateView/CommandsTab'; +import { + LabelsTabValues, + labelsTabUtils, +} from '@/react/docker/containers/CreateView/LabelsTab'; +import { + NetworkTabValues, + networkTabUtils, +} from '@/react/docker/containers/CreateView/NetworkTab'; +import { + ResourcesTabValues, + resourcesTabUtils, +} from '@/react/docker/containers/CreateView/ResourcesTab'; +import { + RestartPolicy, + restartPolicyTabUtils, +} from '@/react/docker/containers/CreateView/RestartPolicyTab'; +import { + VolumesTabValues, + volumesTabUtils, +} from '@/react/docker/containers/CreateView/VolumesTab'; +import { + Values as EnvVarsTabValues, + envVarsTabUtils, +} from '@/react/docker/containers/CreateView/EnvVarsTab'; +import { UserId } from '@/portainer/users/types'; +import { getImageConfig } from '@/react/portainer/registries/utils/getImageConfig'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { useWebhooks } from '@/react/portainer/webhooks/useWebhooks'; +import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries'; + +import { useNetworksForSelector } from '../components/NetworkSelector'; +import { useContainers } from '../queries/containers'; +import { useContainer } from '../queries/container'; + +export interface Values extends BaseFormValues { + commands: CommandsTabValues; + volumes: VolumesTabValues; + network: NetworkTabValues; + labels: LabelsTabValues; + restartPolicy: RestartPolicy; + resources: ResourcesTabValues; + capabilities: CapabilitiesTabValues; + env: EnvVarsTabValues; +} + +export function useInitialValues(submitting: boolean) { + const { + params: { nodeName, from }, + } = useCurrentStateAndParams(); + const environmentId = useEnvironmentId(); + const { isAdmin, user } = useCurrentUser(); + const networksQuery = useNetworksForSelector(); + + const fromContainerQuery = useContainer(environmentId, from, { + enabled: !submitting, + }); + const runningContainersQuery = useContainers(environmentId, { + enabled: !!from, + }); + const webhookQuery = useWebhooks( + { endpointId: environmentId, resourceId: from }, + { enabled: !!from } + ); + const registriesQuery = useEnvironmentRegistries(environmentId, { + enabled: !!from, + }); + + if (!networksQuery.data) { + return null; + } + + if (!from) { + return { + initialValues: defaultValues(isAdmin, user.Id, nodeName), + }; + } + + const fromContainer = fromContainerQuery.data; + if ( + !fromContainer || + !registriesQuery.data || + !runningContainersQuery.data || + !webhookQuery.data + ) { + return null; + } + + const network = networkTabUtils.toViewModel( + fromContainer, + networksQuery.data, + runningContainersQuery.data + ); + + const extraNetworks = Object.entries( + fromContainer.NetworkSettings?.Networks || {} + ) + .filter(([n]) => n !== network.networkMode) + .map(([networkName, network]) => ({ + networkName, + aliases: (network.Aliases || []).filter( + (o) => !fromContainer.Id?.startsWith(o) + ), + })); + + const imageConfig = getImageConfig( + fromContainer?.Config?.Image || '', + registriesQuery.data + ); + + const initialValues: Values = { + commands: commandsTabUtils.toViewModel(fromContainer), + volumes: volumesTabUtils.toViewModel(fromContainer), + network: networkTabUtils.toViewModel( + fromContainer, + networksQuery.data, + runningContainersQuery.data + ), + labels: labelsTabUtils.toViewModel(fromContainer), + restartPolicy: restartPolicyTabUtils.toViewModel(fromContainer), + resources: resourcesTabUtils.toViewModel(fromContainer), + capabilities: capabilitiesTabUtils.toViewModel(fromContainer), + env: envVarsTabUtils.toViewModel(fromContainer), + ...baseFormUtils.toViewModel( + fromContainer, + isAdmin, + user.Id, + nodeName, + imageConfig, + (webhookQuery.data?.length || 0) > 0 + ), + }; + + return { initialValues, isDuplicating: true, extraNetworks }; +} + +function defaultValues( + isAdmin: boolean, + currentUserId: UserId, + nodeName: string +): Values { + return { + commands: commandsTabUtils.getDefaultViewModel(), + volumes: volumesTabUtils.getDefaultViewModel(), + network: networkTabUtils.getDefaultViewModel(), + labels: labelsTabUtils.getDefaultViewModel(), + restartPolicy: restartPolicyTabUtils.getDefaultViewModel(), + resources: resourcesTabUtils.getDefaultViewModel(), + capabilities: capabilitiesTabUtils.getDefaultViewModel(), + env: envVarsTabUtils.getDefaultViewModel(), + ...baseFormUtils.getDefaultViewModel(isAdmin, currentUserId, nodeName), + }; +} diff --git a/app/react/docker/containers/CreateView/validation.ts b/app/react/docker/containers/CreateView/validation.ts new file mode 100644 index 000000000..d3f7becc5 --- /dev/null +++ b/app/react/docker/containers/CreateView/validation.ts @@ -0,0 +1,58 @@ +import { object, SchemaOf } from 'yup'; +import { useMemo } from 'react'; + +import { baseFormUtils } from './BaseForm'; +import { capabilitiesTabUtils } from './CapabilitiesTab'; +import { commandsTabUtils } from './CommandsTab'; +import { labelsTabUtils } from './LabelsTab'; +import { networkTabUtils } from './NetworkTab'; +import { resourcesTabUtils } from './ResourcesTab'; +import { restartPolicyTabUtils } from './RestartPolicyTab'; +import { volumesTabUtils } from './VolumesTab'; +import { envVarsTabUtils } from './EnvVarsTab'; +import { Values } from './useInitialValues'; + +export function useValidation({ + isAdmin, + maxCpu, + maxMemory, + isDuplicating, + isDuplicatingPortainer, + isDockerhubRateLimited, +}: { + isAdmin: boolean; + maxCpu: number; + maxMemory: number; + isDuplicating: boolean | undefined; + isDuplicatingPortainer: boolean | undefined; + isDockerhubRateLimited: boolean; +}): SchemaOf { + return useMemo( + () => + object({ + commands: commandsTabUtils.validation(), + volumes: volumesTabUtils.validation(), + network: networkTabUtils.validation(), + labels: labelsTabUtils.validation(), + restartPolicy: restartPolicyTabUtils.validation(), + resources: resourcesTabUtils.validation({ maxCpu, maxMemory }), + capabilities: capabilitiesTabUtils.validation(), + env: envVarsTabUtils.validation(), + }).concat( + baseFormUtils.validation({ + isAdmin, + isDuplicating, + isDuplicatingPortainer, + isDockerhubRateLimited, + }) + ), + [ + isAdmin, + isDockerhubRateLimited, + isDuplicating, + isDuplicatingPortainer, + maxCpu, + maxMemory, + ] + ); +} diff --git a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx index c4c1e5133..05c0b8814 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx @@ -285,13 +285,15 @@ export function ContainersDatatableActions({ async function removeSelectedContainers( containers: DockerContainer[], - cleanVolumes: boolean + removeVolumes: boolean ) { for (let i = 0; i < containers.length; i += 1) { const container = containers[i]; try { - setPortainerAgentTargetHeader(container.NodeName); - await removeContainer(endpointId, container, cleanVolumes); + await removeContainer(endpointId, container.Id, { + removeVolumes, + nodeName: container.NodeName, + }); notifications.success( 'Container successfully removed', container.Names[0] diff --git a/app/react/docker/containers/containers.service.ts b/app/react/docker/containers/containers.service.ts index ffdcf6026..d511a20f9 100644 --- a/app/react/docker/containers/containers.service.ts +++ b/app/react/docker/containers/containers.service.ts @@ -1,89 +1,173 @@ +import { AxiosRequestHeaders } from 'axios'; + import { EnvironmentId } from '@/react/portainer/environments/types'; import PortainerError from '@/portainer/error'; -import axios from '@/portainer/services/axios'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; import { genericHandler } from '@/docker/rest/response/handlers'; -import { ContainerId, DockerContainer } from './types'; +import { ContainerId } from './types'; export async function startContainer( - endpointId: EnvironmentId, - id: ContainerId + environmentId: EnvironmentId, + id: ContainerId, + { nodeName }: { nodeName?: string } = {} ) { - await axios.post( - urlBuilder(endpointId, id, 'start'), - {}, - { transformResponse: genericHandler } - ); + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + try { + await axios.post( + urlBuilder(environmentId, id, 'start'), + {}, + { transformResponse: genericHandler, headers } + ); + } catch (e) { + throw parseAxiosError(e, 'Failed starting container'); + } } export async function stopContainer( endpointId: EnvironmentId, - id: ContainerId + id: ContainerId, + { nodeName }: { nodeName?: string } = {} ) { - await axios.post(urlBuilder(endpointId, id, 'stop'), {}); + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + await axios.post(urlBuilder(endpointId, id, 'stop'), {}, { headers }); } export async function recreateContainer( endpointId: EnvironmentId, id: ContainerId, - pullImage: boolean + pullImage: boolean, + { nodeName }: { nodeName?: string } = {} ) { - await axios.post(`/docker/${endpointId}/containers/${id}/recreate`, { - PullImage: pullImage, - }); + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + await axios.post( + `/docker/${endpointId}/containers/${id}/recreate`, + { + PullImage: pullImage, + }, + { headers } + ); } export async function restartContainer( endpointId: EnvironmentId, - id: ContainerId + id: ContainerId, + { nodeName }: { nodeName?: string } = {} ) { - await axios.post(urlBuilder(endpointId, id, 'restart'), {}); + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + await axios.post( + urlBuilder(endpointId, id, 'restart'), + {}, + { headers } + ); } export async function killContainer( endpointId: EnvironmentId, - id: ContainerId + id: ContainerId, + { nodeName }: { nodeName?: string } = {} ) { - await axios.post(urlBuilder(endpointId, id, 'kill'), {}); + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + await axios.post(urlBuilder(endpointId, id, 'kill'), {}, { headers }); } export async function pauseContainer( endpointId: EnvironmentId, - id: ContainerId + id: ContainerId, + { nodeName }: { nodeName?: string } = {} ) { - await axios.post(urlBuilder(endpointId, id, 'pause'), {}); + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + await axios.post(urlBuilder(endpointId, id, 'pause'), {}, { headers }); } export async function resumeContainer( endpointId: EnvironmentId, - id: ContainerId + id: ContainerId, + { nodeName }: { nodeName?: string } = {} ) { - await axios.post(urlBuilder(endpointId, id, 'unpause'), {}); + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + await axios.post( + urlBuilder(endpointId, id, 'unpause'), + {}, + { headers } + ); } export async function renameContainer( endpointId: EnvironmentId, id: ContainerId, - name: string + name: string, + { nodeName }: { nodeName?: string } = {} ) { + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + await axios.post( urlBuilder(endpointId, id, 'rename'), {}, - { params: { name }, transformResponse: genericHandler } + { params: { name }, transformResponse: genericHandler, headers } ); } export async function removeContainer( endpointId: EnvironmentId, - container: DockerContainer, - removeVolumes: boolean + containerId: string, + { + nodeName, + removeVolumes, + }: { removeVolumes?: boolean; nodeName?: string } = {} ) { try { + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + const { data } = await axios.delete( - urlBuilder(endpointId, container.Id), + urlBuilder(endpointId, containerId), { params: { v: removeVolumes ? 1 : 0, force: true }, transformResponse: genericHandler, + headers, } ); diff --git a/app/react/docker/containers/queries/container.ts b/app/react/docker/containers/queries/container.ts index c0ca5783f..75c2e91a6 100644 --- a/app/react/docker/containers/queries/container.ts +++ b/app/react/docker/containers/queries/container.ts @@ -12,7 +12,6 @@ import { PortainerResponse } from '@/react/docker/types'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { ContainerId } from '@/react/docker/containers/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; import { urlBuilder } from '../containers.service'; @@ -74,16 +73,18 @@ export interface ContainerJSON { export function useContainer( environmentId: EnvironmentId, - containerId: ContainerId + containerId?: ContainerId, + { enabled }: { enabled?: boolean } = {} ) { return useQuery( - queryKeys.container(environmentId, containerId), - () => getContainer(environmentId, containerId), + containerId ? queryKeys.container(environmentId, containerId) : [], + () => (containerId ? getContainer(environmentId, containerId) : undefined), { meta: { title: 'Failure', message: 'Unable to retrieve container', }, + enabled: enabled && !!containerId, } ); } @@ -98,19 +99,8 @@ async function getContainer( const { data } = await axios.get( urlBuilder(environmentId, containerId, 'json') ); - return parseViewModel(data); + return data; } catch (error) { throw parseAxiosError(error as Error, 'Unable to retrieve container'); } } - -export function parseViewModel(response: ContainerResponse) { - const resourceControl = - response.Portainer?.ResourceControl && - new ResourceControlViewModel(response?.Portainer?.ResourceControl); - - return { - ...response, - ResourceControl: resourceControl, - }; -} diff --git a/app/react/docker/containers/queries/containers.ts b/app/react/docker/containers/queries/containers.ts index 338e36a1f..1c6c2b6ba 100644 --- a/app/react/docker/containers/queries/containers.ts +++ b/app/react/docker/containers/queries/containers.ts @@ -9,7 +9,7 @@ import { withGlobalError } from '@/react-tools/react-query'; import { urlBuilder } from '../containers.service'; import { DockerContainerResponse } from '../types/response'; -import { parseListViewModel } from '../utils'; +import { toListViewModel } from '../utils'; import { DockerContainer } from '../types'; import { Filters } from './types'; @@ -26,10 +26,12 @@ export function useContainers( { autoRefreshRate, select, + enabled, ...params }: UseContainers & { autoRefreshRate?: number; select?: (data: DockerContainer[]) => T; + enabled?: boolean; } = {} ) { return useQuery( @@ -41,6 +43,7 @@ export function useContainers( return autoRefreshRate ?? false; }, select, + enabled, } ); } @@ -61,7 +64,7 @@ export async function getContainers( : undefined, } ); - return data.map((c) => parseListViewModel(c)); + return data.map((c) => toListViewModel(c)); } catch (error) { throw parseAxiosError(error as Error, 'Unable to retrieve containers'); } diff --git a/app/react/docker/containers/queries/query-keys.ts b/app/react/docker/containers/queries/query-keys.ts index d7843e4f9..ec39d14dd 100644 --- a/app/react/docker/containers/queries/query-keys.ts +++ b/app/react/docker/containers/queries/query-keys.ts @@ -6,7 +6,7 @@ import { Filters } from './types'; export const queryKeys = { list: (environmentId: EnvironmentId) => - [dockerQueryKeys.root(environmentId), 'containers'] as const, + [...dockerQueryKeys.root(environmentId), 'containers'] as const, filters: ( environmentId: EnvironmentId, diff --git a/app/react/docker/containers/queries/types.ts b/app/react/docker/containers/queries/types.ts index 2a81b49af..115524cfe 100644 --- a/app/react/docker/containers/queries/types.ts +++ b/app/react/docker/containers/queries/types.ts @@ -3,6 +3,7 @@ import { ContainerStatus } from '../types'; export interface Filters { label?: string[]; + name?: string[]; network?: NetworkId[]; status?: ContainerStatus[]; } diff --git a/app/react/docker/containers/utils.ts b/app/react/docker/containers/utils.ts index 6550c50b0..a1dca9974 100644 --- a/app/react/docker/containers/utils.ts +++ b/app/react/docker/containers/utils.ts @@ -8,7 +8,7 @@ import { useEnvironment } from '@/react/portainer/environments/queries'; import { DockerContainer, ContainerStatus } from './types'; import { DockerContainerResponse } from './types/response'; -export function parseListViewModel( +export function toListViewModel( response: DockerContainerResponse ): DockerContainer { const resourceControl = diff --git a/app/react/docker/images/queries/build-url.ts b/app/react/docker/images/queries/build-url.ts index 71d6df3a0..e9ad86c1a 100644 --- a/app/react/docker/images/queries/build-url.ts +++ b/app/react/docker/images/queries/build-url.ts @@ -1,6 +1,23 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { buildUrl as buildDockerUrl } from '@/react/docker/queries/utils/build-url'; +import { buildUrl as buildDockerProxyUrl } from '@/react/docker/proxy/queries/build-url'; export function buildUrl(environmentId: EnvironmentId) { return buildDockerUrl(environmentId, 'images'); } + +export function buildProxyUrl( + environmentId: EnvironmentId, + { id, action }: { id?: string; action?: string } = {} +) { + let dockerAction = ''; + if (id) { + dockerAction += `${id}`; + } + + if (action) { + dockerAction = dockerAction ? `${dockerAction}/${action}` : action; + } + + return buildDockerProxyUrl(environmentId, 'images', dockerAction); +} diff --git a/app/react/docker/images/queries/usePullImageMutation.ts b/app/react/docker/images/queries/usePullImageMutation.ts new file mode 100644 index 000000000..66271bc46 --- /dev/null +++ b/app/react/docker/images/queries/usePullImageMutation.ts @@ -0,0 +1,56 @@ +import { AxiosRequestHeaders } from 'axios'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { Registry } from '@/react/portainer/registries/types/registry'; + +import { buildImageFullURI } from '../utils'; + +import { encodeRegistryCredentials } from './encodeRegistryCredentials'; +import { buildProxyUrl } from './build-url'; + +interface PullImageOptions { + environmentId: EnvironmentId; + image: string; + nodeName?: string; + registry?: Registry; + ignoreErrors: boolean; +} + +export async function pullImage({ + environmentId, + ignoreErrors, + image, + nodeName, + registry, +}: PullImageOptions) { + const authenticationDetails = + registry && registry.Authentication + ? encodeRegistryCredentials(registry.Id) + : ''; + + const imageURI = buildImageFullURI(image, registry); + + const headers: AxiosRequestHeaders = { + 'X-Registry-Auth': authenticationDetails, + }; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + try { + await axios.post(buildProxyUrl(environmentId, { action: 'create' }), null, { + params: { + fromImage: imageURI, + }, + headers, + }); + } catch (err) { + if (ignoreErrors) { + return; + } + + throw parseAxiosError(err as Error, 'Unable to pull image'); + } +} diff --git a/app/react/docker/types.ts b/app/react/docker/types.ts index 4ffd662a3..15cabc864 100644 --- a/app/react/docker/types.ts +++ b/app/react/docker/types.ts @@ -11,4 +11,8 @@ export interface PortainerMetadata { export type PortainerResponse = T & { Portainer?: PortainerMetadata; + /** + * will be true if the portainer is running in this resource + */ + IsPortainer?: boolean; }; diff --git a/app/react/edge/components/EdgeScriptForm/ScriptTabs.tsx b/app/react/edge/components/EdgeScriptForm/ScriptTabs.tsx index 653d2b5ff..f076bb080 100644 --- a/app/react/edge/components/EdgeScriptForm/ScriptTabs.tsx +++ b/app/react/edge/components/EdgeScriptForm/ScriptTabs.tsx @@ -5,6 +5,7 @@ import { useAgentDetails } from '@/react/portainer/environments/queries/useAgent import { Code } from '@@/Code'; import { CopyButton } from '@@/buttons/CopyButton'; import { NavTabs } from '@@/NavTabs'; +import { NavContainer } from '@@/NavTabs/NavContainer'; import { ScriptFormValues, Platform } from './types'; import { CommandTab } from './scripts'; @@ -67,10 +68,12 @@ export function ScriptTabs({ }); return ( - onPlatformChange(id)} - /> + + onPlatformChange(id)} + /> + ); } diff --git a/app/react/hooks/useUser.tsx b/app/react/hooks/useUser.tsx index 430568a48..7088e26eb 100644 --- a/app/react/hooks/useUser.tsx +++ b/app/react/hooks/useUser.tsx @@ -70,6 +70,20 @@ export function useAuthorizations( ); } +export function useIsEnvironmentAdmin({ + forceEnvironmentId, + adminOnlyCE = true, +}: { + forceEnvironmentId?: EnvironmentId; + adminOnlyCE?: boolean; +} = {}) { + return useAuthorizations( + ['EndpointResourcesAccess'], + forceEnvironmentId, + adminOnlyCE + ); +} + export function isEnvironmentAdmin( user: User, environmentId: EnvironmentId, diff --git a/app/react/portainer/access-control/AccessControlForm/AccessControlForm.validation.ts b/app/react/portainer/access-control/AccessControlForm/AccessControlForm.validation.ts index 174367031..d15cd22d3 100644 --- a/app/react/portainer/access-control/AccessControlForm/AccessControlForm.validation.ts +++ b/app/react/portainer/access-control/AccessControlForm/AccessControlForm.validation.ts @@ -1,15 +1,17 @@ -import { object, string, array, number } from 'yup'; +import { object, mixed, array, number, SchemaOf } from 'yup'; -import { ResourceControlOwnership } from '../types'; +import { AccessControlFormData, ResourceControlOwnership } from '../types'; -export function validationSchema(isAdmin: boolean) { +export function validationSchema( + isAdmin: boolean +): SchemaOf { return object() .shape({ - ownership: string() + ownership: mixed() .oneOf(Object.values(ResourceControlOwnership)) .required(), - authorizedUsers: array(number()), - authorizedTeams: array(number()), + authorizedUsers: array(number().default(0)), + authorizedTeams: array(number().default(0)), }) .test( 'user-and-team', diff --git a/app/react/portainer/access-control/access-control.service.ts b/app/react/portainer/access-control/access-control.service.ts index b94263d23..6ad2cd1f1 100644 --- a/app/react/portainer/access-control/access-control.service.ts +++ b/app/react/portainer/access-control/access-control.service.ts @@ -4,7 +4,6 @@ import { AccessControlFormData, OwnershipParameters, ResourceControlId, - ResourceControlResponse, ResourceControlType, ResourceId, } from './types'; @@ -39,14 +38,14 @@ export function applyResourceControlChange( */ export function applyResourceControl( accessControlData: AccessControlFormData, - resourceControl: ResourceControlResponse, + resourceControlId: ResourceControlId, subResourcesIds: (number | string)[] = [] ) { const ownershipParameters = parseOwnershipParameters( accessControlData, subResourcesIds ); - return updateResourceControl(resourceControl.Id, ownershipParameters); + return updateResourceControl(resourceControlId, ownershipParameters); } /** diff --git a/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx b/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx index 579346b7a..212ff7c00 100644 --- a/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx +++ b/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx @@ -3,6 +3,7 @@ import { number } from 'yup'; import { useEffect } from 'react'; import { NavTabs } from '@@/NavTabs'; +import { NavContainer } from '@@/NavTabs/NavContainer'; import { ScheduleType } from '../types'; @@ -37,35 +38,37 @@ export function ScheduleTypeSelector() { return (
    - - ), - }, - { - id: ScheduleType.Rollback, - label: 'Rollback', - children: ( - - ), - }, - ]} - selectedId={values.type} - onSelect={handleChangeType} - /> + + + ), + }, + { + id: ScheduleType.Rollback, + label: 'Rollback', + children: ( + + ), + }, + ]} + selectedId={values.type} + onSelect={handleChangeType} + /> +
    ); diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/DeploymentScripts.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/DeploymentScripts.tsx index bbc8eec12..855e0aab0 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/DeploymentScripts.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/DeploymentScripts.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { CopyButton } from '@@/buttons/CopyButton'; import { Code } from '@@/Code'; import { NavTabs } from '@@/NavTabs'; +import { NavContainer } from '@@/NavTabs/NavContainer'; const deployments = [ { @@ -27,11 +28,13 @@ export function DeploymentScripts() { })); return ( - setDeployType(id)} - selectedId={deployType} - /> + + setDeployType(id)} + selectedId={deployType} + /> + ); } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/AgentTab/DeploymentScripts.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/AgentTab/DeploymentScripts.tsx index 37e3ed1b0..b0f455718 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/AgentTab/DeploymentScripts.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/AgentTab/DeploymentScripts.tsx @@ -5,6 +5,7 @@ import { useAgentDetails } from '@/react/portainer/environments/queries/useAgent import { CopyButton } from '@@/buttons/CopyButton'; import { Code } from '@@/Code'; import { NavTabs } from '@@/NavTabs'; +import { NavContainer } from '@@/NavTabs/NavContainer'; const deploymentsStandalone = [ { @@ -61,11 +62,13 @@ export function DeploymentScripts({ isDockerStandalone }: Props) { }); return ( - setDeployType(id)} - selectedId={deployType} - /> + + setDeployType(id)} + selectedId={deployType} + /> + ); } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/DeploymentScripts.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/DeploymentScripts.tsx index 8530d8050..128ec2fb6 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/DeploymentScripts.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/DeploymentScripts.tsx @@ -9,6 +9,7 @@ import { Code } from '@@/Code'; import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; import { NavTabs } from '@@/NavTabs'; import { Icon } from '@@/Icon'; +import { NavContainer } from '@@/NavTabs/NavContainer'; const deployments = [ { @@ -63,11 +64,13 @@ export function DeploymentScripts() {
- setDeployType(id)} - selectedId={deployType} - /> + + setDeployType(id)} + selectedId={deployType} + /> + ); } diff --git a/app/react/portainer/registries/types/registry.ts b/app/react/portainer/registries/types/registry.ts index c068c559c..002a9c20c 100644 --- a/app/react/portainer/registries/types/registry.ts +++ b/app/react/portainer/registries/types/registry.ts @@ -1,6 +1,8 @@ import { TeamId } from '@/react/portainer/users/teams/types'; import { UserId } from '@/portainer/users/types'; +import { TLSConfiguration } from '../../settings/types'; + export type Catalog = { repositories: string[]; }; @@ -60,20 +62,31 @@ export interface Ecr { Region: string; } +interface RegistryManagementConfiguration { + Type: RegistryTypes; + Authentication: boolean; + Username: string; + Password: string; + TLSConfig: TLSConfiguration; + Ecr: Ecr; + AccessToken?: string; + AccessTokenExpiry?: number; +} + export type RegistryId = number; export interface Registry { Id: RegistryId; - Type: number; + Type: RegistryTypes; Name: string; URL: string; BaseURL: string; Authentication: boolean; Username: string; - Password: string; + Password?: string; RegistryAccesses: RegistryAccesses; - Checked: boolean; Gitlab: Gitlab; Quay: Quay; Github: Github; Ecr: Ecr; + ManagementConfiguration?: RegistryManagementConfiguration; } diff --git a/app/react/portainer/registries/utils/findRegistryMatch.test.ts b/app/react/portainer/registries/utils/findRegistryMatch.test.ts index 992509b49..7b976df44 100644 --- a/app/react/portainer/registries/utils/findRegistryMatch.test.ts +++ b/app/react/portainer/registries/utils/findRegistryMatch.test.ts @@ -17,7 +17,6 @@ function buildTestRegistry( Authentication: false, Password: '', BaseURL: '', - Checked: false, Ecr: { Region: '' }, Github: { OrganisationName: '', UseOrganisation: false }, Quay: { OrganisationName: '', UseOrganisation: false }, diff --git a/app/react/portainer/webhooks/build-url.ts b/app/react/portainer/webhooks/build-url.ts new file mode 100644 index 000000000..6f195eced --- /dev/null +++ b/app/react/portainer/webhooks/build-url.ts @@ -0,0 +1,11 @@ +import { Webhook } from './types'; + +export function buildUrl(id?: Webhook['Id']) { + const url = '/webhooks'; + + if (id) { + return `${url}/${id}`; + } + + return url; +} diff --git a/app/react/portainer/webhooks/createWebhook.ts b/app/react/portainer/webhooks/createWebhook.ts new file mode 100644 index 000000000..431e26d45 --- /dev/null +++ b/app/react/portainer/webhooks/createWebhook.ts @@ -0,0 +1,23 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { EnvironmentId } from '../environments/types'; +import { RegistryId } from '../registries/types/registry'; + +import { buildUrl } from './build-url'; +import { Webhook, WebhookType } from './types'; + +interface CreateWebhookPayload { + resourceId: string; + environmentId: EnvironmentId; + registryId?: RegistryId; + webhookType: WebhookType; +} + +export async function createWebhook(payload: CreateWebhookPayload) { + try { + const { data } = await axios.post(buildUrl(), payload); + return data; + } catch (error) { + throw parseAxiosError(error, 'Unable to create webhook'); + } +} diff --git a/app/react/portainer/webhooks/query-keys.ts b/app/react/portainer/webhooks/query-keys.ts new file mode 100644 index 000000000..84bf4b1df --- /dev/null +++ b/app/react/portainer/webhooks/query-keys.ts @@ -0,0 +1,6 @@ +import { Filters } from './types'; + +export const queryKeys = { + base: () => ['webhooks'] as const, + list: (filters: Filters) => [...queryKeys.base(), { filters }], +}; diff --git a/app/react/portainer/webhooks/types.ts b/app/react/portainer/webhooks/types.ts new file mode 100644 index 000000000..0026e5d9c --- /dev/null +++ b/app/react/portainer/webhooks/types.ts @@ -0,0 +1,21 @@ +import { EnvironmentId } from '../environments/types'; +import { RegistryId } from '../registries/types/registry'; + +export enum WebhookType { + DockerService = 1, + DockerContainer = 2, +} + +export interface Webhook { + Id: number; + Token: string; + ResourceId: string; + EndpointId: EnvironmentId; + RegistryId: RegistryId; + Type: WebhookType; +} + +export interface Filters { + endpointId: EnvironmentId; + resourceId?: string; +} diff --git a/app/react/portainer/webhooks/useWebhooks.ts b/app/react/portainer/webhooks/useWebhooks.ts new file mode 100644 index 000000000..b2d21487f --- /dev/null +++ b/app/react/portainer/webhooks/useWebhooks.ts @@ -0,0 +1,27 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; +import { Filters, Webhook } from './types'; + +export function useWebhooks( + filters: Filters, + { enabled }: { enabled?: boolean } = {} +) { + return useQuery(queryKeys.list(filters), () => getWebhooks(filters), { + enabled, + }); +} + +async function getWebhooks(filters: Filters) { + try { + const { data } = await axios.get>(buildUrl(), { + params: { filters }, + }); + return data; + } catch (err) { + throw parseAxiosError(err, 'failed fetching webhooks'); + } +}