mirror of https://github.com/portainer/portainer
refactor(containers): migrate resources tab to react [EE-5214] (#10355)
parent
ec091efe3b
commit
ffac83864d
|
@ -25,6 +25,11 @@ import {
|
||||||
NetworkTab,
|
NetworkTab,
|
||||||
type NetworkTabValues,
|
type NetworkTabValues,
|
||||||
} from '@/react/docker/containers/CreateView/NetworkTab';
|
} from '@/react/docker/containers/CreateView/NetworkTab';
|
||||||
|
import {
|
||||||
|
ResourcesTab,
|
||||||
|
resourcesTabUtils,
|
||||||
|
type ResourcesTabValues,
|
||||||
|
} from '@/react/docker/containers/CreateView/ResourcesTab';
|
||||||
|
|
||||||
const ngModule = angular
|
const ngModule = angular
|
||||||
.module('portainer.docker.react.components.containers', [])
|
.module('portainer.docker.react.components.containers', [])
|
||||||
|
@ -70,3 +75,19 @@ withFormValidation<ComponentProps<typeof NetworkTab>, NetworkTabValues>(
|
||||||
[],
|
[],
|
||||||
networkTabUtils.validation
|
networkTabUtils.validation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
withFormValidation<ComponentProps<typeof ResourcesTab>, ResourcesTabValues>(
|
||||||
|
ngModule,
|
||||||
|
withUIRouter(withReactQuery(ResourcesTab)),
|
||||||
|
'dockerCreateContainerResourcesTab',
|
||||||
|
[
|
||||||
|
'allowPrivilegedMode',
|
||||||
|
'isDevicesFieldVisible',
|
||||||
|
'isInitFieldVisible',
|
||||||
|
'isSysctlFieldVisible',
|
||||||
|
'isDuplicate',
|
||||||
|
'isImageInvalid',
|
||||||
|
'redeploy',
|
||||||
|
],
|
||||||
|
resourcesTabUtils.validation
|
||||||
|
);
|
||||||
|
|
|
@ -6,7 +6,6 @@ import { StackContainersDatatable } from '@/react/common/stacks/ItemView/StackCo
|
||||||
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
|
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
|
||||||
import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown';
|
import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown';
|
||||||
import { TemplateListSortAngular } from '@/react/docker/app-templates/TemplateListSort';
|
import { TemplateListSortAngular } from '@/react/docker/app-templates/TemplateListSort';
|
||||||
import { Gpu } from '@/react/docker/containers/CreateView/Gpu';
|
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
@ -57,17 +56,6 @@ const ngModule = angular
|
||||||
['environment', 'stackName']
|
['environment', 'stackName']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.component(
|
|
||||||
'gpu',
|
|
||||||
r2a(Gpu, [
|
|
||||||
'values',
|
|
||||||
'onChange',
|
|
||||||
'gpus',
|
|
||||||
'usedGpus',
|
|
||||||
'usedAllGpus',
|
|
||||||
'enableGpuManagement',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
.component(
|
.component(
|
||||||
'gpusList',
|
'gpusList',
|
||||||
r2a(withControlledInput(GpusList), ['value', 'onChange'])
|
r2a(withControlledInput(GpusList), ['value', 'onChange'])
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { ContainerDetailsViewModel } from '@/docker/models/container';
|
||||||
import './createcontainer.css';
|
import './createcontainer.css';
|
||||||
import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
|
import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
|
||||||
import { getContainers } from '@/react/docker/containers/queries/containers';
|
import { getContainers } from '@/react/docker/containers/queries/containers';
|
||||||
|
import { resourcesTabUtils } from '@/react/docker/containers/CreateView/ResourcesTab';
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('CreateContainerController', [
|
angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
'$q',
|
'$q',
|
||||||
|
@ -65,7 +66,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
endpoint
|
endpoint
|
||||||
) {
|
) {
|
||||||
$scope.create = create;
|
$scope.create = create;
|
||||||
$scope.update = update;
|
|
||||||
$scope.endpoint = endpoint;
|
$scope.endpoint = endpoint;
|
||||||
$scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK;
|
$scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK;
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
|
@ -84,18 +84,14 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
DnsPrimary: '',
|
DnsPrimary: '',
|
||||||
DnsSecondary: '',
|
DnsSecondary: '',
|
||||||
AccessControlData: new AccessControlFormData(),
|
AccessControlData: new AccessControlFormData(),
|
||||||
CpuLimit: 0,
|
|
||||||
MemoryLimit: 0,
|
|
||||||
MemoryReservation: 0,
|
|
||||||
ShmSize: 64,
|
|
||||||
NodeName: null,
|
NodeName: null,
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
Sysctls: [],
|
|
||||||
RegistryModel: new PorImageRegistryModel(),
|
RegistryModel: new PorImageRegistryModel(),
|
||||||
commands: commandsTabUtils.getDefaultViewModel(),
|
commands: commandsTabUtils.getDefaultViewModel(),
|
||||||
envVars: envVarsTabUtils.getDefaultViewModel(),
|
envVars: envVarsTabUtils.getDefaultViewModel(),
|
||||||
volumes: volumesTabUtils.getDefaultViewModel(),
|
volumes: volumesTabUtils.getDefaultViewModel(),
|
||||||
network: networkTabUtils.getDefaultViewModel(),
|
network: networkTabUtils.getDefaultViewModel(),
|
||||||
|
resources: resourcesTabUtils.getDefaultViewModel(),
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.state = {
|
$scope.state = {
|
||||||
|
@ -138,6 +134,12 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.onResourcesChange = function (resources) {
|
||||||
|
return $scope.$evalAsync(() => {
|
||||||
|
$scope.formValues.resources = resources;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function onAlwaysPullChange(checked) {
|
function onAlwaysPullChange(checked) {
|
||||||
return $scope.$evalAsync(() => {
|
return $scope.$evalAsync(() => {
|
||||||
$scope.formValues.alwaysPull = checked;
|
$scope.formValues.alwaysPull = checked;
|
||||||
|
@ -299,57 +301,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
config.Labels = labels;
|
config.Labels = labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareDevices(config) {
|
|
||||||
var path = [];
|
|
||||||
config.HostConfig.Devices.forEach(function (p) {
|
|
||||||
if (p.pathOnHost) {
|
|
||||||
if (p.pathInContainer === '') {
|
|
||||||
p.pathInContainer = p.pathOnHost;
|
|
||||||
}
|
|
||||||
path.push({ PathOnHost: p.pathOnHost, PathInContainer: p.pathInContainer, CgroupPermissions: 'rwm' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
config.HostConfig.Devices = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareSysctls(config) {
|
|
||||||
var sysctls = {};
|
|
||||||
$scope.formValues.Sysctls.forEach(function (sysctl) {
|
|
||||||
if (sysctl.name && sysctl.value) {
|
|
||||||
sysctls[sysctl.name] = sysctl.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
config.HostConfig.Sysctls = sysctls;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareResources(config) {
|
|
||||||
// Shared Memory Size - Round to 0.125
|
|
||||||
if ($scope.formValues.ShmSize >= 0) {
|
|
||||||
var shmSize = (Math.round($scope.formValues.ShmSize * 8) / 8).toFixed(3);
|
|
||||||
shmSize *= 1024 * 1024;
|
|
||||||
config.HostConfig.ShmSize = shmSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory Limit - Round to 0.125
|
|
||||||
if ($scope.formValues.MemoryLimit >= 0) {
|
|
||||||
var memoryLimit = (Math.round($scope.formValues.MemoryLimit * 8) / 8).toFixed(3);
|
|
||||||
memoryLimit *= 1024 * 1024;
|
|
||||||
config.HostConfig.Memory = memoryLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory Resevation - Round to 0.125
|
|
||||||
if ($scope.formValues.MemoryReservation >= 0) {
|
|
||||||
var memoryReservation = (Math.round($scope.formValues.MemoryReservation * 8) / 8).toFixed(3);
|
|
||||||
memoryReservation *= 1024 * 1024;
|
|
||||||
config.HostConfig.MemoryReservation = memoryReservation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CPU Limit
|
|
||||||
if ($scope.formValues.CpuLimit >= 0) {
|
|
||||||
config.HostConfig.NanoCpus = $scope.formValues.CpuLimit * 1000000000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareCapabilities(config) {
|
function prepareCapabilities(config) {
|
||||||
var allowed = $scope.formValues.capabilities.filter(function (item) {
|
var allowed = $scope.formValues.capabilities.filter(function (item) {
|
||||||
return item.allowed === true;
|
return item.allowed === true;
|
||||||
|
@ -365,51 +316,18 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
config.HostConfig.CapDrop = notAllowed.map(getCapName);
|
config.HostConfig.CapDrop = notAllowed.map(getCapName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareGPUOptions(config) {
|
|
||||||
const driver = 'nvidia';
|
|
||||||
const gpuOptions = $scope.formValues.GPU;
|
|
||||||
const existingDeviceRequest = _.find($scope.config.HostConfig.DeviceRequests, { Driver: driver });
|
|
||||||
if (existingDeviceRequest) {
|
|
||||||
_.pullAllBy(config.HostConfig.DeviceRequests, [existingDeviceRequest], 'Driver');
|
|
||||||
}
|
|
||||||
if (!gpuOptions.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const deviceRequest = {
|
|
||||||
Driver: driver,
|
|
||||||
Count: -1,
|
|
||||||
DeviceIDs: [], // must be empty if Count != 0 https://github.com/moby/moby/blob/master/daemon/nvidia_linux.go#L50
|
|
||||||
Capabilities: [], // array of ORed arrays of ANDed capabilites = [ [c1 AND c2] OR [c1 AND c3] ] : https://github.com/moby/moby/blob/master/api/types/container/host_config.go#L272
|
|
||||||
// Options: { property1: "string", property2: "string" }, // seems to never be evaluated/used in docker API ?
|
|
||||||
};
|
|
||||||
if (gpuOptions.useSpecific) {
|
|
||||||
deviceRequest.DeviceIDs = gpuOptions.selectedGPUs;
|
|
||||||
deviceRequest.Count = 0;
|
|
||||||
}
|
|
||||||
deviceRequest.Capabilities = [gpuOptions.capabilities];
|
|
||||||
|
|
||||||
if (config.HostConfig.DeviceRequests) {
|
|
||||||
config.HostConfig.DeviceRequests.push(deviceRequest);
|
|
||||||
} else {
|
|
||||||
config.HostConfig.DeviceRequests = [deviceRequest];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareConfiguration() {
|
function prepareConfiguration() {
|
||||||
var config = angular.copy($scope.config);
|
var config = angular.copy($scope.config);
|
||||||
config = commandsTabUtils.toRequest(config, $scope.formValues.commands);
|
config = commandsTabUtils.toRequest(config, $scope.formValues.commands);
|
||||||
config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars);
|
config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars);
|
||||||
config = volumesTabUtils.toRequest(config, $scope.formValues.volumes);
|
config = volumesTabUtils.toRequest(config, $scope.formValues.volumes);
|
||||||
config = networkTabUtils.toRequest(config, $scope.formValues.network, $scope.fromContainer.Id);
|
config = networkTabUtils.toRequest(config, $scope.formValues.network, $scope.fromContainer.Id);
|
||||||
|
config = resourcesTabUtils.toRequest(config, $scope.formValues.resources);
|
||||||
|
|
||||||
prepareImageConfig(config);
|
prepareImageConfig(config);
|
||||||
preparePortBindings(config);
|
preparePortBindings(config);
|
||||||
prepareLabels(config);
|
prepareLabels(config);
|
||||||
prepareDevices(config);
|
|
||||||
prepareResources(config);
|
|
||||||
prepareCapabilities(config);
|
prepareCapabilities(config);
|
||||||
prepareSysctls(config);
|
|
||||||
prepareGPUOptions(config);
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -426,45 +344,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromContainerDevices() {
|
|
||||||
var path = [];
|
|
||||||
for (var dev in $scope.config.HostConfig.Devices) {
|
|
||||||
if ({}.hasOwnProperty.call($scope.config.HostConfig.Devices, dev)) {
|
|
||||||
var device = $scope.config.HostConfig.Devices[dev];
|
|
||||||
path.push({ pathOnHost: device.PathOnHost, pathInContainer: device.PathInContainer });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$scope.config.HostConfig.Devices = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFromContainerDeviceRequests() {
|
|
||||||
const deviceRequest = _.find($scope.config.HostConfig.DeviceRequests, function (o) {
|
|
||||||
return o.Driver === 'nvidia' || o.Capabilities[0][0] === 'gpu';
|
|
||||||
});
|
|
||||||
if (deviceRequest) {
|
|
||||||
$scope.formValues.GPU.enabled = true;
|
|
||||||
$scope.formValues.GPU.useSpecific = deviceRequest.Count !== -1;
|
|
||||||
$scope.formValues.GPU.selectedGPUs = deviceRequest.DeviceIDs || [];
|
|
||||||
if ($scope.formValues.GPU.useSpecific) {
|
|
||||||
$scope.formValues.GPU.selectedGPUs = deviceRequest.DeviceIDs;
|
|
||||||
} else {
|
|
||||||
$scope.formValues.GPU.selectedGPUs = ['all'];
|
|
||||||
}
|
|
||||||
// we only support a single set of capabilities for now
|
|
||||||
// UI needs to be reworked in order to support OR combinations of AND capabilities
|
|
||||||
$scope.formValues.GPU.capabilities = deviceRequest.Capabilities[0];
|
|
||||||
$scope.formValues.GPU = { ...$scope.formValues.GPU };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFromContainerSysctls() {
|
|
||||||
for (var s in $scope.config.HostConfig.Sysctls) {
|
|
||||||
if ({}.hasOwnProperty.call($scope.config.HostConfig.Sysctls, s)) {
|
|
||||||
$scope.formValues.Sysctls.push({ name: s, value: $scope.config.HostConfig.Sysctls[s] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFromContainerImageConfig() {
|
function loadFromContainerImageConfig() {
|
||||||
RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id)
|
RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id)
|
||||||
.then((model) => {
|
.then((model) => {
|
||||||
|
@ -475,21 +354,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromContainerResources(d) {
|
|
||||||
if (d.HostConfig.NanoCpus) {
|
|
||||||
$scope.formValues.CpuLimit = d.HostConfig.NanoCpus / 1000000000;
|
|
||||||
}
|
|
||||||
if (d.HostConfig.Memory) {
|
|
||||||
$scope.formValues.MemoryLimit = d.HostConfig.Memory / 1024 / 1024;
|
|
||||||
}
|
|
||||||
if (d.HostConfig.MemoryReservation) {
|
|
||||||
$scope.formValues.MemoryReservation = d.HostConfig.MemoryReservation / 1024 / 1024;
|
|
||||||
}
|
|
||||||
if (d.HostConfig.ShmSize) {
|
|
||||||
$scope.formValues.ShmSize = d.HostConfig.ShmSize / 1024 / 1024;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFromContainerCapabilities(d) {
|
function loadFromContainerCapabilities(d) {
|
||||||
if (d.HostConfig.CapAdd) {
|
if (d.HostConfig.CapAdd) {
|
||||||
d.HostConfig.CapAdd.forEach(function (cap) {
|
d.HostConfig.CapAdd.forEach(function (cap) {
|
||||||
|
@ -543,15 +407,13 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
$scope.formValues.envVars = envVarsTabUtils.toViewModel(d);
|
$scope.formValues.envVars = envVarsTabUtils.toViewModel(d);
|
||||||
$scope.formValues.volumes = volumesTabUtils.toViewModel(d);
|
$scope.formValues.volumes = volumesTabUtils.toViewModel(d);
|
||||||
$scope.formValues.network = networkTabUtils.toViewModel(d, $scope.availableNetworks, $scope.runningContainers);
|
$scope.formValues.network = networkTabUtils.toViewModel(d, $scope.availableNetworks, $scope.runningContainers);
|
||||||
|
$scope.formValues.resources = resourcesTabUtils.toViewModel(d);
|
||||||
|
|
||||||
loadFromContainerPortBindings(d);
|
loadFromContainerPortBindings(d);
|
||||||
loadFromContainerLabels(d);
|
loadFromContainerLabels(d);
|
||||||
loadFromContainerDevices(d);
|
|
||||||
loadFromContainerDeviceRequests(d);
|
|
||||||
loadFromContainerImageConfig(d);
|
loadFromContainerImageConfig(d);
|
||||||
loadFromContainerResources(d);
|
|
||||||
loadFromContainerCapabilities(d);
|
loadFromContainerCapabilities(d);
|
||||||
loadFromContainerSysctls(d);
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
$scope.state.containerIsLoaded = true;
|
$scope.state.containerIsLoaded = true;
|
||||||
|
@ -568,7 +430,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
|
|
||||||
$scope.isAdmin = Authentication.isAdmin();
|
$scope.isAdmin = Authentication.isAdmin();
|
||||||
$scope.showDeviceMapping = await shouldShowDevices();
|
$scope.showDeviceMapping = await shouldShowDevices();
|
||||||
$scope.showSysctls = await shouldShowSysctls();
|
$scope.allowSysctl = await shouldShowSysctls();
|
||||||
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
|
$scope.areContainerCapabilitiesEnabled = await checkIfContainerCapabilitiesEnabled();
|
||||||
$scope.isAdminOrEndpointAdmin = Authentication.isAdmin();
|
$scope.isAdminOrEndpointAdmin = Authentication.isAdmin();
|
||||||
|
|
||||||
|
@ -647,27 +509,12 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateLimits(config) {
|
$scope.redeployUnlimitedResources = function (resources) {
|
||||||
try {
|
return $async(async () => {
|
||||||
if ($scope.state.settingUnlimitedResources) {
|
$scope.formValues.resources = resources;
|
||||||
create();
|
return create();
|
||||||
} else {
|
});
|
||||||
await ContainerService.updateLimits($transition$.params().from, config);
|
};
|
||||||
$scope.config = config;
|
|
||||||
Notifications.success('Success', 'Limits updated');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
Notifications.error('Failure', err, 'Update Limits fail');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function update() {
|
|
||||||
$scope.state.actionInProgress = true;
|
|
||||||
var config = angular.copy($scope.config);
|
|
||||||
prepareResources(config);
|
|
||||||
await updateLimits(config);
|
|
||||||
$scope.state.actionInProgress = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function create() {
|
function create() {
|
||||||
var oldContainer = null;
|
var oldContainer = null;
|
||||||
|
|
|
@ -283,233 +283,21 @@
|
||||||
<!-- !tab-restart-policy -->
|
<!-- !tab-restart-policy -->
|
||||||
<!-- tab-runtime-resources -->
|
<!-- tab-runtime-resources -->
|
||||||
<div class="tab-pane" id="runtime-resources">
|
<div class="tab-pane" id="runtime-resources">
|
||||||
<form class="form-horizontal" style="margin-top: 15px">
|
<docker-create-container-resources-tab
|
||||||
<div class="col-sm-12 form-section-title"> Runtime </div>
|
values="formValues.resources"
|
||||||
<!-- privileged-mode -->
|
on-change="(onResourcesChange)"
|
||||||
<div class="form-group" ng-if="isAdmin || allowPrivilegedMode">
|
allow-privileged-mode="allowPrivilegedMode"
|
||||||
<div class="col-sm-12">
|
is-devices-field-visible="showDeviceMapping"
|
||||||
<por-switch-field
|
is-sysctl-field-visible="allowSysctl"
|
||||||
label-class="'col-sm-2'"
|
is-init-field-visible="applicationState.endpoint.apiVersion >= 1.37"
|
||||||
checked="config.HostConfig.Privileged"
|
is-image-invalid="!formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer)"
|
||||||
label="'Privileged mode'"
|
redeploy="(redeployUnlimitedResources)"
|
||||||
on-change="(handlePrivilegedChange)"
|
is-duplicate="state.mode == 'duplicate'"
|
||||||
></por-switch-field>
|
validation-data="{
|
||||||
</div>
|
maxMemory: state.sliderMaxMemory,
|
||||||
</div>
|
maxCpu: state.sliderMaxCpu,
|
||||||
<!-- !privileged-mode -->
|
}"
|
||||||
<!-- init -->
|
></docker-create-container-resources-tab>
|
||||||
<div class="form-group" ng-if="applicationState.endpoint.apiVersion >= 1.37">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<por-switch-field label-class="'col-sm-2'" checked="config.HostConfig.Init" label="'Init'" on-change="(handleInitChange)"></por-switch-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !init -->
|
|
||||||
<!-- runtimes -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="container_runtime" class="col-sm-1 control-label text-left">Runtime</label>
|
|
||||||
<div class="col-sm-11">
|
|
||||||
<select class="form-control" ng-model="config.HostConfig.Runtime" id="container_runtime" ng-options="runtime for runtime in availableRuntimes">
|
|
||||||
<option selected value="">Default</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !runtimes -->
|
|
||||||
</form>
|
|
||||||
<form class="form-horizontal" style="margin-top: 15px" name="resourceForm">
|
|
||||||
<!-- devices -->
|
|
||||||
<div ng-if="showDeviceMapping" class="form-group">
|
|
||||||
<div class="col-sm-12" style="margin-top: 5px">
|
|
||||||
<label class="control-label text-left">Devices</label>
|
|
||||||
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addDevice()">
|
|
||||||
<pr-icon icon="'plus'" mode="'alt'"></pr-icon> add device
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- devices-input-list -->
|
|
||||||
<div class="col-sm-12 form-inline" style="margin-top: 10px">
|
|
||||||
<div ng-repeat="device in config.HostConfig.Devices" style="margin-top: 2px">
|
|
||||||
<div class="input-group col-sm-5 input-group-sm">
|
|
||||||
<span class="input-group-addon">host</span>
|
|
||||||
<input type="text" class="form-control" ng-model="device.pathOnHost" placeholder="e.g. /dev/tty0" />
|
|
||||||
</div>
|
|
||||||
<div class="input-group col-sm-5 input-group-sm">
|
|
||||||
<span class="input-group-addon">container</span>
|
|
||||||
<input type="text" class="form-control" ng-model="device.pathInContainer" placeholder="e.g. /dev/tty0" />
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-light" type="button" ng-click="removeDevice($index)">
|
|
||||||
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !devices-input-list -->
|
|
||||||
</div>
|
|
||||||
<!-- !devices-->
|
|
||||||
<!-- sysctls -->
|
|
||||||
<div ng-if="showSysctls" class="form-group">
|
|
||||||
<div class="col-sm-12" style="margin-top: 5px">
|
|
||||||
<label class="control-label text-left">Sysctls</label>
|
|
||||||
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addSysctl()"> <pr-icon icon="'plus'"></pr-icon> add sysctl </span>
|
|
||||||
</div>
|
|
||||||
<!-- sysctls-input-list -->
|
|
||||||
<div class="col-sm-12 form-inline" style="margin-top: 10px">
|
|
||||||
<div ng-repeat="sysctl in formValues.Sysctls" style="margin-top: 2px">
|
|
||||||
<div class="input-group col-sm-5 input-group-sm">
|
|
||||||
<span class="input-group-addon">name</span>
|
|
||||||
<input type="text" class="form-control" ng-model="sysctl.name" placeholder="e.g. FOO" />
|
|
||||||
</div>
|
|
||||||
<div class="input-group col-sm-5 input-group-sm">
|
|
||||||
<span class="input-group-addon">value</span>
|
|
||||||
<input type="text" class="form-control" ng-model="sysctl.value" placeholder="e.g. bar" />
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-light" type="button" ng-click="removeSysctl($index)">
|
|
||||||
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !sysctls-input-list -->
|
|
||||||
</div>
|
|
||||||
<!-- !sysctls -->
|
|
||||||
<!-- shm-size-input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="shm-size" class="col-sm-2 control-label text-left"> Shared memory size </label>
|
|
||||||
<div class="col-sm-2">
|
|
||||||
<input type="number" min="1" class="form-control" ng-model="formValues.ShmSize" id="shm-size" />
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-2">
|
|
||||||
<p class="small text-muted mt-2"> Size of /dev/shm (<b>MB</b>) </p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !shm-size-input -->
|
|
||||||
<!-- #region GPU -->
|
|
||||||
<div ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
|
|
||||||
<div class="col-sm-12 form-section-title"> GPU </div>
|
|
||||||
<gpu
|
|
||||||
ng-if="applicationState.endpoint.apiVersion >= 1.4"
|
|
||||||
values="formValues.GPU"
|
|
||||||
on-change="(onGpuChange)"
|
|
||||||
gpus="endpoint.Gpus"
|
|
||||||
used-gpus="gpuUseList"
|
|
||||||
used-all-gpus="gpuUseAll"
|
|
||||||
enable-gpu-management="endpoint.EnableGPUManagement"
|
|
||||||
>
|
|
||||||
</gpu>
|
|
||||||
</div>
|
|
||||||
<!-- #endregion GPU -->
|
|
||||||
<div ng-class="{ 'edit-resources': state.mode == 'duplicate' }">
|
|
||||||
<div class="col-sm-12 form-section-title"> Resources </div>
|
|
||||||
<!-- memory-reservation-input -->
|
|
||||||
<div class="form-group flex">
|
|
||||||
<label for="memory-reservation" class="col-sm-3 col-lg-2 control-label vertical-center text-left"> Memory reservation (MB) </label>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<slider
|
|
||||||
on-change="(handleResourceChange)"
|
|
||||||
model="formValues.MemoryReservation"
|
|
||||||
floor="0"
|
|
||||||
ceil="state.sliderMaxMemory"
|
|
||||||
step="256"
|
|
||||||
ng-if="state.sliderMaxMemory"
|
|
||||||
></slider>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-2 vertical-center">
|
|
||||||
<input
|
|
||||||
name="memory_reservation"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="{{ state.sliderMaxMemory }}"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="formValues.MemoryReservation"
|
|
||||||
id="memory-reservation"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-show="resourceForm.memory_reservation.$invalid">
|
|
||||||
<div class="col-sm-3 col-lg-2"></div>
|
|
||||||
<div class="col-sm-8 small text-muted">
|
|
||||||
<div ng-messages="resourceForm.memory-reservation.$error">
|
|
||||||
<p class="vertical-center text-warning">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Value must be between 0 and {{ state.sliderMaxMemory }}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !memory-reservation-input -->
|
|
||||||
<!-- memory-limit-input -->
|
|
||||||
<div class="form-group flex">
|
|
||||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label vertical-center text-left"> Memory limit (MB) </label>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<slider
|
|
||||||
on-change="(handleResourceChange)"
|
|
||||||
model="formValues.MemoryLimit"
|
|
||||||
floor="0"
|
|
||||||
ceil="state.sliderMaxMemory"
|
|
||||||
step="256"
|
|
||||||
ng-if="state.sliderMaxMemory"
|
|
||||||
></slider>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-2 vertical-center">
|
|
||||||
<input
|
|
||||||
name="memory_Limit"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="{{ state.sliderMaxMemory }}"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="formValues.MemoryLimit"
|
|
||||||
id="memory-limit"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-show="resourceForm.memory_Limit.$invalid">
|
|
||||||
<div class="col-sm-3 col-lg-2"></div>
|
|
||||||
<div class="col-sm-8 small text-muted">
|
|
||||||
<div ng-messages="resourceForm.memory-limit.$error">
|
|
||||||
<p class="vertical-center text-warning">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Value must be between 0 and {{ state.sliderMaxMemory }}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !memory-limit-input -->
|
|
||||||
<!-- cpu-limit-input -->
|
|
||||||
<div class="form-group flex">
|
|
||||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label vertical-center text-left"> Maximum CPU usage </label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<slider
|
|
||||||
on-change="(handleResourceChange)"
|
|
||||||
model="formValues.CpuLimit"
|
|
||||||
floor="0"
|
|
||||||
ceil="state.sliderMaxCpu"
|
|
||||||
step="0.1"
|
|
||||||
precision="2"
|
|
||||||
ng-if="state.sliderMaxCpu"
|
|
||||||
></slider>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !cpu-limit-input -->
|
|
||||||
<!-- update-limit-btn -->
|
|
||||||
<div class="form-group" ng-if="state.mode == 'duplicate'">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || (!formValues.RegistryModel.Registry && fromContainer)"
|
|
||||||
ng-click="update()"
|
|
||||||
button-spinner="state.actionInProgress"
|
|
||||||
>
|
|
||||||
<span ng-hide="state.actionInProgress">Update Limits</span>
|
|
||||||
<span ng-show="state.actionInProgress">Update in progress...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12" ng-if="state.settingUnlimitedResources">
|
|
||||||
<p class="text-muted mr-4">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'" class-name="'mt-10'"></pr-icon>
|
|
||||||
Updating any resource value to ‘unlimited' will redeploy this container.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !update-limit-btn -->
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- !tab-runtime-resources -->
|
<!-- !tab-runtime-resources -->
|
||||||
<!-- tab-container-capabilities -->
|
<!-- tab-container-capabilities -->
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
.items > * + * {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 0.9em;
|
|
||||||
padding-top: 7px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 100%;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-line {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-line.has-error {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.default-item {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { ComponentType } from 'react';
|
import { ComponentType } from 'react';
|
||||||
import clsx from 'clsx';
|
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
@ -10,7 +9,6 @@ import { TextTip } from '@@/Tip/TextTip';
|
||||||
import { Input } from '../Input';
|
import { Input } from '../Input';
|
||||||
import { FormError } from '../FormError';
|
import { FormError } from '../FormError';
|
||||||
|
|
||||||
import styles from './InputList.module.css';
|
|
||||||
import { arrayMove } from './utils';
|
import { arrayMove } from './utils';
|
||||||
|
|
||||||
type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
|
type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
|
||||||
|
@ -94,12 +92,9 @@ export function InputList<T = DefaultType>({
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const isAddButtonVisible = !(isAddButtonHidden || readOnly);
|
const isAddButtonVisible = !(isAddButtonHidden || readOnly);
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="form-group" aria-label={ariaLabel || label}>
|
||||||
className={clsx('form-group', styles.root)}
|
|
||||||
aria-label={ariaLabel || label}
|
|
||||||
>
|
|
||||||
{label && (
|
{label && (
|
||||||
<div className={clsx('col-sm-12', styles.header)}>
|
<div className="col-sm-12">
|
||||||
<span className="control-label space-right pt-2 text-left !font-bold">
|
<span className="control-label space-right pt-2 text-left !font-bold">
|
||||||
{label}
|
{label}
|
||||||
{tooltip && <Tooltip message={tooltip} />}
|
{tooltip && <Tooltip message={tooltip} />}
|
||||||
|
@ -121,14 +116,7 @@ export function InputList<T = DefaultType>({
|
||||||
typeof errors === 'object' ? errors[index] : undefined;
|
typeof errors === 'object' ? errors[index] : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={key} className="flex">
|
||||||
key={key}
|
|
||||||
className={clsx(
|
|
||||||
styles.itemLine,
|
|
||||||
{ [styles.hasError]: !!error },
|
|
||||||
'vertical-center'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{Item ? (
|
{Item ? (
|
||||||
<Item
|
<Item
|
||||||
item={item}
|
item={item}
|
||||||
|
@ -183,7 +171,7 @@ export function InputList<T = DefaultType>({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAddButtonVisible && (
|
{isAddButtonVisible && (
|
||||||
<div className="col-sm-12 mt-5">
|
<div className="col-sm-12 mt-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
@ -272,7 +260,7 @@ function DefaultItem({
|
||||||
<Input
|
<Input
|
||||||
value={item.value}
|
value={item.value}
|
||||||
onChange={(e) => onChange({ value: e.target.value })}
|
onChange={(e) => onChange({ value: e.target.value })}
|
||||||
className={styles.defaultItem}
|
className="!w-full"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { useCallback } from 'react';
|
import { ReactElement } from 'react';
|
||||||
import RcSlider from 'rc-slider';
|
import RcSlider from 'rc-slider';
|
||||||
|
import { HandleProps } from 'rc-slider/lib/Handles/Handle';
|
||||||
|
|
||||||
import { SliderTooltip } from '@@/Tip/SliderTooltip';
|
import { SliderTooltip } from '@@/Tip/SliderTooltip';
|
||||||
|
|
||||||
import styles from './Slider.module.css';
|
import styles from './Slider.module.css';
|
||||||
|
|
||||||
import 'rc-slider/assets/index.css';
|
import 'rc-slider/assets/index.css';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
@ -12,8 +14,8 @@ export interface Props {
|
||||||
step: number;
|
step: number;
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number | number[]) => void;
|
onChange: (value: number | number[]) => void;
|
||||||
|
dataCy?: string;
|
||||||
// true if you want to always show the tooltip
|
// true if you want to always show the tooltip
|
||||||
dataCy: string;
|
|
||||||
visibleTooltip?: boolean;
|
visibleTooltip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,17 +33,6 @@ export function Slider({
|
||||||
[max]: visible && value / max > 0.9 ? '' : max.toString(),
|
[max]: visible && value / max > 0.9 ? '' : max.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const sliderTooltip = useCallback(
|
|
||||||
(node, handleProps) => (
|
|
||||||
<SliderTooltip
|
|
||||||
value={translateMinValue(handleProps.value)}
|
|
||||||
child={node}
|
|
||||||
delay={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<RcSlider
|
<RcSlider
|
||||||
|
@ -64,3 +55,16 @@ function translateMinValue(value: number) {
|
||||||
}
|
}
|
||||||
return value.toString();
|
return value.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sliderTooltip(
|
||||||
|
node: ReactElement<HandleProps>,
|
||||||
|
handleProps: { value: number }
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<SliderTooltip
|
||||||
|
value={translateMinValue(handleProps.value)}
|
||||||
|
child={node}
|
||||||
|
delay={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Input } from '../Input';
|
||||||
|
|
||||||
|
import { Slider } from './Slider';
|
||||||
|
|
||||||
|
export function SliderWithInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
max,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
max: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{max && (
|
||||||
|
<div className="mr-2 flex-1">
|
||||||
|
<Slider
|
||||||
|
onChange={(value) =>
|
||||||
|
onChange(typeof value === 'number' ? value : value[0])
|
||||||
|
}
|
||||||
|
value={value}
|
||||||
|
min={0}
|
||||||
|
max={max}
|
||||||
|
step={256}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { array, object, SchemaOf, string } from 'yup';
|
||||||
|
import { DeviceMapping } from 'docker-types/generated/1.41';
|
||||||
|
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
import { InputList, ItemProps } from '@@/form-components/InputList';
|
||||||
|
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
|
||||||
|
|
||||||
|
interface Device {
|
||||||
|
pathOnHost: string;
|
||||||
|
pathInContainer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Values = Array<Device>;
|
||||||
|
|
||||||
|
export function DevicesField({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
values: Values;
|
||||||
|
onChange: (value: Values) => void;
|
||||||
|
errors?: FormikErrors<Device>[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputList
|
||||||
|
value={values}
|
||||||
|
onChange={onChange}
|
||||||
|
item={Item}
|
||||||
|
addLabel="Add device"
|
||||||
|
label="Devices"
|
||||||
|
errors={errors}
|
||||||
|
itemBuilder={() => ({ pathOnHost: '', pathInContainer: '' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item({ item, onChange, error }: ItemProps<Device>) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex w-full gap-4">
|
||||||
|
<InputLabeled
|
||||||
|
value={item.pathOnHost}
|
||||||
|
onChange={(e) => onChange({ ...item, pathOnHost: e.target.value })}
|
||||||
|
label="host"
|
||||||
|
placeholder="e.g. /dev/tty0"
|
||||||
|
className="w-1/2"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<InputLabeled
|
||||||
|
value={item.pathInContainer}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...item, pathInContainer: e.target.value })
|
||||||
|
}
|
||||||
|
label="container"
|
||||||
|
placeholder="e.g. /dev/tty0"
|
||||||
|
className="w-1/2"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <FormError>{Object.values(error)[0]}</FormError>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function devicesValidation(): SchemaOf<Values> {
|
||||||
|
return array(
|
||||||
|
object({
|
||||||
|
pathOnHost: string().required('Host path is required'),
|
||||||
|
pathInContainer: string().required('Container path is required'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDevicesViewModel(devices: Array<DeviceMapping>): Values {
|
||||||
|
return devices.filter(hasPath).map((device) => ({
|
||||||
|
pathOnHost: device.PathOnHost,
|
||||||
|
pathInContainer: device.PathInContainer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function hasPath(
|
||||||
|
device: DeviceMapping
|
||||||
|
): device is { PathOnHost: string; PathInContainer: string } {
|
||||||
|
return !!device.PathOnHost && !!device.PathInContainer;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
import { useMutation } from 'react-query';
|
||||||
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { mutationOptions, withError } from '@/react-tools/react-query';
|
||||||
|
import { useSystemLimits } from '@/react/docker/proxy/queries/useInfo';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { updateContainer } from '../../queries/useUpdateContainer';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ResourceFieldset,
|
||||||
|
resourcesValidation,
|
||||||
|
Values,
|
||||||
|
} from './ResourcesFieldset';
|
||||||
|
import { toConfigCpu, toConfigMemory } from './memory-utils';
|
||||||
|
|
||||||
|
export function EditResourcesForm({
|
||||||
|
redeploy,
|
||||||
|
initialValues,
|
||||||
|
isImageInvalid,
|
||||||
|
}: {
|
||||||
|
initialValues: Values;
|
||||||
|
redeploy: (values: Values) => Promise<void>;
|
||||||
|
isImageInvalid: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
params: { from: containerId },
|
||||||
|
} = useCurrentStateAndParams();
|
||||||
|
if (!containerId || typeof containerId !== 'string') {
|
||||||
|
throw new Error('missing parameter "from"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMutation = useMutation(
|
||||||
|
updateLimitsOrCreate,
|
||||||
|
mutationOptions(withError('Failed to update limits'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const systemLimits = useSystemLimits(environmentId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
validationSchema={() => resourcesValidation(systemLimits)}
|
||||||
|
>
|
||||||
|
{({ values, errors, setValues, dirty, submitForm }) => (
|
||||||
|
<div className="edit-resources p-5">
|
||||||
|
<ResourceFieldset
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12 flex items-center gap-4">
|
||||||
|
<LoadingButton
|
||||||
|
isLoading={updateMutation.isLoading}
|
||||||
|
disabled={isImageInvalid || !dirty}
|
||||||
|
loadingText="Update in progress..."
|
||||||
|
type="button"
|
||||||
|
onClick={submitForm}
|
||||||
|
>
|
||||||
|
Update Limits
|
||||||
|
</LoadingButton>
|
||||||
|
{settingUnlimitedResources(values) && (
|
||||||
|
<TextTip>
|
||||||
|
Updating any resource value to 'unlimited' will
|
||||||
|
redeploy this container.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit(values: Values) {
|
||||||
|
updateMutation.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess('Success', 'Limits updated');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingUnlimitedResources(values: Values) {
|
||||||
|
return (
|
||||||
|
(initialValues.limit > 0 && values.limit === 0) ||
|
||||||
|
(initialValues.reservation > 0 && values.reservation === 0) ||
|
||||||
|
(initialValues.cpu > 0 && values.cpu === 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLimitsOrCreate(values: Values) {
|
||||||
|
if (settingUnlimitedResources(values)) {
|
||||||
|
return redeploy(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateLimits(environmentId, containerId, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLimits(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
containerId: string,
|
||||||
|
values: Values
|
||||||
|
) {
|
||||||
|
return updateContainer(environmentId, containerId, {
|
||||||
|
// MemorySwap: must be set
|
||||||
|
// -1: non limits, 0: treated as unset(cause update error).
|
||||||
|
MemorySwap: -1,
|
||||||
|
MemoryReservation: toConfigMemory(values.reservation),
|
||||||
|
Memory: toConfigMemory(values.limit),
|
||||||
|
NanoCpus: toConfigCpu(values.cpu),
|
||||||
|
});
|
||||||
|
}
|
|
@ -12,12 +12,7 @@ import { Switch } from '@@/form-components/SwitchField/Switch';
|
||||||
import { Tooltip } from '@@/Tip/Tooltip';
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
interface Values {
|
import { Values } from './types';
|
||||||
enabled: boolean;
|
|
||||||
useSpecific: boolean;
|
|
||||||
selectedGPUs: string[];
|
|
||||||
capabilities: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GpuOption {
|
interface GpuOption {
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -25,7 +20,7 @@ interface GpuOption {
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GPU {
|
interface GPU {
|
||||||
value: string;
|
value: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
@ -71,35 +66,9 @@ const NvidiaCapabilitiesOptions = [
|
||||||
label: 'display',
|
label: 'display',
|
||||||
description: 'required for leveraging X11 display',
|
description: 'required for leveraging X11 display',
|
||||||
},
|
},
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
function Option(props: OptionProps<GpuOption, true>) {
|
export function GpuFieldset({
|
||||||
const {
|
|
||||||
data: { value, description },
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
|
||||||
<components.Option {...props}>
|
|
||||||
{`${value} - ${description}`}
|
|
||||||
</components.Option>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MultiValueRemove(props: MultiValueRemoveProps<GpuOption, true>) {
|
|
||||||
const {
|
|
||||||
selectProps: { value },
|
|
||||||
} = props;
|
|
||||||
if (value && (value as MultiValue<GpuOption>).length === 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
|
||||||
return <components.MultiValueRemove {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Gpu({
|
|
||||||
values,
|
values,
|
||||||
onChange,
|
onChange,
|
||||||
gpus = [],
|
gpus = [],
|
||||||
|
@ -124,43 +93,6 @@ export function Gpu({
|
||||||
return options;
|
return options;
|
||||||
}, [gpus, usedGpus, usedAllGpus]);
|
}, [gpus, usedGpus, usedAllGpus]);
|
||||||
|
|
||||||
function onChangeValues(key: string, newValue: boolean | string[]) {
|
|
||||||
const newValues = {
|
|
||||||
...values,
|
|
||||||
[key]: newValue,
|
|
||||||
};
|
|
||||||
onChange(newValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEnableGpu() {
|
|
||||||
onChangeValues('enabled', !values.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChangeSelectedGpus(
|
|
||||||
newValue: OnChangeValue<GpuOption, true>,
|
|
||||||
actionMeta: ActionMeta<GpuOption>
|
|
||||||
) {
|
|
||||||
let { useSpecific } = values;
|
|
||||||
let selectedGPUs = newValue.map((option) => option.value);
|
|
||||||
|
|
||||||
if (actionMeta.action === 'select-option') {
|
|
||||||
useSpecific = actionMeta.option?.value !== 'all';
|
|
||||||
selectedGPUs = selectedGPUs.filter((value) =>
|
|
||||||
useSpecific ? value !== 'all' : value === 'all'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newValues = { ...values, selectedGPUs, useSpecific };
|
|
||||||
onChange(newValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChangeSelectedCaps(newValue: OnChangeValue<GpuOption, true>) {
|
|
||||||
onChangeValues(
|
|
||||||
'capabilities',
|
|
||||||
newValue.map((option) => option.value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gpuCmd = useMemo(() => {
|
const gpuCmd = useMemo(() => {
|
||||||
const devices = values.selectedGPUs.join(',');
|
const devices = values.selectedGPUs.join(',');
|
||||||
const deviceStr = devices === 'all' ? 'all,' : `device=${devices},`;
|
const deviceStr = devices === 'all' ? 'all,' : `device=${devices},`;
|
||||||
|
@ -250,4 +182,67 @@ export function Gpu({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function onChangeValues(key: string, newValue: boolean | string[]) {
|
||||||
|
const newValues = {
|
||||||
|
...values,
|
||||||
|
[key]: newValue,
|
||||||
|
};
|
||||||
|
onChange(newValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEnableGpu() {
|
||||||
|
onChangeValues('enabled', !values.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeSelectedGpus(
|
||||||
|
newValue: OnChangeValue<GpuOption, true>,
|
||||||
|
actionMeta: ActionMeta<GpuOption>
|
||||||
|
) {
|
||||||
|
let { useSpecific } = values;
|
||||||
|
let selectedGPUs = newValue.map((option) => option.value);
|
||||||
|
|
||||||
|
if (actionMeta.action === 'select-option') {
|
||||||
|
useSpecific = actionMeta.option?.value !== 'all';
|
||||||
|
selectedGPUs = selectedGPUs.filter((value) =>
|
||||||
|
useSpecific ? value !== 'all' : value === 'all'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValues = { ...values, selectedGPUs, useSpecific };
|
||||||
|
onChange(newValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeSelectedCaps(newValue: OnChangeValue<GpuOption, true>) {
|
||||||
|
onChangeValues(
|
||||||
|
'capabilities',
|
||||||
|
newValue.map((option) => option.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Option(props: OptionProps<GpuOption, true>) {
|
||||||
|
const {
|
||||||
|
data: { value, description },
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
|
<components.Option {...props}>
|
||||||
|
{`${value} - ${description}`}
|
||||||
|
</components.Option>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiValueRemove(props: MultiValueRemoveProps<GpuOption, true>) {
|
||||||
|
const {
|
||||||
|
selectProps: { value },
|
||||||
|
} = props;
|
||||||
|
if (value && (value as MultiValue<GpuOption>).length === 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
return <components.MultiValueRemove {...props} />;
|
||||||
}
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { validation } from './validation';
|
||||||
|
import { toRequest } from './toRequest';
|
||||||
|
import { toViewModel, getDefaultViewModel } from './toViewModel';
|
||||||
|
|
||||||
|
export { GpuFieldset } from './GpuFieldset';
|
||||||
|
|
||||||
|
export type { Values as GpuFieldsetValues } from './types';
|
||||||
|
|
||||||
|
export const gpuFieldsetUtils = {
|
||||||
|
toRequest,
|
||||||
|
toViewModel,
|
||||||
|
validation,
|
||||||
|
getDefaultViewModel,
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { DeviceRequest } from 'docker-types/generated/1.41';
|
||||||
|
|
||||||
|
import { Values } from './types';
|
||||||
|
|
||||||
|
export function toRequest(
|
||||||
|
deviceRequests: Array<DeviceRequest>,
|
||||||
|
gpu: Values
|
||||||
|
): Array<DeviceRequest> {
|
||||||
|
const driver = 'nvidia';
|
||||||
|
|
||||||
|
const otherDeviceRequests = deviceRequests.filter(
|
||||||
|
(deviceRequest) => deviceRequest.Driver !== driver
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!gpu.enabled) {
|
||||||
|
return otherDeviceRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceRequest: DeviceRequest = {
|
||||||
|
Driver: driver,
|
||||||
|
Count: -1,
|
||||||
|
DeviceIDs: [], // must be empty if Count != 0 https://github.com/moby/moby/blob/master/daemon/nvidia_linux.go#L50
|
||||||
|
Capabilities: [], // array of ORed arrays of ANDed capabilites = [ [c1 AND c2] OR [c1 AND c3] ] : https://github.com/moby/moby/blob/master/api/types/container/host_config.go#L272
|
||||||
|
};
|
||||||
|
|
||||||
|
if (gpu.useSpecific) {
|
||||||
|
deviceRequest.DeviceIDs = gpu.selectedGPUs;
|
||||||
|
deviceRequest.Count = 0;
|
||||||
|
}
|
||||||
|
deviceRequest.Capabilities = [gpu.capabilities];
|
||||||
|
|
||||||
|
return [...otherDeviceRequests, deviceRequest];
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { DeviceRequest } from 'docker-types/generated/1.41';
|
||||||
|
|
||||||
|
import { Values } from './types';
|
||||||
|
|
||||||
|
export function getDefaultViewModel(): Values {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
useSpecific: false,
|
||||||
|
selectedGPUs: ['all'],
|
||||||
|
capabilities: ['compute', 'utility'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toViewModel(deviceRequests: Array<DeviceRequest> = []): Values {
|
||||||
|
const deviceRequest = deviceRequests.find(
|
||||||
|
(o) => o.Driver === 'nvidia' || o.Capabilities?.[0]?.[0] === 'gpu'
|
||||||
|
);
|
||||||
|
if (!deviceRequest) {
|
||||||
|
return getDefaultViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSpecific = deviceRequest.Count !== -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
useSpecific,
|
||||||
|
selectedGPUs: useSpecific ? deviceRequest.DeviceIDs || [] : ['all'],
|
||||||
|
capabilities: deviceRequest.Capabilities?.[0] || [],
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface Values {
|
||||||
|
enabled: boolean;
|
||||||
|
useSpecific: boolean;
|
||||||
|
selectedGPUs: string[];
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { SchemaOf, object, array, string, bool } from 'yup';
|
||||||
|
|
||||||
|
import { Values } from './types';
|
||||||
|
|
||||||
|
export function validation(): SchemaOf<Values> {
|
||||||
|
return object({
|
||||||
|
capabilities: array()
|
||||||
|
.of(string().default(''))
|
||||||
|
.default(['compute', 'utility']),
|
||||||
|
enabled: bool().default(false),
|
||||||
|
selectedGPUs: array().of(string()).default(['all']),
|
||||||
|
useSpecific: bool().default(false),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { number, object, SchemaOf } from 'yup';
|
||||||
|
|
||||||
|
import { useSystemLimits } from '@/react/docker/proxy/queries/useInfo';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { Slider } from '@@/form-components/Slider';
|
||||||
|
import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
||||||
|
|
||||||
|
import { CreateContainerRequest } from '../types';
|
||||||
|
|
||||||
|
import { toConfigCpu, toConfigMemory } from './memory-utils';
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
reservation: number;
|
||||||
|
limit: number;
|
||||||
|
cpu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceFieldset({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
values: Values;
|
||||||
|
onChange: (values: Values) => void;
|
||||||
|
errors: FormikErrors<Values> | undefined;
|
||||||
|
}) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const { maxCpu, maxMemory } = useSystemLimits(environmentId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title="Resources">
|
||||||
|
<FormControl label="Memory reservation (MB)" errors={errors?.reservation}>
|
||||||
|
<SliderWithInput
|
||||||
|
value={values.reservation}
|
||||||
|
onChange={(value) => onChange({ ...values, reservation: value })}
|
||||||
|
max={maxMemory}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl label="Memory limit (MB)" errors={errors?.limit}>
|
||||||
|
<SliderWithInput
|
||||||
|
value={values.limit}
|
||||||
|
onChange={(value) => onChange({ ...values, limit: value })}
|
||||||
|
max={maxMemory}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl label="Maximum CPU usage" errors={errors?.cpu}>
|
||||||
|
<Slider
|
||||||
|
value={values.cpu}
|
||||||
|
onChange={(value) =>
|
||||||
|
onChange({
|
||||||
|
...values,
|
||||||
|
cpu: typeof value === 'number' ? value : value[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={maxCpu}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRequest(
|
||||||
|
oldConfig: CreateContainerRequest['HostConfig'],
|
||||||
|
values: Values
|
||||||
|
): CreateContainerRequest['HostConfig'] {
|
||||||
|
return {
|
||||||
|
...oldConfig,
|
||||||
|
NanoCpus: toConfigCpu(values.cpu),
|
||||||
|
MemoryReservation: toConfigMemory(values.reservation),
|
||||||
|
Memory: toConfigMemory(values.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resourcesValidation({
|
||||||
|
maxMemory = Number.POSITIVE_INFINITY,
|
||||||
|
maxCpu = Number.POSITIVE_INFINITY,
|
||||||
|
}: {
|
||||||
|
maxMemory?: number;
|
||||||
|
maxCpu?: number;
|
||||||
|
} = {}): SchemaOf<Values> {
|
||||||
|
return object({
|
||||||
|
reservation: number()
|
||||||
|
.min(0)
|
||||||
|
.max(maxMemory, `Value must be between 0 and ${maxMemory}`)
|
||||||
|
.default(0),
|
||||||
|
limit: number()
|
||||||
|
.min(0)
|
||||||
|
.max(maxMemory, `Value must be between 0 and ${maxMemory}`)
|
||||||
|
.default(0),
|
||||||
|
cpu: number()
|
||||||
|
.min(0)
|
||||||
|
.max(maxCpu, `Value must be between 0 and ${maxCpu}`)
|
||||||
|
.default(0),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,148 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useIsStandAlone } from '@/react/docker/proxy/queries/useInfo';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
import { GpuFieldset, GpuFieldsetValues } from './GpuFieldset';
|
||||||
|
import { Values as RuntimeValues, RuntimeSection } from './RuntimeSection';
|
||||||
|
import { DevicesField, Values as Devices } from './DevicesField';
|
||||||
|
import { SysctlsField, Values as Sysctls } from './SysctlsField';
|
||||||
|
import {
|
||||||
|
ResourceFieldset,
|
||||||
|
Values as ResourcesValues,
|
||||||
|
} from './ResourcesFieldset';
|
||||||
|
import { EditResourcesForm } from './EditResourceForm';
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
runtime: RuntimeValues;
|
||||||
|
|
||||||
|
devices: Devices;
|
||||||
|
|
||||||
|
sysctls: Sysctls;
|
||||||
|
|
||||||
|
sharedMemorySize: number;
|
||||||
|
|
||||||
|
gpu: GpuFieldsetValues;
|
||||||
|
|
||||||
|
resources: ResourcesValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcesTab({
|
||||||
|
values: initialValues,
|
||||||
|
onChange,
|
||||||
|
allowPrivilegedMode,
|
||||||
|
isInitFieldVisible,
|
||||||
|
isDevicesFieldVisible,
|
||||||
|
isSysctlFieldVisible,
|
||||||
|
errors,
|
||||||
|
isDuplicate,
|
||||||
|
redeploy,
|
||||||
|
isImageInvalid,
|
||||||
|
}: {
|
||||||
|
values: Values;
|
||||||
|
onChange: (values: Values) => void;
|
||||||
|
allowPrivilegedMode: boolean;
|
||||||
|
isInitFieldVisible: boolean;
|
||||||
|
isDevicesFieldVisible: boolean;
|
||||||
|
isSysctlFieldVisible: boolean;
|
||||||
|
errors?: FormikErrors<Values>;
|
||||||
|
isDuplicate?: boolean;
|
||||||
|
redeploy: (values: Values) => Promise<void>;
|
||||||
|
isImageInvalid: boolean;
|
||||||
|
}) {
|
||||||
|
const [values, setControlledValues] = useState(initialValues);
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
const environmentQuery = useCurrentEnvironment();
|
||||||
|
|
||||||
|
const isStandalone = useIsStandAlone(environmentId);
|
||||||
|
|
||||||
|
if (!environmentQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = environmentQuery.data;
|
||||||
|
const gpuUseAll = _.get(environment, 'Snapshots[0].GpuUseAll', false);
|
||||||
|
const gpuUseList = _.get(environment, 'Snapshots[0].GpuUseList', []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<RuntimeSection
|
||||||
|
values={values.runtime}
|
||||||
|
onChange={(runtime) => handleChange({ runtime })}
|
||||||
|
allowPrivilegedMode={allowPrivilegedMode}
|
||||||
|
isInitFieldVisible={isInitFieldVisible}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isDevicesFieldVisible && (
|
||||||
|
<DevicesField
|
||||||
|
values={values.devices}
|
||||||
|
onChange={(devices) => handleChange({ devices })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSysctlFieldVisible && (
|
||||||
|
<SysctlsField
|
||||||
|
values={values.sysctls}
|
||||||
|
onChange={(sysctls) => handleChange({ sysctls })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControl label="Shared memory size" inputId="shm-size">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Input
|
||||||
|
id="shm-size"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={values.sharedMemorySize}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange({ sharedMemorySize: e.target.valueAsNumber })
|
||||||
|
}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<div className="small text-muted">
|
||||||
|
Size of /dev/shm (<b>MB</b>)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{isStandalone && (
|
||||||
|
<GpuFieldset
|
||||||
|
values={values.gpu}
|
||||||
|
onChange={(gpu) => handleChange({ gpu })}
|
||||||
|
gpus={environment.Gpus}
|
||||||
|
enableGpuManagement={environment.EnableGPUManagement}
|
||||||
|
usedGpus={gpuUseList}
|
||||||
|
usedAllGpus={gpuUseAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDuplicate ? (
|
||||||
|
<EditResourcesForm
|
||||||
|
initialValues={values.resources}
|
||||||
|
redeploy={(newValues) =>
|
||||||
|
redeploy({ ...values, resources: newValues })
|
||||||
|
}
|
||||||
|
isImageInvalid={isImageInvalid}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ResourceFieldset
|
||||||
|
values={values.resources}
|
||||||
|
onChange={(resources) => handleChange({ resources })}
|
||||||
|
errors={errors?.resources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(newValues: Partial<Values>) {
|
||||||
|
onChange({ ...values, ...newValues });
|
||||||
|
setControlledValues({ ...values, ...newValues });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { bool, object, SchemaOf, string } from 'yup';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
|
||||||
|
import { RuntimeSelector } from './RuntimeSelector';
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
privileged: boolean;
|
||||||
|
init: boolean;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimeSection({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
allowPrivilegedMode,
|
||||||
|
isInitFieldVisible,
|
||||||
|
}: {
|
||||||
|
values: Values;
|
||||||
|
onChange: (values: Values) => void;
|
||||||
|
allowPrivilegedMode: boolean;
|
||||||
|
isInitFieldVisible: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormSection title="Runtime">
|
||||||
|
{allowPrivilegedMode && (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
labelClass="col-sm-2"
|
||||||
|
label="Privileged mode"
|
||||||
|
checked={values.privileged}
|
||||||
|
onChange={(privileged) => handleChange({ privileged })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isInitFieldVisible && (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
labelClass="col-sm-2"
|
||||||
|
label="Init"
|
||||||
|
checked={values.init}
|
||||||
|
onChange={(init) => handleChange({ init })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControl label="Type" inputId="container_runtime" size="xsmall">
|
||||||
|
<RuntimeSelector
|
||||||
|
value={values.type}
|
||||||
|
onChange={(type) => handleChange({ type })}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(newValues: Partial<Values>) {
|
||||||
|
onChange({ ...values, ...newValues });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeValidation(): SchemaOf<Values> {
|
||||||
|
return object({
|
||||||
|
privileged: bool().default(false),
|
||||||
|
init: bool().default(false),
|
||||||
|
type: string().default(''),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useInfo } from '@/react/docker/proxy/queries/useInfo';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
|
|
||||||
|
export function RuntimeSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const infoQuery = useInfo(environmentId, (info) => [
|
||||||
|
{ label: 'Default', value: '' },
|
||||||
|
...Object.keys(info?.Runtimes || {}).map((runtime) => ({
|
||||||
|
label: runtime,
|
||||||
|
value: runtime,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortainerSelect
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
options={infoQuery.data || []}
|
||||||
|
isLoading={infoQuery.isLoading}
|
||||||
|
disabled={infoQuery.isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { array, object, SchemaOf, string } from 'yup';
|
||||||
|
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
import { InputList, ItemProps } from '@@/form-components/InputList';
|
||||||
|
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
|
||||||
|
|
||||||
|
interface Sysctls {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Values = Array<Sysctls>;
|
||||||
|
|
||||||
|
export function SysctlsField({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
values: Values;
|
||||||
|
onChange: (value: Values) => void;
|
||||||
|
errors?: FormikErrors<Sysctls>[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputList
|
||||||
|
value={values}
|
||||||
|
onChange={onChange}
|
||||||
|
item={Item}
|
||||||
|
addLabel="Add sysctl"
|
||||||
|
label="Sysctls"
|
||||||
|
errors={errors}
|
||||||
|
itemBuilder={() => ({ name: '', value: '' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item({ item, onChange, error }: ItemProps<Sysctls>) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex w-full gap-4">
|
||||||
|
<InputLabeled
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => onChange({ ...item, name: e.target.value })}
|
||||||
|
label="name"
|
||||||
|
placeholder="e.g. FOO"
|
||||||
|
className="w-1/2"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<InputLabeled
|
||||||
|
value={item.value}
|
||||||
|
onChange={(e) => onChange({ ...item, value: e.target.value })}
|
||||||
|
label="value"
|
||||||
|
placeholder="e.g. bar"
|
||||||
|
className="w-1/2"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <FormError>{Object.values(error)[0]}</FormError>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sysctlsValidation(): SchemaOf<Values> {
|
||||||
|
return array(
|
||||||
|
object({
|
||||||
|
name: string().required('Name is required'),
|
||||||
|
value: string().required('Value is required'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { validation } from './validation';
|
||||||
|
import { toRequest } from './toRequest';
|
||||||
|
import { toViewModel, getDefaultViewModel } from './toViewModel';
|
||||||
|
|
||||||
|
export {
|
||||||
|
ResourcesTab,
|
||||||
|
type Values as ResourcesTabValues,
|
||||||
|
} from './ResourcesTab';
|
||||||
|
|
||||||
|
export const resourcesTabUtils = {
|
||||||
|
toRequest,
|
||||||
|
toViewModel,
|
||||||
|
validation,
|
||||||
|
getDefaultViewModel,
|
||||||
|
};
|
|
@ -0,0 +1,36 @@
|
||||||
|
export function toConfigMemory(value: number): number {
|
||||||
|
if (value < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(Math.round(value * 8) / 8, 3) * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toViewModelMemory(value = 0): number {
|
||||||
|
if (value < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value / 1024 / 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number, decimals: number) {
|
||||||
|
const tenth = 10 ** decimals;
|
||||||
|
return Math.round((value + Number.EPSILON) * tenth) / tenth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toViewModelCpu(value = 0) {
|
||||||
|
if (value < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value / 1000000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toConfigCpu(value: number) {
|
||||||
|
if (value < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value * 1000000000;
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { CreateContainerRequest } from '../types';
|
||||||
|
|
||||||
|
import { gpuFieldsetUtils } from './GpuFieldset';
|
||||||
|
import { toConfigMemory } from './memory-utils';
|
||||||
|
import { Values } from './ResourcesTab';
|
||||||
|
import { toRequest as toResourcesRequest } from './ResourcesFieldset';
|
||||||
|
|
||||||
|
export function toRequest(
|
||||||
|
oldConfig: CreateContainerRequest,
|
||||||
|
values: Values
|
||||||
|
): CreateContainerRequest {
|
||||||
|
return {
|
||||||
|
...oldConfig,
|
||||||
|
HostConfig: {
|
||||||
|
...oldConfig.HostConfig,
|
||||||
|
Privileged: values.runtime.privileged,
|
||||||
|
Init: values.runtime.init,
|
||||||
|
Runtime: values.runtime.type,
|
||||||
|
Devices: values.devices.map((device) => ({
|
||||||
|
PathOnHost: device.pathOnHost,
|
||||||
|
PathInContainer: device.pathInContainer,
|
||||||
|
CgroupPermissions: 'rwm',
|
||||||
|
})),
|
||||||
|
Sysctls: Object.fromEntries(
|
||||||
|
values.sysctls.map((sysctl) => [sysctl.name, sysctl.value])
|
||||||
|
),
|
||||||
|
ShmSize: toConfigMemory(values.sharedMemorySize),
|
||||||
|
DeviceRequests: gpuFieldsetUtils.toRequest(
|
||||||
|
oldConfig.HostConfig.DeviceRequests || [],
|
||||||
|
values.gpu
|
||||||
|
),
|
||||||
|
...toResourcesRequest(oldConfig.HostConfig, values.resources),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { ContainerJSON } from '../../queries/container';
|
||||||
|
|
||||||
|
import { toDevicesViewModel } from './DevicesField';
|
||||||
|
import { gpuFieldsetUtils } from './GpuFieldset';
|
||||||
|
import { toViewModelCpu, toViewModelMemory } from './memory-utils';
|
||||||
|
import { Values } from './ResourcesTab';
|
||||||
|
|
||||||
|
export function toViewModel(config: ContainerJSON): Values {
|
||||||
|
return {
|
||||||
|
runtime: {
|
||||||
|
privileged: config.HostConfig?.Privileged || false,
|
||||||
|
init: config.HostConfig?.Init || false,
|
||||||
|
type: config.HostConfig?.Runtime || '',
|
||||||
|
},
|
||||||
|
devices: toDevicesViewModel(config.HostConfig?.Devices || []),
|
||||||
|
sysctls: Object.entries(config.HostConfig?.Sysctls || {}).map(
|
||||||
|
([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
gpu: gpuFieldsetUtils.toViewModel(config.HostConfig?.DeviceRequests || []),
|
||||||
|
sharedMemorySize: toViewModelMemory(config.HostConfig?.ShmSize),
|
||||||
|
resources: {
|
||||||
|
cpu: toViewModelCpu(config.HostConfig?.NanoCpus),
|
||||||
|
reservation: toViewModelMemory(config.HostConfig?.MemoryReservation),
|
||||||
|
limit: toViewModelMemory(config.HostConfig?.Memory),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultViewModel(): Values {
|
||||||
|
return {
|
||||||
|
runtime: {
|
||||||
|
privileged: false,
|
||||||
|
init: false,
|
||||||
|
type: '',
|
||||||
|
},
|
||||||
|
devices: [],
|
||||||
|
sysctls: [],
|
||||||
|
sharedMemorySize: 64,
|
||||||
|
gpu: gpuFieldsetUtils.getDefaultViewModel(),
|
||||||
|
resources: {
|
||||||
|
reservation: 0,
|
||||||
|
limit: 0,
|
||||||
|
cpu: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { number, object, SchemaOf } from 'yup';
|
||||||
|
|
||||||
|
import { devicesValidation } from './DevicesField';
|
||||||
|
import { gpuFieldsetUtils } from './GpuFieldset';
|
||||||
|
import { resourcesValidation } from './ResourcesFieldset';
|
||||||
|
import { Values } from './ResourcesTab';
|
||||||
|
import { runtimeValidation } from './RuntimeSection';
|
||||||
|
import { sysctlsValidation } from './SysctlsField';
|
||||||
|
|
||||||
|
export function validation({
|
||||||
|
maxMemory,
|
||||||
|
maxCpu,
|
||||||
|
}: {
|
||||||
|
maxMemory?: number;
|
||||||
|
maxCpu?: number;
|
||||||
|
} = {}): SchemaOf<Values> {
|
||||||
|
return object({
|
||||||
|
runtime: runtimeValidation(),
|
||||||
|
devices: devicesValidation(),
|
||||||
|
sysctls: sysctlsValidation(),
|
||||||
|
sharedMemorySize: number().min(0).default(0),
|
||||||
|
gpu: gpuFieldsetUtils.validation(),
|
||||||
|
resources: resourcesValidation({ maxMemory, maxCpu }),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Resources, RestartPolicy } from 'docker-types/generated/1.41';
|
||||||
|
import { AxiosRequestHeaders } from 'axios';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { urlBuilder } from '../containers.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UpdateConfig holds the mutable attributes of a Container.
|
||||||
|
* Those attributes can be updated at runtime.
|
||||||
|
*/
|
||||||
|
interface UpdateConfig extends Resources {
|
||||||
|
// Contains container's resources (cgroups, ulimits)
|
||||||
|
|
||||||
|
RestartPolicy?: RestartPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContainer(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
containerId: string,
|
||||||
|
config: UpdateConfig,
|
||||||
|
{ nodeName }: { nodeName?: string } = {}
|
||||||
|
) {
|
||||||
|
const headers: AxiosRequestHeaders = {};
|
||||||
|
|
||||||
|
if (nodeName) {
|
||||||
|
headers['X-PortainerAgent-Target'] = nodeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post<{ Warnings: string[] }>(
|
||||||
|
urlBuilder(environmentId, containerId, 'update'),
|
||||||
|
config,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'failed updating container');
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue