mirror of https://github.com/portainer/portainer
refactor(containers): migrate create view to react [EE-2307] (#9175)
parent
bc0050a7b4
commit
d970f0e2bc
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
]);
|
|
@ -1,8 +0,0 @@
|
|||
.edit-resources {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-widget-color);
|
||||
}
|
||||
|
||||
.widget .edit-resources button {
|
||||
margin-left: 0;
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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']),
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
|
@ -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';
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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'
|
||||
),
|
||||
});
|
||||
}
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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: '',
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { CreateView } from './CreateView';
|
|
@ -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;
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -3,6 +3,7 @@ import { ContainerStatus } from '../types';
|
|||
|
||||
export interface Filters {
|
||||
label?: string[];
|
||||
name?: string[];
|
||||
network?: NetworkId[];
|
||||
status?: ContainerStatus[];
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ function buildTestRegistry(
|
|||
Authentication: false,
|
||||
Password: '',
|
||||
BaseURL: '',
|
||||
Checked: false,
|
||||
Ecr: { Region: '' },
|
||||
Github: { OrganisationName: '', UseOrganisation: false },
|
||||
Quay: { OrganisationName: '', UseOrganisation: false },
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import { Webhook } from './types';
|
||||
|
||||
export function buildUrl(id?: Webhook['Id']) {
|
||||
const url = '/webhooks';
|
||||
|
||||
if (id) {
|
||||
return `${url}/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { Filters } from './types';
|
||||
|
||||
export const queryKeys = {
|
||||
base: () => ['webhooks'] as const,
|
||||
list: (filters: Filters) => [...queryKeys.base(), { filters }],
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue