refactor(containers): migrate create view to react [EE-2307] (#9175)

pull/10445/head
Chaim Lev-Ari 2023-10-19 13:45:50 +02:00 committed by GitHub
parent bc0050a7b4
commit d970f0e2bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 2612 additions and 1399 deletions

View File

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

View File

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

View File

@ -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<ComponentProps<typeof CommandsTab>, 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<ComponentProps<typeof NetworkTab>, NetworkTabValues>(
ngModule,
withUIRouter(withReactQuery(NetworkTab)),
'dockerCreateContainerNetworkTab',
[],
networkTabUtils.validation
);
withFormValidation<ComponentProps<typeof ResourcesTab>, 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
);

View File

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

View File

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

View File

@ -1,8 +0,0 @@
.edit-resources {
padding: 20px;
border: 1px solid var(--border-widget-color);
}
.widget .edit-resources button {
margin-left: 0;
}

View File

@ -1,273 +0,0 @@
<page-header title="'Create container'" breadcrumbs="[{label:'Containers', link:'docker.containers'}, 'Add container']"> </page-header>
<information-panel title-text="Caution" ng-if="state.mode == 'duplicate'">
<span class="small">
<p class="text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'" class-name="'mr-0.5'"></pr-icon>
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
<a href="https://docs.portainer.io/user/docker/containers/advanced" target="_blank">other settings</a> set by an image.
</p>
</span>
</information-panel>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" autocomplete="off">
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-8">
<input type="text" class="form-control" ng-model="config.name" id="container_name" placeholder="e.g. myContainer" />
</div>
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title"> Image configuration </div>
<div ng-if="!formValues.RegistryModel.Registry && fromContainer">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<span class="small text-danger" style="margin-left: 5px">
The Docker registry for the <code>{{ config.Image }}</code> image is not registered inside Portainer, you will not be able to create a container. Please register that
registry first.
</span>
</div>
<div ng-if="formValues.RegistryModel.Registry || !fromContainer">
<!-- image-and-registry -->
<por-image-registry
model="formValues.RegistryModel"
ng-if="formValues.RegistryModel.Registry"
auto-complete="true"
endpoint="endpoint"
is-admin="isAdmin"
check-rate-limits="formValues.alwaysPull"
on-image-change="onImageNameChange()"
set-validity="setPullImageValidity"
>
<!-- always-pull -->
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
name="'alwaysPull'"
label-class="'col-sm-2'"
checked="formValues.alwaysPull"
disabled="!state.pullImageValidity"
label="'Always pull the image'"
on-change="(onAlwaysPullChange)"
tooltip="'When enabled, Portainer will automatically try to pull the specified image before creating the container.'"
></por-switch-field>
</div>
</div>
<!-- !always-pull -->
</por-image-registry>
<!-- !image-and-registry -->
</div>
<!-- create-webhook -->
<div ng-if="isAdmin && applicationState.endpoint.type !== 4">
<div class="col-sm-12 form-section-title"> Webhooks </div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
feature-id="'container-webhook'"
label-class="'col-sm-2'"
label="'Create a container webhook'"
tooltip="'Create a webhook (or callback URI) to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container.'"
></por-switch-field>
</div>
</div>
</div>
<!-- !create-webhook -->
<div class="col-sm-12 form-section-title"> Network ports configuration </div>
<!-- publish-exposed-ports -->
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label-class="'col-sm-2'"
checked="config.HostConfig.PublishAllPorts"
label="'Publish all exposed network ports to random host ports'"
on-change="(handlePublishAllPortsChange)"
tooltip="'When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile.'"
></por-switch-field>
</div>
</div>
<!-- !publish-exposed-ports -->
<!-- port-mapping -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Manual network port publishing
<portainer-tooltip
message="'When a range of ports on the host and a single port on the container is specified, Docker will randomly choose a single available port in the defined range and forward that to the container port.'"
></portainer-tooltip>
</label>
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addPortBinding()">
<pr-icon icon="'plus'" mode="'alt'"></pr-icon> publish a new network port
</span>
</div>
<!-- port-mapping-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px">
<div ng-repeat="portBinding in config.HostConfig.PortBindings" style="margin-top: 2px">
<!-- host-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80, 80-88, ip:80 or ip:80-88 (optional)" />
</div>
<!-- !host-port -->
<span style="margin: 0 10px 0 10px">
<pr-icon icon="'arrow-right'"></pr-icon>
</span>
<!-- container-port -->
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80 or 80-88" />
</div>
<!-- !container-port -->
<!-- protocol-actions -->
<div class="input-group col-sm-3 input-group-sm">
<div class="btn-group btn-group-sm">
<label class="btn btn-light" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
<label class="btn btn-light" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
</div>
<button class="btn btn-light" type="button" ng-click="removePortBinding($index)">
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
</button>
</div>
<!-- !protocol-actions -->
</div>
</div>
<!-- !port-mapping-input-list -->
</div>
<!-- !port-mapping -->
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-sm-12 form-section-title"> Deployment </div>
<!-- node-selection -->
<node-selector model="formValues.NodeName" endpoint-id="endpoint.Id"> </node-selector>
<!-- !node-selection -->
</div>
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="fromContainer"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title"> Actions </div>
<!-- autoremove -->
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
label-class="'col-sm-2'"
checked="config.HostConfig.AutoRemove"
label="'Auto remove'"
on-change="(handleAutoRemoveChange)"
tooltip="'When enabled, Portainer will automatically remove the container when it exits. This is useful when you want to use the container only once.'"
></por-switch-field>
</div>
</div>
<!-- !autoremove -->
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer)
|| (fromContainer.IsPortainer && fromContainer.Name === '/' + config.name)"
ng-click="create()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Deploy the container</span>
<span ng-show="state.actionInProgress">Deployment in progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="settings" title-text="Advanced container settings"></rd-widget-header>
<rd-widget-body>
<ul class="nav nav-pills nav-justified">
<li class="active interactive"><a data-target="#command" data-toggle="tab">Command & logging</a></li>
<li class="interactive"><a data-target="#volumes" data-toggle="tab">Volumes</a></li>
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
<li class="interactive"><a data-target="#env" data-toggle="tab">Env</a></li>
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
<li class="interactive"><a data-target="#restart-policy" data-toggle="tab">Restart policy</a></li>
<li class="interactive"><a data-target="#runtime-resources" ng-mouseup="refreshSlider()" data-toggle="tab">Runtime & Resources</a></li>
<li ng-if="areContainerCapabilitiesEnabled" class="interactive"><a data-target="#container-capabilities" data-toggle="tab">Capabilities</a></li>
</ul>
<div class="form-horizontal" ng-if="state.containerIsLoaded">
<!-- tab-content -->
<div class="tab-content">
<!-- tab-command -->
<div class="tab-pane active" id="command">
<docker-create-container-commands-tab
values="formValues.commands"
api-version="applicationState.endpoint.apiVersion"
on-change="(handleCommandsChange)"
></docker-create-container-commands-tab>
</div>
<!-- !tab-command -->
<div class="tab-pane" id="volumes">
<docker-create-container-volumes-tab ng-if="state.containerIsLoaded" values="formValues.volumes" on-change="(onVolumesChange)" allow-bind-mounts="allowBindMounts">
</docker-create-container-volumes-tab>
</div>
<div class="tab-pane" id="network">
<docker-create-container-network-tab values="formValues.network" on-change="(onNetworkChange)"> </docker-create-container-network-tab>
</div>
<div class="tab-pane" id="labels">
<docker-create-container-labels-tab values="formValues.labels" on-change="(onLabelsChange)"></docker-create-container-labels-tab>
</div>
<!-- tab-env -->
<div class="tab-pane" id="env">
<docker-create-container-env-vars-tab
ng-if="state.containerIsLoaded"
values="formValues.envVars"
on-change="(handleEnvVarsChange)"
></docker-create-container-env-vars-tab>
</div>
<!-- !tab-env -->
<div class="tab-pane" id="restart-policy">
<docker-create-container-restart-policy-tab values="formValues.restartPolicy" on-change="(onRestartPolicyChange)"></docker-create-container-restart-policy-tab>
</div>
<!-- tab-runtime-resources -->
<div class="tab-pane" id="runtime-resources">
<docker-create-container-resources-tab
values="formValues.resources"
on-change="(onResourcesChange)"
allow-privileged-mode="allowPrivilegedMode"
is-devices-field-visible="showDeviceMapping"
is-sysctl-field-visible="allowSysctl"
is-init-field-visible="applicationState.endpoint.apiVersion >= 1.37"
is-image-invalid="!formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer)"
redeploy="(redeployUnlimitedResources)"
is-duplicate="state.mode == 'duplicate'"
validation-data="{
maxMemory: state.sliderMaxMemory,
maxCpu: state.sliderMaxCpu,
}"
></docker-create-container-resources-tab>
</div>
<!-- !tab-runtime-resources -->
<!-- tab-container-capabilities -->
<div class="tab-pane" id="container-capabilities">
<docker-create-container-capabilities-tab values="formValues.capabilities" on-change="(onCapabilitiesChange)"></docker-create-container-capabilities-tab>
</div>
<!-- !tab-container-capabilities -->
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

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

View File

@ -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<Values>;
fieldNamespace?: string;
onChangeImage?: (name: string) => void;
setFieldValue: <T>(field: string, value: T) => void;
}) {
const { setFieldValue } = useFormikContext<Values>();
return (
<>
<TextTip color="blue">
@ -27,15 +27,15 @@ export function AdvancedForm({
<Input
id="image-field"
value={values.image}
onChange={(e) => 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
/>
</FormControl>
</>
);
function namespaced(field: string) {
return fieldNamespace ? `${fieldNamespace}.${field}` : field;
}
}

View File

@ -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<Values>;
fieldNamespace?: string;
checkRateLimits?: boolean;
autoComplete?: boolean;
setValidity: (error?: string) => void;
onRateLimit?: (limited?: boolean) => void;
onChangeImage?: (name: string) => void;
setFieldValue: <T>(field: string, value: T) => void;
}>) {
const { setFieldValue } = useFormikContext<Values>();
const Component = values.useRegistry ? SimpleForm : AdvancedForm;
return (
<div className="row">
<Component
autoComplete={autoComplete}
fieldNamespace={fieldNamespace}
values={values}
errors={errors}
onChangeImage={onChangeImage}
setFieldValue={setFieldValue}
/>
<div className="form-group">
@ -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
</Button>
@ -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
</Button>
@ -66,13 +65,9 @@ export function ImageConfigFieldset({
{children}
{checkRateLimits && values.useRegistry && (
<RateLimits registryId={values.registryId} setValidity={setValidity} />
{onRateLimit && values.useRegistry && (
<RateLimits registryId={values.registryId} onRateLimit={onRateLimit} />
)}
</div>
);
function namespaced(field: string) {
return fieldNamespace ? `${fieldNamespace}.${field}` : field;
}
}

View File

@ -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({
<RateLimitsInner
isAuthenticated={registry?.Authentication}
registryId={registryId}
setValidity={setValidity}
onRateLimit={onRateLimit}
environment={environmentQuery.data}
/>
);
@ -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) {

View File

@ -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<Values>;
fieldNamespace?: string;
onChangeImage?: (name: string) => void;
setFieldValue: <T>(field: string, value: T) => void;
}) {
const { setFieldValue } = useFormikContext<Values>();
const registryQuery = useRegistry(values.registryId);
const registry = registryQuery.data;
@ -55,7 +55,7 @@ export function SimpleForm({
errors={errors?.registryId}
>
<RegistrySelector
onChange={(value) => setFieldValue(namespaced('registryId'), value)}
onChange={(value) => setFieldValue('registryId', value)}
value={values.registryId}
inputId="registry-field"
/>
@ -66,7 +66,10 @@ export function SimpleForm({
<InputGroup.Addon>{registryUrl}</InputGroup.Addon>
<ImageField
onChange={(value) => 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({
</FormControl>
</>
);
function namespaced(field: string) {
return fieldNamespace ? `${fieldNamespace}.${field}` : field;
}
}
function getImagesForRegistry(

View File

@ -2,10 +2,10 @@ import { bool, number, object, SchemaOf, string } from 'yup';
import { Values } from './types';
export function validation(): SchemaOf<Values> {
export function validation(rateLimitExceeded: boolean): SchemaOf<Values> {
return object({
image: string().required('Image is required'),
registryId: number().default(0),
useRegistry: bool().default(false),
});
}).test('rate-limits', 'Rate limit exceeded', () => !rateLimitExceeded);
}

View File

@ -0,0 +1,17 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
export function NavContainer({ children }: PropsWithChildren<unknown>) {
return (
<div
className={clsx(
'rounded-lg border border-solid p-2',
'border-gray-5 bg-gray-2',
'th-dark:border-gray-neutral-8 th-dark:bg-gray-iron-10',
'th-highcontrast:border-white th-highcontrast:bg-black'
)}
>
{children}
</div>
);
}

View File

@ -14,6 +14,8 @@ interface Props<T extends string | number> {
selectedId?: T;
onSelect?(id: T): void;
disabled?: boolean;
type?: 'tabs' | 'pills';
justified?: boolean;
}
export function NavTabs<T extends string | number = string>({
@ -21,12 +23,16 @@ export function NavTabs<T extends string | number = string>({
selectedId,
onSelect = () => {},
disabled,
type = 'tabs',
justified = false,
}: Props<T>) {
const selected = options.find((option) => option.id === selectedId);
return (
<div className="nav-container">
<ul className="nav nav-tabs">
<div>
<ul
className={clsx('nav', `nav-${type}`, { 'nav-justified': justified })}
>
{options.map((option) => (
<li
className={clsx({

View File

@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { FormControl } from '@@/form-components/FormControl';
import { useApiVersion } from './queries/useApiVersion';
import { useAgentNodes } from './queries/useAgentNodes';
export function NodeSelector({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const environmentId = useEnvironmentId();
const apiVersionQuery = useApiVersion(environmentId);
const nodesQuery = useAgentNodes<Array<Option<string>>>(
environmentId,
apiVersionQuery.data || 1,
{
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 (
<FormControl label="Node" inputId="node-selector">
<PortainerSelect
inputId="node-selector"
value={value}
onChange={onChange}
options={nodesQuery.data || []}
/>
</FormControl>
);
}

View File

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

View File

@ -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<T = Array<Node>>(
environmentId: EnvironmentId,
apiVersion: number,
{
select,
enabled,
}: {
select?: (data: Array<Node>) => 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<Array<Node>>(
buildAgentUrl(environmentId, apiVersion, 'agents')
);
return response.data;
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to retrieve nodes');
}
}

View File

@ -0,0 +1,29 @@
import { useQuery } from 'react-query';
import axios, {
isAxiosError,
parseAxiosError,
} from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildUrl } from '../../proxy/queries/build-url';
export function useApiVersion(environmentId: EnvironmentId) {
return useQuery(['environment', environmentId, 'agent', 'ping'], () =>
getApiVersion(environmentId)
);
}
async function getApiVersion(environmentId: EnvironmentId) {
try {
const { headers } = await axios.get(buildUrl(environmentId, 'ping'));
return parseInt(headers['portainer-agent-api-version'], 10) || 1;
} catch (error) {
// 404 - agent is up - set version to 1
if (isAxiosError(error) && error.response?.status === 404) {
return 1;
}
throw parseAxiosError(error as Error, 'Unable to ping agent');
}
}

View File

@ -0,0 +1,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<Values>();
const environmentQuery = useCurrentEnvironment();
const isAgentOnSwarm = useIsAgentOnSwarm();
if (!environmentQuery.data) {
return null;
}
const environment = environmentQuery.data;
const canUseWebhook = environment.Type !== EnvironmentType.EdgeAgentOnDocker;
return (
<Widget>
<Widget.Body>
<FormControl label="Name" inputId="name-input" errors={errors?.name}>
<Input
id="name-input"
value={values.name}
onChange={(e) => {
const name = e.target.value;
onChangeName(name);
setFieldValue('name', name);
}}
placeholder="e.g. myContainer"
/>
</FormControl>
<FormSection title="Image Configuration">
<ImageConfigFieldset
values={values.image}
setFieldValue={(field, value) =>
setFieldValue(`image.${field}`, value)
}
autoComplete
onRateLimit={values.alwaysPull ? onRateLimit : undefined}
errors={errors?.image}
onChangeImage={onChangeImageName}
>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Always pull the image"
tooltip="When enabled, Portainer will automatically try to pull the specified image before creating the container."
checked={values.alwaysPull}
onChange={(alwaysPull) =>
setFieldValue('alwaysPull', alwaysPull)
}
/>
</div>
</div>
</ImageConfigFieldset>
</FormSection>
{canUseWebhook && (
<Authorized authorizations="PortainerWebhookCreate" adminOnlyCE>
<FormSection title="Webhook">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Create a container webhook"
tooltip="Create a webhook (or callback URI) to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container."
checked={values.enableWebhook}
onChange={(enableWebhook) =>
setFieldValue('enableWebhook', enableWebhook)
}
featureId={FeatureId.CONTAINER_WEBHOOK}
/>
</div>
</div>
</FormSection>
</Authorized>
)}
<FormSection title="Network ports configuration">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Publish all exposed ports to random host ports"
tooltip="When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile."
checked={values.publishAllPorts}
onChange={(publishAllPorts) =>
setFieldValue('publishAllPorts', publishAllPorts)
}
/>
</div>
</div>
<PortsMappingField
value={values.ports}
onChange={(ports) => setFieldValue('ports', ports)}
errors={errors?.ports}
/>
</FormSection>
{isAgentOnSwarm && (
<FormSection title="Deployment">
<NodeSelector
value={values.nodeName}
onChange={(nodeName) => setFieldValue('nodeName', nodeName)}
/>
</FormSection>
)}
<AccessControlForm
onChange={(accessControl) =>
setFieldValue('accessControl', accessControl)
}
errors={errors?.accessControl}
values={values.accessControl}
/>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Auto remove"
tooltip="When enabled, Portainer will automatically remove the container when it exits. This is useful when you want to use the container only once."
checked={values.autoRemove}
onChange={(autoRemove) => setFieldValue('autoRemove', autoRemove)}
/>
</div>
</div>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="Deployment in progress..."
isLoading={isLoading}
disabled={!isValid}
>
Deploy the container
</LoadingButton>
</div>
</div>
</Widget.Body>
</Widget>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<StringPortBinding | NumericPortBinding> {
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<StringPortBinding | NumericPortBinding>) {
const rangePorts = ports.filter(isStringPortBinding);
const nonRangePorts = ports.filter(isNumericPortBinding);
return {
rangePorts,
nonRangePorts: _.sortBy(nonRangePorts, [
'containerPort',
'hostPort',
'protocol',
]),
};
}
function combinePorts(ports: Array<NumericPortBinding>) {
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<RangePortBinding>)
.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';
}

View File

@ -0,0 +1,12 @@
import { getDefaultViewModel, toViewModel } from './toViewModel';
import { toRequest } from './toRequest';
import { validation } from './validation';
export { BaseForm, type Values as BaseFormValues } from './BaseForm';
export const baseFormUtils = {
toRequest,
toViewModel,
validation,
getDefaultViewModel,
};

View File

@ -0,0 +1,24 @@
import { CreateContainerRequest } from '../types';
import { Values } from './BaseForm';
import { parsePortBindingRequest } from './PortsMappingField.requestModel';
export function toRequest(
oldConfig: CreateContainerRequest,
values: Values
): CreateContainerRequest {
const bindings = parsePortBindingRequest(values.ports);
return {
...oldConfig,
ExposedPorts: Object.fromEntries(
Object.keys(bindings).map((key) => [key, {}])
),
HostConfig: {
...oldConfig.HostConfig,
PublishAllPorts: values.publishAllPorts,
PortBindings: bindings,
AutoRemove: values.autoRemove,
},
};
}

View File

@ -0,0 +1,58 @@
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
import { UserId } from '@/portainer/users/types';
import { getDefaultImageConfig } from '@/react/portainer/registries/utils/getImageConfig';
import { ContainerResponse } from '../../queries/container';
import { toViewModel as toPortsMappingViewModel } from './PortsMappingField.viewModel';
import { Values } from './BaseForm';
export function toViewModel(
config: ContainerResponse,
isAdmin: boolean,
currentUserId: UserId,
nodeName: string,
image: Values['image'],
enableWebhook: boolean
): Values {
// accessControl shouldn't be copied to new container
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
if (config.Portainer?.ResourceControl?.Public) {
accessControl.ownership = ResourceControlOwnership.PUBLIC;
}
return {
accessControl,
name: config.Name ? config.Name.replace('/', '') : '',
alwaysPull: true,
autoRemove: config.HostConfig?.AutoRemove || false,
ports: toPortsMappingViewModel(config.HostConfig?.PortBindings || {}),
publishAllPorts: config.HostConfig?.PublishAllPorts || false,
nodeName,
image,
enableWebhook,
};
}
export function getDefaultViewModel(
isAdmin: boolean,
currentUserId: UserId,
nodeName: string
): Values {
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
return {
nodeName,
enableWebhook: false,
image: getDefaultImageConfig(),
accessControl,
name: '',
alwaysPull: true,
autoRemove: false,
ports: [],
publishAllPorts: false,
};
}

View File

@ -0,0 +1,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<Values> {
return object({
name: string()
.default('')
.test('not-duplicate-portainer', () => !isDuplicatingPortainer),
alwaysPull: boolean().default(true),
accessControl: accessControlSchema(isAdmin),
autoRemove: boolean().default(false),
enableWebhook: boolean().default(false),
nodeName: string().default(''),
ports: portsSchema(),
publishAllPorts: boolean().default(false),
image: imageConfigValidation(isDockerhubRateLimited).test(
'duplicate-must-have-registry',
'Duplicate is only possible when registry is selected',
(value) => !isDuplicating || typeof value.registryId !== 'undefined'
),
});
}

View File

@ -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<Values>;
}) {
const [controlledValues, setControlledValues] = useState(values);
return (
<div className="mt-3">
<FormControl
@ -31,8 +28,8 @@ export function CommandsTab({
errors={errors?.cmd}
>
<OverridableInput
value={controlledValues.cmd}
onChange={(cmd) => 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}
>
<OverridableInput
value={controlledValues.entrypoint}
onChange={(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}
>
<Input
value={controlledValues.workingDir}
onChange={(e) => handleChange({ workingDir: e.target.value })}
value={values.workingDir}
onChange={(e) => setFieldValue('workingDir', e.target.value)}
placeholder="e.g. /myapp"
/>
</FormControl>
@ -73,33 +70,24 @@ export function CommandsTab({
errors={errors?.user}
>
<Input
value={controlledValues.user}
onChange={(e) => handleChange({ user: e.target.value })}
value={values.user}
onChange={(e) => setFieldValue('user', e.target.value)}
placeholder="e.g. nginx"
/>
</FormControl>
</div>
<ConsoleSettings
value={controlledValues.console}
onChange={(console) => handleChange({ console })}
value={values.console}
onChange={(console) => setFieldValue('console', console)}
/>
<LoggerConfig
apiVersion={apiVersion}
value={controlledValues.logConfig}
onChange={(logConfig) =>
handleChange({
logConfig,
})
}
value={values.logConfig}
onChange={(logConfig) => setFieldValue('logConfig', logConfig)}
errors={errors?.logConfig}
/>
</div>
);
function handleChange(newValues: Partial<Values>) {
onChange({ ...values, ...newValues });
setControlledValues((values) => ({ ...values, ...newValues }));
}
}

View File

@ -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 = {

View File

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

View File

@ -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 (
<>
<PageHeader
title="Create container"
breadcrumbs={[
{ label: 'Containers', link: 'docker.containers' },
'Add container',
]}
/>
<CreateForm />
</>
);
}
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 && (
<InformationPanel title-text="Caution">
<TextTip>
The new container may fail to start if the image is changed, and
settings from the previous container aren&apos;t compatible. Common
causes include entrypoint, cmd or
<a
href="https://docs.portainer.io/user/docker/containers/advanced"
target="_blank"
rel="noreferrer"
>
other settings
</a>{' '}
set by an image.
</TextTip>
</InformationPanel>
)}
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validateOnMount
validationSchema={validationSchema}
>
<InnerForm
onChangeName={syncName}
isDuplicate={isDuplicating}
isLoading={mutation.isLoading}
onRateLimit={(limited = false) => setIsDockerhubRateLimited(limited)}
/>
</Formik>
</>
);
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,
};
}

View File

@ -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<Values>;
}) {
const [values, setControlledValues] = useState(initialValues);
return (
<EnvironmentVariablesPanel
values={values}
explanation="These values will be applied to the container when deployed"
onChange={handleChange}
errors={errors}
/>
<div className="form-group">
<EnvironmentVariablesPanel
values={values}
explanation="These values will be applied to the container when deployed"
onChange={handleChange}
errors={errors}
/>
</div>
);
function handleChange(values: Values) {
setControlledValues(values);
onChange(values);
}
}

View File

@ -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<Values>();
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 (
<Form className="form-horizontal">
<div className="row">
<div className="col-sm-12">
<div>
<BaseForm
onChangeName={onChangeName}
onChangeImageName={() => {
setFieldValue('commands.cmd', null);
setFieldValue('commands.entrypoint', null);
}}
isLoading={isLoading}
onRateLimit={onRateLimit}
/>
<div className="mt-4">
<Widget>
<Widget.Title
title="Advanced container settings"
icon={Settings}
/>
<Widget.Body>
<NavTabs<string>
onSelect={setTab}
selectedId={tab}
type="pills"
justified
options={[
{
id: 'commands',
label: 'Commands & logging',
children: (
<CommandsTab
apiVersion={apiVersion}
values={values.commands}
setFieldValue={(field, value) =>
setFieldValue(`commands.${field}`, value)
}
/>
),
},
{
id: 'volumes',
label: 'Volumes',
children: (
<VolumesTab
values={values.volumes}
onChange={(value) =>
setFieldValue('volumes', value)
}
errors={errors.volumes}
allowBindMounts={
isEnvironmentAdmin ||
environment.SecuritySettings
.allowBindMountsForRegularUsers
}
/>
),
},
{
id: 'network',
label: 'Network',
children: (
<NetworkTab
values={values.network}
setFieldValue={(field, value) =>
setFieldValue(`network.${field}`, value)
}
/>
),
},
{
id: 'env',
label: 'Env',
children: (
<EnvVarsTab
values={values.env}
onChange={(value) => setFieldValue('env', value)}
errors={errors.env}
/>
),
},
{
id: 'labels',
label: 'Labels',
children: (
<LabelsTab
values={values.labels}
onChange={(value) => setFieldValue('labels', value)}
errors={errors.labels}
/>
),
},
{
id: 'restart',
label: 'Restart policy',
children: (
<RestartPolicyTab
values={values.restartPolicy}
onChange={(value) =>
setFieldValue('restartPolicy', value)
}
/>
),
},
{
id: 'runtime',
label: 'Runtime & resources',
children: (
<ResourcesTab
values={values.resources}
errors={errors.resources}
setFieldValue={(field, value) =>
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) => (
<EditResourcesForm
initialValues={values}
redeploy={(values) => {
setFieldValue(
'resources.resources',
values
);
return submitForm();
}}
isImageInvalid={!!errors?.image}
/>
)
: undefined
}
/>
),
},
{
id: 'capabilities',
label: 'Capabilities',
children: (
<CapabilitiesTab
values={values.capabilities}
onChange={(value) =>
setFieldValue('capabilities', value)
}
/>
),
},
]}
/>
</Widget.Body>
</Widget>
</div>
</div>
</div>
</div>
</Form>
);
}

View File

@ -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<Values>;
}) {
const [values, setControlledValues] = useState(initialValues);
return (
<InputList
label="Labels"
@ -29,7 +25,6 @@ export function LabelsTab({
);
function handleChange(values: Values) {
setControlledValues(values);
onChange(values);
}
}

View File

@ -1,5 +1,4 @@
import { FormikErrors } from 'formik';
import { useState } from 'react';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
@ -13,23 +12,21 @@ import { CONTAINER_MODE, Values } from './types';
import { ContainerSelector } from './ContainerSelector';
export function NetworkTab({
values: initialValues,
onChange,
values,
setFieldValue,
errors,
}: {
values: Values;
onChange(values: Values): void;
setFieldValue: (field: string, value: unknown) => void;
errors?: FormikErrors<Values>;
}) {
const [values, setControlledValues] = useState(initialValues);
return (
<div className="mt-3">
<FormControl label="Network" errors={errors?.networkMode}>
<NetworkSelector
value={values.networkMode}
additionalOptions={[{ label: 'Container', value: CONTAINER_MODE }]}
onChange={(networkMode) => handleChange({ networkMode })}
onChange={(networkMode) => setFieldValue('networkMode', networkMode)}
/>
</FormControl>
@ -37,7 +34,7 @@ export function NetworkTab({
<FormControl label="Container" errors={errors?.container}>
<ContainerSelector
value={values.container}
onChange={(container) => handleChange({ container })}
onChange={(container) => setFieldValue('container', container)}
/>
</FormControl>
)}
@ -45,7 +42,7 @@ export function NetworkTab({
<FormControl label="Hostname" errors={errors?.hostname}>
<Input
value={values.hostname}
onChange={(e) => handleChange({ hostname: e.target.value })}
onChange={(e) => setFieldValue('hostname', e.target.value)}
placeholder="e.g. web01"
/>
</FormControl>
@ -53,7 +50,7 @@ export function NetworkTab({
<FormControl label="Domain Name" errors={errors?.domain}>
<Input
value={values.domain}
onChange={(e) => handleChange({ domain: e.target.value })}
onChange={(e) => setFieldValue('domain', e.target.value)}
placeholder="e.g. example.com"
/>
</FormControl>
@ -61,7 +58,7 @@ export function NetworkTab({
<FormControl label="MAC Address" errors={errors?.macAddress}>
<Input
value={values.macAddress}
onChange={(e) => handleChange({ macAddress: e.target.value })}
onChange={(e) => setFieldValue('macAddress', e.target.value)}
placeholder="e.g. 12-34-56-78-9a-bc"
/>
</FormControl>
@ -69,7 +66,7 @@ export function NetworkTab({
<FormControl label="IPv4 Address" errors={errors?.ipv4Address}>
<Input
value={values.ipv4Address}
onChange={(e) => handleChange({ ipv4Address: e.target.value })}
onChange={(e) => setFieldValue('ipv4Address', e.target.value)}
placeholder="e.g. 172.20.0.7"
/>
</FormControl>
@ -77,7 +74,7 @@ export function NetworkTab({
<FormControl label="IPv6 Address" errors={errors?.ipv6Address}>
<Input
value={values.ipv6Address}
onChange={(e) => handleChange({ ipv6Address: e.target.value })}
onChange={(e) => setFieldValue('ipv6Address', e.target.value)}
placeholder="e.g. a:b:c:d::1234"
/>
</FormControl>
@ -85,7 +82,7 @@ export function NetworkTab({
<FormControl label="Primary DNS Server" errors={errors?.primaryDns}>
<Input
value={values.primaryDns}
onChange={(e) => handleChange({ primaryDns: e.target.value })}
onChange={(e) => setFieldValue('primaryDns', e.target.value)}
placeholder="e.g. 1.1.1.1, 2606:4700:4700::1111"
/>
</FormControl>
@ -93,7 +90,7 @@ export function NetworkTab({
<FormControl label="Secondary DNS Server" errors={errors?.secondaryDns}>
<Input
value={values.secondaryDns}
onChange={(e) => handleChange({ secondaryDns: e.target.value })}
onChange={(e) => setFieldValue('secondaryDns', e.target.value)}
placeholder="e.g. 1.0.0.1, 2606:4700:4700::1001"
/>
</FormControl>
@ -101,17 +98,15 @@ export function NetworkTab({
<InputList
label="Hosts file entries"
value={values.hostsFileEntries}
onChange={(hostsFileEntries) => handleChange({ hostsFileEntries })}
onChange={(hostsFileEntries) =>
setFieldValue('hostsFileEntries', hostsFileEntries)
}
errors={errors?.hostsFileEntries}
item={HostsFileEntryItem}
itemBuilder={() => ''}
/>
</div>
);
function handleChange(newValues: Partial<Values>) {
onChange({ ...values, ...newValues });
setControlledValues((values) => ({ ...values, ...newValues }));
}
}
function HostsFileEntryItem({

View File

@ -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 = {

View File

@ -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: '',

View File

@ -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<Values>;
allowPrivilegedMode: boolean;
isInitFieldVisible: boolean;
isDevicesFieldVisible: boolean;
isSysctlFieldVisible: boolean;
errors?: FormikErrors<Values>;
isDuplicate?: boolean;
redeploy: (values: Values) => Promise<void>;
isImageInvalid: boolean;
renderLimits?: (values: ResourcesValues) => ReactNode;
}) {
const [values, setControlledValues] = useState(initialValues);
const environmentId = useEnvironmentId();
const environmentQuery = useCurrentEnvironment();
@ -75,7 +69,7 @@ export function ResourcesTab({
<div className="mt-3">
<RuntimeSection
values={values.runtime}
onChange={(runtime) => handleChange({ runtime })}
onChange={(runtime) => setFieldValue('runtime', runtime)}
allowPrivilegedMode={allowPrivilegedMode}
isInitFieldVisible={isInitFieldVisible}
/>
@ -83,14 +77,14 @@ export function ResourcesTab({
{isDevicesFieldVisible && (
<DevicesField
values={values.devices}
onChange={(devices) => handleChange({ devices })}
onChange={(devices) => setFieldValue('devices', devices)}
/>
)}
{isSysctlFieldVisible && (
<SysctlsField
values={values.sysctls}
onChange={(sysctls) => 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 && (
<GpuFieldset
values={values.gpu}
onChange={(gpu) => handleChange({ gpu })}
onChange={(gpu) => setFieldValue('gpu', gpu)}
gpus={environment.Gpus}
enableGpuManagement={environment.EnableGPUManagement}
usedGpus={gpuUseList}
@ -123,26 +117,15 @@ export function ResourcesTab({
/>
)}
{isDuplicate ? (
<EditResourcesForm
initialValues={values.resources}
redeploy={(newValues) =>
redeploy({ ...values, resources: newValues })
}
isImageInvalid={isImageInvalid}
/>
{renderLimits ? (
renderLimits(values.resources)
) : (
<ResourceFieldset
values={values.resources}
onChange={(resources) => handleChange({ resources })}
onChange={(resources) => setFieldValue('resources', resources)}
errors={errors?.resources}
/>
)}
</div>
);
function handleChange(newValues: Partial<Values>) {
onChange({ ...values, ...newValues });
setControlledValues({ ...values, ...newValues });
}
}

View File

@ -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<Values>;
errors?: ArrayError<Values>;
}) {
const [controlledValues, setControlledValues] = useState(values);
return (
<InputContext.Provider value={allowBindMounts}>
<InputList<Volume>
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);
}
}

View File

@ -0,0 +1 @@
export { CreateView } from './CreateView';

View File

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

View File

@ -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<ExtraNetwork>;
}
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<string> }>
>(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<ExtraNetwork>
) {
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 });
}

View File

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

View File

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

View File

@ -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]

View File

@ -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<void>(
urlBuilder(endpointId, id, 'start'),
{},
{ transformResponse: genericHandler }
);
const headers: AxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
try {
await axios.post<void>(
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<void>(urlBuilder(endpointId, id, 'stop'), {});
const headers: AxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
await axios.post<void>(urlBuilder(endpointId, id, 'stop'), {}, { headers });
}
export async function recreateContainer(
endpointId: EnvironmentId,
id: ContainerId,
pullImage: boolean
pullImage: boolean,
{ nodeName }: { nodeName?: string } = {}
) {
await axios.post<void>(`/docker/${endpointId}/containers/${id}/recreate`, {
PullImage: pullImage,
});
const headers: AxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
await axios.post<void>(
`/docker/${endpointId}/containers/${id}/recreate`,
{
PullImage: pullImage,
},
{ headers }
);
}
export async function restartContainer(
endpointId: EnvironmentId,
id: ContainerId
id: ContainerId,
{ nodeName }: { nodeName?: string } = {}
) {
await axios.post<void>(urlBuilder(endpointId, id, 'restart'), {});
const headers: AxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
await axios.post<void>(
urlBuilder(endpointId, id, 'restart'),
{},
{ headers }
);
}
export async function killContainer(
endpointId: EnvironmentId,
id: ContainerId
id: ContainerId,
{ nodeName }: { nodeName?: string } = {}
) {
await axios.post<void>(urlBuilder(endpointId, id, 'kill'), {});
const headers: AxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
await axios.post<void>(urlBuilder(endpointId, id, 'kill'), {}, { headers });
}
export async function pauseContainer(
endpointId: EnvironmentId,
id: ContainerId
id: ContainerId,
{ nodeName }: { nodeName?: string } = {}
) {
await axios.post<void>(urlBuilder(endpointId, id, 'pause'), {});
const headers: AxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
await axios.post<void>(urlBuilder(endpointId, id, 'pause'), {}, { headers });
}
export async function resumeContainer(
endpointId: EnvironmentId,
id: ContainerId
id: ContainerId,
{ nodeName }: { nodeName?: string } = {}
) {
await axios.post<void>(urlBuilder(endpointId, id, 'unpause'), {});
const headers: AxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
await axios.post<void>(
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<void>(
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<null | { message: string }>(
urlBuilder(endpointId, container.Id),
urlBuilder(endpointId, containerId),
{
params: { v: removeVolumes ? 1 : 0, force: true },
transformResponse: genericHandler,
headers,
}
);

View File

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

View File

@ -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<T = DockerContainer[]>(
{
autoRefreshRate,
select,
enabled,
...params
}: UseContainers & {
autoRefreshRate?: number;
select?: (data: DockerContainer[]) => T;
enabled?: boolean;
} = {}
) {
return useQuery(
@ -41,6 +43,7 @@ export function useContainers<T = DockerContainer[]>(
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');
}

View File

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

View File

@ -3,6 +3,7 @@ import { ContainerStatus } from '../types';
export interface Filters {
label?: string[];
name?: string[];
network?: NetworkId[];
status?: ContainerStatus[];
}

View File

@ -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 =

View File

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

View File

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

View File

@ -11,4 +11,8 @@ export interface PortainerMetadata {
export type PortainerResponse<T> = T & {
Portainer?: PortainerMetadata;
/**
* will be true if the portainer is running in this resource
*/
IsPortainer?: boolean;
};

View File

@ -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 (
<NavTabs
selectedId={platform}
options={options}
onSelect={(id: Platform) => onPlatformChange(id)}
/>
<NavContainer>
<NavTabs
selectedId={platform}
options={options}
onSelect={(id: Platform) => onPlatformChange(id)}
/>
</NavContainer>
);
}

View File

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

View File

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

View File

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

View File

@ -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 (
<div className="form-group">
<div className="col-sm-12">
<NavTabs
options={[
{
id: ScheduleType.Update,
label: 'Update',
children: (
<UpdateScheduleDetailsFieldset
environments={environments}
hasTimeZone={hasTimeZone}
hasNoTimeZone={hasNoTimeZone}
hasGroupSelected={hasGroupSelected}
version={values.version}
/>
),
},
{
id: ScheduleType.Rollback,
label: 'Rollback',
children: (
<RollbackScheduleDetailsFieldset
hasTimeZone={hasTimeZone}
hasGroupSelected={hasGroupSelected}
/>
),
},
]}
selectedId={values.type}
onSelect={handleChangeType}
/>
<NavContainer>
<NavTabs
options={[
{
id: ScheduleType.Update,
label: 'Update',
children: (
<UpdateScheduleDetailsFieldset
environments={environments}
hasTimeZone={hasTimeZone}
hasNoTimeZone={hasNoTimeZone}
hasGroupSelected={hasGroupSelected}
version={values.version}
/>
),
},
{
id: ScheduleType.Rollback,
label: 'Rollback',
children: (
<RollbackScheduleDetailsFieldset
hasTimeZone={hasTimeZone}
hasGroupSelected={hasGroupSelected}
/>
),
},
]}
selectedId={values.type}
onSelect={handleChangeType}
/>
</NavContainer>
</div>
</div>
);

View File

@ -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 (
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
<NavContainer>
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
</NavContainer>
);
}

View File

@ -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 (
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
<NavContainer>
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
</NavContainer>
);
}

View File

@ -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() {
</span>
</div>
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
<NavContainer>
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
</NavContainer>
</>
);
}

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import { Webhook } from './types';
export function buildUrl(id?: Webhook['Id']) {
const url = '/webhooks';
if (id) {
return `${url}/${id}`;
}
return url;
}

View File

@ -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<Webhook>(buildUrl(), payload);
return data;
} catch (error) {
throw parseAxiosError(error, 'Unable to create webhook');
}
}

View File

@ -0,0 +1,6 @@
import { Filters } from './types';
export const queryKeys = {
base: () => ['webhooks'] as const,
list: (filters: Filters) => [...queryKeys.base(), { filters }],
};

View File

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

View File

@ -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<Array<Webhook>>(buildUrl(), {
params: { filters },
});
return data;
} catch (err) {
throw parseAxiosError(err, 'failed fetching webhooks');
}
}