1
0
Fork 0
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:
Chaim Lev-Ari 2023-09-24 15:31:06 +03:00 committed by GitHub
parent ec091efe3b
commit ffac83864d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1114 additions and 537 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
export interface Values {
enabled: boolean;
useSpecific: boolean;
selectedGPUs: string[];
capabilities: string[];
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
};

View file

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

View file

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

View file

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

View file

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