mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
refactor(containers): migrate resources tab to react [EE-5214] (#10355)
This commit is contained in:
parent
ec091efe3b
commit
ffac83864d
28 changed files with 1114 additions and 537 deletions
|
@ -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),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,248 @@
|
|||
import { useMemo } from 'react';
|
||||
import { components, MultiValue } from 'react-select';
|
||||
import { MultiValueRemoveProps } from 'react-select/dist/declarations/src/components/MultiValue';
|
||||
import {
|
||||
ActionMeta,
|
||||
OnChangeValue,
|
||||
} from 'react-select/dist/declarations/src/types';
|
||||
import { OptionProps } from 'react-select/dist/declarations/src/components/Option';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { Switch } from '@@/form-components/SwitchField/Switch';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { Values } from './types';
|
||||
|
||||
interface GpuOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GPU {
|
||||
value: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
values: Values;
|
||||
onChange(values: Values): void;
|
||||
gpus: GPU[];
|
||||
usedGpus: string[];
|
||||
usedAllGpus: boolean;
|
||||
enableGpuManagement?: boolean;
|
||||
}
|
||||
|
||||
const NvidiaCapabilitiesOptions = [
|
||||
// Taken from https://github.com/containerd/containerd/blob/master/contrib/nvidia/nvidia.go#L40
|
||||
{
|
||||
value: 'compute',
|
||||
label: 'compute',
|
||||
description: 'required for CUDA and OpenCL applications',
|
||||
},
|
||||
{
|
||||
value: 'compat32',
|
||||
label: 'compat32',
|
||||
description: 'required for running 32-bit applications',
|
||||
},
|
||||
{
|
||||
value: 'graphics',
|
||||
label: 'graphics',
|
||||
description: 'required for running OpenGL and Vulkan applications',
|
||||
},
|
||||
{
|
||||
value: 'utility',
|
||||
label: 'utility',
|
||||
description: 'required for using nvidia-smi and NVML',
|
||||
},
|
||||
{
|
||||
value: 'video',
|
||||
label: 'video',
|
||||
description: 'required for using the Video Codec SDK',
|
||||
},
|
||||
{
|
||||
value: 'display',
|
||||
label: 'display',
|
||||
description: 'required for leveraging X11 display',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function GpuFieldset({
|
||||
values,
|
||||
onChange,
|
||||
gpus = [],
|
||||
usedGpus = [],
|
||||
usedAllGpus,
|
||||
enableGpuManagement,
|
||||
}: Props) {
|
||||
const options = useMemo(() => {
|
||||
const options = (gpus || []).map((gpu) => ({
|
||||
value: gpu.value,
|
||||
label:
|
||||
usedGpus.includes(gpu.value) || usedAllGpus
|
||||
? `${gpu.name} (in use)`
|
||||
: gpu.name,
|
||||
}));
|
||||
|
||||
options.unshift({
|
||||
value: 'all',
|
||||
label: 'Use All GPUs',
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [gpus, usedGpus, usedAllGpus]);
|
||||
|
||||
const gpuCmd = useMemo(() => {
|
||||
const devices = values.selectedGPUs.join(',');
|
||||
const deviceStr = devices === 'all' ? 'all,' : `device=${devices},`;
|
||||
const caps = values.capabilities.join(',');
|
||||
return `--gpus '${deviceStr}"capabilities=${caps}"'`;
|
||||
}, [values.selectedGPUs, values.capabilities]);
|
||||
|
||||
const gpuValue = useMemo(
|
||||
() =>
|
||||
options.filter((option) => values.selectedGPUs.includes(option.value)),
|
||||
[values.selectedGPUs, options]
|
||||
);
|
||||
|
||||
const capValue = useMemo(
|
||||
() =>
|
||||
NvidiaCapabilitiesOptions.filter((option) =>
|
||||
values.capabilities.includes(option.value)
|
||||
),
|
||||
[values.capabilities]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!enableGpuManagement && (
|
||||
<TextTip color="blue">
|
||||
GPU in the UI is not currently enabled for this environment.
|
||||
</TextTip>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<div className="col-sm-3 col-lg-2 control-label text-left">
|
||||
Enable GPU
|
||||
<Switch
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
checked={values.enabled && !!enableGpuManagement}
|
||||
onChange={toggleEnableGpu}
|
||||
className="ml-2"
|
||||
disabled={enableGpuManagement === false}
|
||||
/>
|
||||
</div>
|
||||
{enableGpuManagement && values.enabled && (
|
||||
<div className="col-sm-9 col-lg-10 text-left">
|
||||
<Select<GpuOption, true>
|
||||
isMulti
|
||||
closeMenuOnSelect
|
||||
value={gpuValue}
|
||||
isClearable={false}
|
||||
backspaceRemovesValue={false}
|
||||
isDisabled={!values.enabled}
|
||||
onChange={onChangeSelectedGpus}
|
||||
options={options}
|
||||
components={{ MultiValueRemove }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{values.enabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-3 col-lg-2 control-label text-left">
|
||||
Capabilities
|
||||
<Tooltip message="‘compute’ and ‘utility’ capabilities are preselected by Portainer because they are used by default when you don’t explicitly specify capabilities with docker CLI ‘--gpus’ option." />
|
||||
</div>
|
||||
<div className="col-sm-9 col-lg-10 text-left">
|
||||
<Select<GpuOption, true>
|
||||
isMulti
|
||||
closeMenuOnSelect
|
||||
value={capValue}
|
||||
options={NvidiaCapabilitiesOptions}
|
||||
components={{ Option }}
|
||||
onChange={onChangeSelectedCaps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-3 col-lg-2 control-label text-left">
|
||||
Control
|
||||
<Tooltip message="This is the generated equivalent of the '--gpus' docker CLI parameter based on your settings." />
|
||||
</div>
|
||||
<div className="col-sm-9 col-lg-10">
|
||||
<code>{gpuCmd}</code>
|
||||
</div>
|
||||
</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'),
|
||||
})
|
||||
);
|
||||
}
|
15
app/react/docker/containers/CreateView/ResourcesTab/index.ts
Normal file
15
app/react/docker/containers/CreateView/ResourcesTab/index.ts
Normal file
|
@ -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 }),
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue