1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-22 06:49:40 +02:00

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

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

View file

@ -0,0 +1,199 @@
import { useFormikContext } from 'formik';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { Authorized } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control';
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { NodeSelector } from '@/react/docker/agent/NodeSelector';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { Input } from '@@/form-components/Input';
import { SwitchField } from '@@/form-components/SwitchField';
import { ImageConfigFieldset, ImageConfigValues } from '@@/ImageConfigFieldset';
import { LoadingButton } from '@@/buttons';
import { Widget } from '@@/Widget';
import {
PortsMappingField,
Values as PortMappingValue,
} from './PortsMappingField';
export interface Values {
name: string;
enableWebhook: boolean;
publishAllPorts: boolean;
image: ImageConfigValues;
alwaysPull: boolean;
ports: PortMappingValue;
accessControl: AccessControlFormData;
nodeName: string;
autoRemove: boolean;
}
function useIsAgentOnSwarm() {
const environmentId = useEnvironmentId();
const environmentQuery = useCurrentEnvironment();
const isSwarm = useIsSwarm(environmentId);
return (
!!environmentQuery.data &&
isAgentEnvironment(environmentQuery.data?.Type) &&
isSwarm
);
}
export function BaseForm({
isLoading,
onChangeName,
onChangeImageName,
onRateLimit,
}: {
isLoading: boolean;
onChangeName: (value: string) => void;
onChangeImageName: () => void;
onRateLimit: (limited?: boolean) => void;
}) {
const { setFieldValue, values, errors, isValid } = useFormikContext<Values>();
const environmentQuery = useCurrentEnvironment();
const isAgentOnSwarm = useIsAgentOnSwarm();
if (!environmentQuery.data) {
return null;
}
const environment = environmentQuery.data;
const canUseWebhook = environment.Type !== EnvironmentType.EdgeAgentOnDocker;
return (
<Widget>
<Widget.Body>
<FormControl label="Name" inputId="name-input" errors={errors?.name}>
<Input
id="name-input"
value={values.name}
onChange={(e) => {
const name = e.target.value;
onChangeName(name);
setFieldValue('name', name);
}}
placeholder="e.g. myContainer"
/>
</FormControl>
<FormSection title="Image Configuration">
<ImageConfigFieldset
values={values.image}
setFieldValue={(field, value) =>
setFieldValue(`image.${field}`, value)
}
autoComplete
onRateLimit={values.alwaysPull ? onRateLimit : undefined}
errors={errors?.image}
onChangeImage={onChangeImageName}
>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Always pull the image"
tooltip="When enabled, Portainer will automatically try to pull the specified image before creating the container."
checked={values.alwaysPull}
onChange={(alwaysPull) =>
setFieldValue('alwaysPull', alwaysPull)
}
/>
</div>
</div>
</ImageConfigFieldset>
</FormSection>
{canUseWebhook && (
<Authorized authorizations="PortainerWebhookCreate" adminOnlyCE>
<FormSection title="Webhook">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Create a container webhook"
tooltip="Create a webhook (or callback URI) to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container."
checked={values.enableWebhook}
onChange={(enableWebhook) =>
setFieldValue('enableWebhook', enableWebhook)
}
featureId={FeatureId.CONTAINER_WEBHOOK}
/>
</div>
</div>
</FormSection>
</Authorized>
)}
<FormSection title="Network ports configuration">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Publish all exposed ports to random host ports"
tooltip="When enabled, Portainer will let Docker automatically map a random port on the host to each one defined in the image Dockerfile."
checked={values.publishAllPorts}
onChange={(publishAllPorts) =>
setFieldValue('publishAllPorts', publishAllPorts)
}
/>
</div>
</div>
<PortsMappingField
value={values.ports}
onChange={(ports) => setFieldValue('ports', ports)}
errors={errors?.ports}
/>
</FormSection>
{isAgentOnSwarm && (
<FormSection title="Deployment">
<NodeSelector
value={values.nodeName}
onChange={(nodeName) => setFieldValue('nodeName', nodeName)}
/>
</FormSection>
)}
<AccessControlForm
onChange={(accessControl) =>
setFieldValue('accessControl', accessControl)
}
errors={errors?.accessControl}
values={values.accessControl}
/>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Auto remove"
tooltip="When enabled, Portainer will automatically remove the container when it exits. This is useful when you want to use the container only once."
checked={values.autoRemove}
onChange={(autoRemove) => setFieldValue('autoRemove', autoRemove)}
/>
</div>
</div>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="Deployment in progress..."
isLoading={isLoading}
disabled={!isValid}
>
Deploy the container
</LoadingButton>
</div>
</div>
</Widget.Body>
</Widget>
);
}

View file

@ -0,0 +1,117 @@
import { PortMap } from 'docker-types/generated/1.41';
import _ from 'lodash';
import { PortMapping, Protocol, Values } from './PortsMappingField';
import { Range } from './PortsMappingField.viewModel';
type PortKey = `${string}/${Protocol}`;
export function parsePortBindingRequest(portBindings: Values): PortMap {
const bindings: Record<
PortKey,
Array<{ HostIp: string; HostPort: string }>
> = {};
_.forEach(portBindings, (portBinding) => {
if (!portBinding.containerPort) {
return;
}
const portInfo = extractPortInfo(portBinding);
if (!portInfo) {
return;
}
let { hostPort } = portBinding;
const { endHostPort, endPort, hostIp, startHostPort, startPort } = portInfo;
_.range(startPort, endPort + 1).forEach((containerPort) => {
const bindKey: PortKey = `${containerPort}/${portBinding.protocol}`;
if (!bindings[bindKey]) {
bindings[bindKey] = [];
}
if (startHostPort > 0) {
hostPort = (startHostPort + containerPort - startPort).toString();
}
if (startPort === endPort && startHostPort !== endHostPort) {
hostPort += `-${endHostPort.toString()}`;
}
bindings[bindKey].push({ HostIp: hostIp, HostPort: hostPort });
});
});
return bindings;
}
function isValidPortRange(portRange: Range) {
return portRange.start > 0 && portRange.end >= portRange.start;
}
function parsePortRange(portRange: string | number): Range {
// Make sure we have a string
const portRangeString = portRange.toString();
// Split the range and convert to integers
const stringPorts = _.split(portRangeString, '-', 2);
const intPorts = _.map(stringPorts, parsePort);
return {
start: intPorts[0],
end: intPorts[1] || intPorts[0],
};
}
const portPattern =
/^([1-9]|[1-5]?[0-9]{2,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/m;
function parsePort(port: string) {
if (portPattern.test(port)) {
return parseInt(port, 10);
}
return 0;
}
function extractPortInfo(portBinding: PortMapping) {
const containerPortRange = parsePortRange(portBinding.containerPort);
if (!isValidPortRange(containerPortRange)) {
throw new Error(`Invalid port specification: ${portBinding.containerPort}`);
}
const startPort = containerPortRange.start;
const endPort = containerPortRange.end;
let hostIp = '';
let { hostPort } = portBinding;
if (!hostPort) {
return null;
}
if (hostPort.includes('[')) {
const hostAndPort = _.split(hostPort, ']:');
if (hostAndPort.length < 2) {
throw new Error(
`Invalid port specification: ${portBinding.containerPort}`
);
}
hostIp = hostAndPort[0].replace('[', '');
[, hostPort] = hostAndPort;
} else if (hostPort.includes(':')) {
[hostIp, hostPort] = _.split(hostPort, ':');
}
const hostPortRange = parsePortRange(hostPort);
if (!isValidPortRange(hostPortRange)) {
throw new Error(`Invalid port specification: ${hostPort}`);
}
const { start: startHostPort, end: endHostPort } = hostPortRange;
if (
endPort !== startPort &&
endPort - startPort !== endHostPort - startHostPort
) {
throw new Error(`Invalid port specification: ${hostPort}`);
}
return { startPort, endPort, hostIp, startHostPort, endHostPort };
}

View file

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

View file

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

View file

@ -0,0 +1,139 @@
import { toViewModel } from './PortsMappingField.viewModel';
test('basic', () => {
expect(
toViewModel({
'22/tcp': [
{
HostIp: '',
HostPort: '222',
},
],
'3000/tcp': [
{
HostIp: '',
HostPort: '3000',
},
],
})
).toStrictEqual([
{
hostPort: '222',
containerPort: '22',
protocol: 'tcp',
},
{
hostPort: '3000',
containerPort: '3000',
protocol: 'tcp',
},
]);
});
test('already combined', () => {
expect(
toViewModel({
'80/tcp': [
{
HostIp: '',
HostPort: '7000-7999',
},
],
})
).toStrictEqual([
{
hostPort: '7000-7999',
containerPort: '80',
protocol: 'tcp',
},
]);
});
test('simple combine ports', () => {
expect(
toViewModel({
'81/tcp': [
{
HostIp: '',
HostPort: '81',
},
],
'82/tcp': [
{
HostIp: '',
HostPort: '82',
},
],
})
).toStrictEqual([
{
hostPort: '81-82',
containerPort: '81-82',
protocol: 'tcp',
},
]);
});
test('combine and sort', () => {
expect(
toViewModel({
'3244/tcp': [
{
HostIp: '',
HostPort: '105',
},
],
'3245/tcp': [
{
HostIp: '',
HostPort: '106',
},
],
'81/tcp': [
{
HostIp: '',
HostPort: '81',
},
],
'82/tcp': [
{
HostIp: '',
HostPort: '82',
},
],
'83/tcp': [
{
HostIp: '0.0.0.0',
HostPort: '0',
},
],
'84/tcp': [
{
HostIp: '0.0.0.0',
HostPort: '0',
},
],
})
).toStrictEqual([
{
hostPort: '81-82',
containerPort: '81-82',
protocol: 'tcp',
},
{
hostPort: '',
containerPort: '83',
protocol: 'tcp',
},
{
hostPort: '',
containerPort: '84',
protocol: 'tcp',
},
{
hostPort: '105-106',
containerPort: '3244-3245',
protocol: 'tcp',
},
]);
});

View file

@ -0,0 +1,156 @@
import { PortMap } from 'docker-types/generated/1.41';
import _ from 'lodash';
import { Protocol, Values } from './PortsMappingField';
export type Range = {
start: number;
end: number;
};
type StringPortBinding = {
hostPort: string;
protocol: Protocol;
containerPort: number;
};
type NumericPortBinding = {
hostPort: number;
protocol: Protocol;
containerPort: number;
};
type RangePortBinding = {
hostPort: Range;
protocol: Protocol;
containerPort: Range;
};
export function toViewModel(portBindings: PortMap): Values {
const parsedPorts = parsePorts(portBindings);
const sortedPorts = sortPorts(parsedPorts);
return [
...sortedPorts.rangePorts.map((port) => ({
...port,
containerPort: String(port.containerPort),
})),
...combinePorts(sortedPorts.nonRangePorts),
];
function isProtocol(value: string): value is Protocol {
return value === 'tcp' || value === 'udp';
}
function parsePorts(
portBindings: PortMap
): Array<StringPortBinding | NumericPortBinding> {
return Object.entries(portBindings).flatMap(([key, bindings]) => {
const [containerPort, protocol] = key.split('/');
if (!isProtocol(protocol)) {
throw new Error(`Invalid protocol: ${protocol}`);
}
if (!bindings) {
return [];
}
const containerPortNumber = parseInt(containerPort, 10);
if (Number.isNaN(containerPortNumber)) {
throw new Error(`Invalid container port: ${containerPort}`);
}
return bindings.map((binding) => {
if (binding.HostPort?.includes('-')) {
return {
hostPort: binding.HostPort,
protocol,
containerPort: containerPortNumber,
};
}
return {
hostPort: parseInt(binding.HostPort || '0', 10),
protocol,
containerPort: containerPortNumber,
};
});
});
}
function sortPorts(ports: Array<StringPortBinding | NumericPortBinding>) {
const rangePorts = ports.filter(isStringPortBinding);
const nonRangePorts = ports.filter(isNumericPortBinding);
return {
rangePorts,
nonRangePorts: _.sortBy(nonRangePorts, [
'containerPort',
'hostPort',
'protocol',
]),
};
}
function combinePorts(ports: Array<NumericPortBinding>) {
return ports
.reduce((acc, port) => {
const lastPort = acc[acc.length - 1];
if (
lastPort &&
lastPort.containerPort.end === port.containerPort - 1 &&
lastPort.hostPort.end === port.hostPort - 1 &&
lastPort.protocol === port.protocol
) {
lastPort.containerPort.end = port.containerPort;
lastPort.hostPort.end = port.hostPort;
return acc;
}
return [
...acc,
{
hostPort: {
start: port.hostPort,
end: port.hostPort,
},
containerPort: {
start: port.containerPort,
end: port.containerPort,
},
protocol: port.protocol,
},
];
}, [] as Array<RangePortBinding>)
.map(({ protocol, containerPort, hostPort }) => ({
hostPort: getRange(hostPort.start, hostPort.end),
containerPort: getRange(containerPort.start, containerPort.end),
protocol,
}));
function getRange(start: number, end: number): string {
if (start === end) {
if (start === 0) {
return '';
}
return start.toString();
}
return `${start}-${end}`;
}
}
}
function isNumericPortBinding(
port: StringPortBinding | NumericPortBinding
): port is NumericPortBinding {
return port.hostPort !== 'string';
}
function isStringPortBinding(
port: StringPortBinding | NumericPortBinding
): port is StringPortBinding {
return port.hostPort === 'string';
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,45 @@
import { boolean, object, SchemaOf, string } from 'yup';
import { validationSchema as accessControlSchema } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation';
import { imageConfigValidation } from '@@/ImageConfigFieldset';
import { Values } from './BaseForm';
import { validationSchema as portsSchema } from './PortsMappingField.validation';
export function validation(
{
isAdmin,
isDuplicating,
isDuplicatingPortainer,
isDockerhubRateLimited,
}: {
isAdmin: boolean;
isDuplicating: boolean | undefined;
isDuplicatingPortainer: boolean | undefined;
isDockerhubRateLimited: boolean;
} = {
isAdmin: false,
isDuplicating: false,
isDuplicatingPortainer: false,
isDockerhubRateLimited: false,
}
): SchemaOf<Values> {
return object({
name: string()
.default('')
.test('not-duplicate-portainer', () => !isDuplicatingPortainer),
alwaysPull: boolean().default(true),
accessControl: accessControlSchema(isAdmin),
autoRemove: boolean().default(false),
enableWebhook: boolean().default(false),
nodeName: string().default(''),
ports: portsSchema(),
publishAllPorts: boolean().default(false),
image: imageConfigValidation(isDockerhubRateLimited).test(
'duplicate-must-have-registry',
'Duplicate is only possible when registry is selected',
(value) => !isDuplicating || typeof value.registryId !== 'undefined'
),
});
}

View file

@ -1,5 +1,4 @@
import { FormikErrors } from 'formik';
import { useState } from 'react';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
@ -12,16 +11,14 @@ import { Values } from './types';
export function CommandsTab({
apiVersion,
values,
onChange,
setFieldValue,
errors,
}: {
apiVersion: number;
values: Values;
onChange: (values: Values) => void;
setFieldValue: (field: string, value: unknown) => void;
errors?: FormikErrors<Values>;
}) {
const [controlledValues, setControlledValues] = useState(values);
return (
<div className="mt-3">
<FormControl
@ -31,8 +28,8 @@ export function CommandsTab({
errors={errors?.cmd}
>
<OverridableInput
value={controlledValues.cmd}
onChange={(cmd) => handleChange({ cmd })}
value={values.cmd}
onChange={(cmd) => setFieldValue('cmd', cmd)}
id="command-input"
placeholder="e.g. '-logtostderr' '--housekeeping_interval=5s' or /usr/bin/nginx -t -c /mynginx.conf"
/>
@ -46,8 +43,8 @@ export function CommandsTab({
errors={errors?.entrypoint}
>
<OverridableInput
value={controlledValues.entrypoint}
onChange={(entrypoint) => handleChange({ entrypoint })}
value={values.entrypoint}
onChange={(entrypoint) => setFieldValue('entrypoint', entrypoint)}
id="entrypoint-input"
placeholder="e.g. /bin/sh -c"
/>
@ -61,8 +58,8 @@ export function CommandsTab({
errors={errors?.workingDir}
>
<Input
value={controlledValues.workingDir}
onChange={(e) => handleChange({ workingDir: e.target.value })}
value={values.workingDir}
onChange={(e) => setFieldValue('workingDir', e.target.value)}
placeholder="e.g. /myapp"
/>
</FormControl>
@ -73,33 +70,24 @@ export function CommandsTab({
errors={errors?.user}
>
<Input
value={controlledValues.user}
onChange={(e) => handleChange({ user: e.target.value })}
value={values.user}
onChange={(e) => setFieldValue('user', e.target.value)}
placeholder="e.g. nginx"
/>
</FormControl>
</div>
<ConsoleSettings
value={controlledValues.console}
onChange={(console) => handleChange({ console })}
value={values.console}
onChange={(console) => setFieldValue('console', console)}
/>
<LoggerConfig
apiVersion={apiVersion}
value={controlledValues.logConfig}
onChange={(logConfig) =>
handleChange({
logConfig,
})
}
value={values.logConfig}
onChange={(logConfig) => setFieldValue('logConfig', logConfig)}
errors={errors?.logConfig}
/>
</div>
);
function handleChange(newValues: Partial<Values>) {
onChange({ ...values, ...newValues });
setControlledValues((values) => ({ ...values, ...newValues }));
}
}

View file

@ -3,7 +3,6 @@ import { toRequest } from './toRequest';
import { toViewModel, getDefaultViewModel } from './toViewModel';
export { CommandsTab } from './CommandsTab';
export { validation as commandsTabValidation } from './validation';
export { type Values as CommandsTabValues } from './types';
export const commandsTabUtils = {

View file

@ -41,18 +41,6 @@ export function toRequest(
return config;
function getLogConfig(
value: LogConfig
): CreateContainerRequest['HostConfig']['LogConfig'] {
return {
Type: value.type,
Config: Object.fromEntries(
value.options.map(({ option, value }) => [option, value])
),
// docker types - requires union while it should allow also custom string for custom plugins
} as CreateContainerRequest['HostConfig']['LogConfig'];
}
function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
switch (value) {
case 'both':
@ -66,4 +54,16 @@ export function toRequest(
return { OpenStdin: false, Tty: false };
}
}
function getLogConfig(
value: LogConfig
): CreateContainerRequest['HostConfig']['LogConfig'] {
return {
Type: value.type,
Config: Object.fromEntries(
value.options.map(({ option, value }) => [option, value])
),
// docker types - requires union while it should allow also custom string for custom plugins
} as CreateContainerRequest['HostConfig']['LogConfig'];
}
}

View file

@ -0,0 +1,196 @@
import { Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { useEffect, useState } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { Registry } from '@/react/portainer/registries/types/registry';
import { notifySuccess } from '@/portainer/services/notifications';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { PageHeader } from '@@/PageHeader';
import { ImageConfigValues } from '@@/ImageConfigFieldset';
import { confirmDestructive } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip';
import { useContainers } from '../queries/containers';
import { useSystemLimits } from '../../proxy/queries/useInfo';
import { useCreateOrReplaceMutation } from './useCreateMutation';
import { useValidation } from './validation';
import { useInitialValues, Values } from './useInitialValues';
import { InnerForm } from './InnerForm';
import { toRequest } from './toRequest';
export function CreateView() {
return (
<>
<PageHeader
title="Create container"
breadcrumbs={[
{ label: 'Containers', link: 'docker.containers' },
'Add container',
]}
/>
<CreateForm />
</>
);
}
function CreateForm() {
const environmentId = useEnvironmentId();
const router = useRouter();
const { trackEvent } = useAnalytics();
const { isAdmin } = useCurrentUser();
const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false);
const mutation = useCreateOrReplaceMutation();
const initialValuesQuery = useInitialValues(
mutation.isLoading || mutation.isSuccess
);
const registriesQuery = useEnvironmentRegistries(environmentId);
const { oldContainer, syncName } = useOldContainer(
initialValuesQuery?.initialValues?.name
);
const { maxCpu, maxMemory } = useSystemLimits(environmentId);
const envQuery = useCurrentEnvironment();
const validationSchema = useValidation({
isAdmin,
maxCpu,
maxMemory,
isDuplicating: initialValuesQuery?.isDuplicating,
isDuplicatingPortainer: oldContainer?.IsPortainer,
isDockerhubRateLimited,
});
if (!envQuery.data || !initialValuesQuery) {
return null;
}
const environment = envQuery.data;
const {
isDuplicating = false,
initialValues,
extraNetworks,
} = initialValuesQuery;
return (
<>
{isDuplicating && (
<InformationPanel title-text="Caution">
<TextTip>
The new container may fail to start if the image is changed, and
settings from the previous container aren&apos;t compatible. Common
causes include entrypoint, cmd or
<a
href="https://docs.portainer.io/user/docker/containers/advanced"
target="_blank"
rel="noreferrer"
>
other settings
</a>{' '}
set by an image.
</TextTip>
</InformationPanel>
)}
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validateOnMount
validationSchema={validationSchema}
>
<InnerForm
onChangeName={syncName}
isDuplicate={isDuplicating}
isLoading={mutation.isLoading}
onRateLimit={(limited = false) => setIsDockerhubRateLimited(limited)}
/>
</Formik>
</>
);
async function handleSubmit(values: Values) {
if (oldContainer) {
const confirmed = await confirmDestructive({
title: 'Are you sure?',
message:
'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?',
confirmButton: buildConfirmButton('Replace', 'danger'),
});
if (!confirmed) {
return;
}
}
const registry = getRegistry(values.image, registriesQuery.data || []);
const config = toRequest(values, registry);
mutation.mutate(
{ config, environment, values, registry, oldContainer, extraNetworks },
{
onSuccess() {
sendAnalytics(values, registry);
notifySuccess('Success', 'Container successfully created');
router.stateService.go('docker.containers');
},
}
);
}
function sendAnalytics(values: Values, registry?: Registry) {
const containerImage = registry?.URL
? `${registry?.URL}/${values.image}`
: values.image;
if (values.resources.gpu.enabled) {
trackEvent('gpuContainerCreated', {
category: 'docker',
metadata: { gpu: values.resources.gpu, containerImage },
});
}
}
}
function getRegistry(image: ImageConfigValues, registries: Registry[]) {
return image.useRegistry
? registries.find((registry) => registry.Id === image.registryId)
: undefined;
}
function useOldContainer(initialName?: string) {
const environmentId = useEnvironmentId();
const [name, setName] = useState(initialName);
const debouncedName = useDebouncedValue(name, 1000);
const oldContainerQuery = useContainers(environmentId, {
enabled: !!debouncedName,
filters: {
name: [`^/${debouncedName}$`],
},
});
useEffect(() => {
if (initialName && initialName !== name) {
setName(initialName);
}
}, [initialName, name]);
return {
syncName: setName,
oldContainer:
oldContainerQuery.data && oldContainerQuery.data.length > 0
? oldContainerQuery.data[0]
: undefined,
};
}

View file

@ -1,12 +1,10 @@
import { useState } from 'react';
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { Values } from './types';
export function EnvVarsTab({
values: initialValues,
values,
onChange,
errors,
}: {
@ -14,19 +12,18 @@ export function EnvVarsTab({
onChange(value: Values): void;
errors?: ArrayError<Values>;
}) {
const [values, setControlledValues] = useState(initialValues);
return (
<EnvironmentVariablesPanel
values={values}
explanation="These values will be applied to the container when deployed"
onChange={handleChange}
errors={errors}
/>
<div className="form-group">
<EnvironmentVariablesPanel
values={values}
explanation="These values will be applied to the container when deployed"
onChange={handleChange}
errors={errors}
/>
</div>
);
function handleChange(values: Values) {
setControlledValues(values);
onChange(values);
}
}

View file

@ -0,0 +1,224 @@
import { useFormikContext, Form } from 'formik';
import { Settings } from 'lucide-react';
import { useState } from 'react';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useIsEnvironmentAdmin } from '@/react/hooks/useUser';
import { NavTabs } from '@@/NavTabs';
import { Widget } from '@@/Widget';
import { useApiVersion } from '../../proxy/queries/useVersion';
import { BaseForm } from './BaseForm';
import { CapabilitiesTab } from './CapabilitiesTab';
import { CommandsTab } from './CommandsTab';
import { LabelsTab } from './LabelsTab';
import { NetworkTab } from './NetworkTab';
import { ResourcesTab } from './ResourcesTab';
import { RestartPolicyTab } from './RestartPolicyTab';
import { VolumesTab } from './VolumesTab';
import { Values } from './useInitialValues';
import { EnvVarsTab } from './EnvVarsTab';
import { EditResourcesForm } from './ResourcesTab/EditResourceForm';
export function InnerForm({
isLoading,
isDuplicate,
onChangeName,
onRateLimit,
}: {
isDuplicate: boolean;
isLoading: boolean;
onChangeName: (value: string) => void;
onRateLimit: (limited?: boolean) => void;
}) {
const { values, setFieldValue, errors, submitForm } =
useFormikContext<Values>();
const environmentId = useEnvironmentId();
const [tab, setTab] = useState('commands');
const apiVersion = useApiVersion(environmentId);
const isEnvironmentAdmin = useIsEnvironmentAdmin();
const envQuery = useCurrentEnvironment();
if (!envQuery.data) {
return null;
}
const environment = envQuery.data;
return (
<Form className="form-horizontal">
<div className="row">
<div className="col-sm-12">
<div>
<BaseForm
onChangeName={onChangeName}
onChangeImageName={() => {
setFieldValue('commands.cmd', null);
setFieldValue('commands.entrypoint', null);
}}
isLoading={isLoading}
onRateLimit={onRateLimit}
/>
<div className="mt-4">
<Widget>
<Widget.Title
title="Advanced container settings"
icon={Settings}
/>
<Widget.Body>
<NavTabs<string>
onSelect={setTab}
selectedId={tab}
type="pills"
justified
options={[
{
id: 'commands',
label: 'Commands & logging',
children: (
<CommandsTab
apiVersion={apiVersion}
values={values.commands}
setFieldValue={(field, value) =>
setFieldValue(`commands.${field}`, value)
}
/>
),
},
{
id: 'volumes',
label: 'Volumes',
children: (
<VolumesTab
values={values.volumes}
onChange={(value) =>
setFieldValue('volumes', value)
}
errors={errors.volumes}
allowBindMounts={
isEnvironmentAdmin ||
environment.SecuritySettings
.allowBindMountsForRegularUsers
}
/>
),
},
{
id: 'network',
label: 'Network',
children: (
<NetworkTab
values={values.network}
setFieldValue={(field, value) =>
setFieldValue(`network.${field}`, value)
}
/>
),
},
{
id: 'env',
label: 'Env',
children: (
<EnvVarsTab
values={values.env}
onChange={(value) => setFieldValue('env', value)}
errors={errors.env}
/>
),
},
{
id: 'labels',
label: 'Labels',
children: (
<LabelsTab
values={values.labels}
onChange={(value) => setFieldValue('labels', value)}
errors={errors.labels}
/>
),
},
{
id: 'restart',
label: 'Restart policy',
children: (
<RestartPolicyTab
values={values.restartPolicy}
onChange={(value) =>
setFieldValue('restartPolicy', value)
}
/>
),
},
{
id: 'runtime',
label: 'Runtime & resources',
children: (
<ResourcesTab
values={values.resources}
errors={errors.resources}
setFieldValue={(field, value) =>
setFieldValue(`resources.${field}`, value)
}
allowPrivilegedMode={
isEnvironmentAdmin ||
environment.SecuritySettings
.allowPrivilegedModeForRegularUsers
}
isDevicesFieldVisible={
isEnvironmentAdmin ||
environment.SecuritySettings
.allowDeviceMappingForRegularUsers
}
isInitFieldVisible={apiVersion >= 1.37}
isSysctlFieldVisible={
isEnvironmentAdmin ||
environment.SecuritySettings
.allowSysctlSettingForRegularUsers
}
renderLimits={
isDuplicate
? (values) => (
<EditResourcesForm
initialValues={values}
redeploy={(values) => {
setFieldValue(
'resources.resources',
values
);
return submitForm();
}}
isImageInvalid={!!errors?.image}
/>
)
: undefined
}
/>
),
},
{
id: 'capabilities',
label: 'Capabilities',
children: (
<CapabilitiesTab
values={values.capabilities}
onChange={(value) =>
setFieldValue('capabilities', value)
}
/>
),
},
]}
/>
</Widget.Body>
</Widget>
</div>
</div>
</div>
</div>
</Form>
);
}

View file

@ -1,5 +1,3 @@
import { useState } from 'react';
import { InputList } from '@@/form-components/InputList';
import { ArrayError } from '@@/form-components/InputList/InputList';
@ -7,7 +5,7 @@ import { Item } from './Item';
import { Values } from './types';
export function LabelsTab({
values: initialValues,
values,
onChange,
errors,
}: {
@ -15,8 +13,6 @@ export function LabelsTab({
onChange: (values: Values) => void;
errors?: ArrayError<Values>;
}) {
const [values, setControlledValues] = useState(initialValues);
return (
<InputList
label="Labels"
@ -29,7 +25,6 @@ export function LabelsTab({
);
function handleChange(values: Values) {
setControlledValues(values);
onChange(values);
}
}

View file

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

View file

@ -3,7 +3,6 @@ import { toRequest } from './toRequest';
import { toViewModel, getDefaultViewModel } from './toViewModel';
export { NetworkTab } from './NetworkTab';
export { type Values as NetworkTabValues } from './types';
export const networkTabUtils = {

View file

@ -5,9 +5,9 @@ import { DockerContainer } from '../../types';
import { CONTAINER_MODE, Values } from './types';
export function getDefaultViewModel(hasBridgeNetwork: boolean) {
export function getDefaultViewModel() {
return {
networkMode: hasBridgeNetwork ? 'bridge' : 'nat',
networkMode: 'bridge',
hostname: '',
domain: '',
macAddress: '',

View file

@ -1,6 +1,6 @@
import _ from 'lodash';
import { FormikErrors } from 'formik';
import { useState } from 'react';
import { ReactNode } from 'react';
import { useIsStandAlone } from '@/react/docker/proxy/queries/useInfo';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@ -17,7 +17,6 @@ import {
ResourceFieldset,
Values as ResourcesValues,
} from './ResourcesFieldset';
import { EditResourcesForm } from './EditResourceForm';
export interface Values {
runtime: RuntimeValues;
@ -34,29 +33,24 @@ export interface Values {
}
export function ResourcesTab({
values: initialValues,
onChange,
values,
setFieldValue,
errors,
allowPrivilegedMode,
isInitFieldVisible,
isDevicesFieldVisible,
isSysctlFieldVisible,
errors,
isDuplicate,
redeploy,
isImageInvalid,
renderLimits,
}: {
values: Values;
onChange: (values: Values) => void;
setFieldValue: (field: string, value: unknown) => void;
errors?: FormikErrors<Values>;
allowPrivilegedMode: boolean;
isInitFieldVisible: boolean;
isDevicesFieldVisible: boolean;
isSysctlFieldVisible: boolean;
errors?: FormikErrors<Values>;
isDuplicate?: boolean;
redeploy: (values: Values) => Promise<void>;
isImageInvalid: boolean;
renderLimits?: (values: ResourcesValues) => ReactNode;
}) {
const [values, setControlledValues] = useState(initialValues);
const environmentId = useEnvironmentId();
const environmentQuery = useCurrentEnvironment();
@ -75,7 +69,7 @@ export function ResourcesTab({
<div className="mt-3">
<RuntimeSection
values={values.runtime}
onChange={(runtime) => handleChange({ runtime })}
onChange={(runtime) => setFieldValue('runtime', runtime)}
allowPrivilegedMode={allowPrivilegedMode}
isInitFieldVisible={isInitFieldVisible}
/>
@ -83,14 +77,14 @@ export function ResourcesTab({
{isDevicesFieldVisible && (
<DevicesField
values={values.devices}
onChange={(devices) => handleChange({ devices })}
onChange={(devices) => setFieldValue('devices', devices)}
/>
)}
{isSysctlFieldVisible && (
<SysctlsField
values={values.sysctls}
onChange={(sysctls) => handleChange({ sysctls })}
onChange={(sysctls) => setFieldValue('sysctls', sysctls)}
/>
)}
@ -102,7 +96,7 @@ export function ResourcesTab({
min="1"
value={values.sharedMemorySize}
onChange={(e) =>
handleChange({ sharedMemorySize: e.target.valueAsNumber })
setFieldValue('sharedMemorySize', e.target.valueAsNumber)
}
className="w-32"
/>
@ -115,7 +109,7 @@ export function ResourcesTab({
{isStandalone && (
<GpuFieldset
values={values.gpu}
onChange={(gpu) => handleChange({ gpu })}
onChange={(gpu) => setFieldValue('gpu', gpu)}
gpus={environment.Gpus}
enableGpuManagement={environment.EnableGPUManagement}
usedGpus={gpuUseList}
@ -123,26 +117,15 @@ export function ResourcesTab({
/>
)}
{isDuplicate ? (
<EditResourcesForm
initialValues={values.resources}
redeploy={(newValues) =>
redeploy({ ...values, resources: newValues })
}
isImageInvalid={isImageInvalid}
/>
{renderLimits ? (
renderLimits(values.resources)
) : (
<ResourceFieldset
values={values.resources}
onChange={(resources) => handleChange({ resources })}
onChange={(resources) => setFieldValue('resources', resources)}
errors={errors?.resources}
/>
)}
</div>
);
function handleChange(newValues: Partial<Values>) {
onChange({ ...values, ...newValues });
setControlledValues({ ...values, ...newValues });
}
}

View file

@ -1,7 +1,5 @@
import { useState } from 'react';
import { FormikErrors } from 'formik';
import { InputList } from '@@/form-components/InputList';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { Values, Volume } from './types';
import { InputContext } from './context';
@ -16,17 +14,15 @@ export function VolumesTab({
onChange: (values: Values) => void;
values: Values;
allowBindMounts: boolean;
errors?: FormikErrors<Values>;
errors?: ArrayError<Values>;
}) {
const [controlledValues, setControlledValues] = useState(values);
return (
<InputContext.Provider value={allowBindMounts}>
<InputList<Volume>
errors={Array.isArray(errors) ? errors : []}
label="Volume mapping"
onChange={(volumes) => handleChange(volumes)}
value={controlledValues}
value={values}
addLabel="map additional volume"
item={Item}
itemBuilder={() => ({
@ -41,6 +37,5 @@ export function VolumesTab({
function handleChange(newValues: Values) {
onChange(newValues);
setControlledValues(() => newValues);
}
}

View file

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

View file

@ -0,0 +1,34 @@
import { Registry } from '@/react/portainer/registries/types/registry';
import { buildImageFullURI } from '@/react/docker/images/utils';
import { baseFormUtils } from './BaseForm';
import { capabilitiesTabUtils } from './CapabilitiesTab';
import { commandsTabUtils } from './CommandsTab';
import { labelsTabUtils } from './LabelsTab';
import { networkTabUtils } from './NetworkTab';
import { resourcesTabUtils } from './ResourcesTab';
import { volumesTabUtils } from './VolumesTab';
import { CreateContainerRequest } from './types';
import { restartPolicyTabUtils } from './RestartPolicyTab';
import { envVarsTabUtils } from './EnvVarsTab';
import { Values } from './useInitialValues';
export function toRequest(values: Values, registry?: Registry) {
let config: CreateContainerRequest = {
HostConfig: {},
NetworkingConfig: {},
};
config = commandsTabUtils.toRequest(config, values.commands);
config = volumesTabUtils.toRequest(config, values.volumes);
config = networkTabUtils.toRequest(config, values.network, '');
config = labelsTabUtils.toRequest(config, values.labels);
config = restartPolicyTabUtils.toRequest(config, values.restartPolicy);
config = resourcesTabUtils.toRequest(config, values.resources);
config = capabilitiesTabUtils.toRequest(config, values.capabilities);
config = baseFormUtils.toRequest(config, values);
config = envVarsTabUtils.toRequest(config, values.env);
config.Image = buildImageFullURI(values.image.image, registry);
return config;
}

View file

@ -0,0 +1,355 @@
import { useMutation, useQueryClient } from 'react-query';
import { AxiosRequestHeaders } from 'axios';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
Environment,
EnvironmentId,
EnvironmentType,
} from '@/react/portainer/environments/types';
import {
Registry,
RegistryId,
} from '@/react/portainer/registries/types/registry';
import { createWebhook } from '@/react/portainer/webhooks/createWebhook';
import { WebhookType } from '@/react/portainer/webhooks/types';
import {
AccessControlFormData,
ResourceControlResponse,
} from '@/react/portainer/access-control/types';
import { applyResourceControl } from '@/react/portainer/access-control/access-control.service';
import PortainerError from '@/portainer/error';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { pullImage } from '../../images/queries/usePullImageMutation';
import {
removeContainer,
renameContainer,
startContainer,
stopContainer,
urlBuilder,
} from '../containers.service';
import { PortainerResponse } from '../../types';
import { connectContainer } from '../../networks/queries/useConnectContainer';
import { DockerContainer } from '../types';
import { queryKeys } from '../queries/query-keys';
import { CreateContainerRequest } from './types';
import { Values } from './useInitialValues';
interface ExtraNetwork {
networkName: string;
aliases: string[];
}
export function useCreateOrReplaceMutation() {
const environmentId = useEnvironmentId();
const queryClient = useQueryClient();
return useMutation(
createOrReplace,
mutationOptions(
withError('Failed to create container'),
withInvalidate(queryClient, [queryKeys.list(environmentId)])
)
);
}
interface CreateOptions {
config: CreateContainerRequest;
values: Values;
registry?: Registry;
environment: Environment;
}
interface ReplaceOptions extends CreateOptions {
oldContainer: DockerContainer;
extraNetworks: Array<ExtraNetwork>;
}
function isReplace(
options: ReplaceOptions | CreateOptions
): options is ReplaceOptions {
return 'oldContainer' in options && !!options.oldContainer;
}
export function createOrReplace(options: ReplaceOptions | CreateOptions) {
return isReplace(options) ? replace(options) : create(options);
}
async function create({
config,
values,
registry,
environment,
}: CreateOptions) {
await pullImageIfNeeded(
environment.Id,
values.nodeName,
values.alwaysPull,
values.image.image,
registry
);
const containerResponse = await createAndStart(
environment,
config,
values.name,
values.nodeName
);
await applyContainerSettings(
containerResponse.Id,
environment,
values.enableWebhook,
values.accessControl,
containerResponse.Portainer?.ResourceControl,
registry
);
}
async function replace({
oldContainer,
config,
values,
registry,
environment,
extraNetworks,
}: ReplaceOptions) {
await pullImageIfNeeded(
environment.Id,
values.nodeName,
values.alwaysPull,
values.image.image,
registry
);
const containerResponse = await renameAndCreate(
environment,
values,
oldContainer,
config
);
await applyContainerSettings(
containerResponse.Id,
environment,
values.enableWebhook,
values.accessControl,
containerResponse.Portainer?.ResourceControl,
registry
);
await connectToExtraNetworks(
environment.Id,
values.nodeName,
containerResponse.Id,
extraNetworks
);
await removeContainer(environment.Id, oldContainer.Id, {
nodeName: values.nodeName,
});
}
/**
* stop and renames the old container, and creates and stops the new container.
* on any failure, it will rename the old container to its original name
*/
async function renameAndCreate(
environment: Environment,
values: Values,
oldContainer: DockerContainer,
config: CreateContainerRequest
) {
let renamed = false;
try {
await stopContainerIfNeeded(environment.Id, values.nodeName, oldContainer);
await renameContainer(
environment.Id,
oldContainer.Id,
`${oldContainer.Names[0]}-old`,
{ nodeName: values.nodeName }
);
renamed = true;
return await createAndStart(
environment,
config,
values.name,
values.nodeName
);
} catch (e) {
if (renamed) {
await renameContainer(environment.Id, oldContainer.Id, values.name, {
nodeName: values.nodeName,
});
}
throw e;
}
}
/**
* creates a webhook if necessary and applies resource control
*/
async function applyContainerSettings(
containerId: string,
environment: Environment,
enableWebhook: boolean,
accessControl: AccessControlFormData,
resourceControl?: ResourceControlResponse,
registry?: Registry
) {
if (enableWebhook) {
await createContainerWebhook(containerId, environment, registry?.Id);
}
// Portainer will always return a resource control, but since types mark it as optional, we need to check it.
// Ignoring the missing value will result with bugs, hence it's better to throw an error
if (!resourceControl) {
throw new PortainerError('resource control expected after creation');
}
await applyResourceControl(accessControl, resourceControl.Id);
}
/**
* creates a new container and starts it.
* on failure, it will remove the new container
*/
async function createAndStart(
environment: Environment,
config: CreateContainerRequest,
name: string,
nodeName: string
) {
let containerId = '';
try {
const containerResponse = await createContainer(
environment.Id,
config,
name,
{
nodeName,
}
);
containerId = containerResponse.Id;
await startContainer(environment.Id, containerResponse.Id, { nodeName });
return containerResponse;
} catch (e) {
if (containerId) {
await removeContainer(environment.Id, containerId, {
nodeName,
});
}
throw e;
}
}
async function pullImageIfNeeded(
environmentId: EnvironmentId,
nodeName: string,
pull: boolean,
image: string,
registry?: Registry
) {
if (!pull) {
return null;
}
return pullImage({
environmentId,
nodeName,
image,
registry,
ignoreErrors: true,
});
}
async function createContainer(
environmentId: EnvironmentId,
config: CreateContainerRequest,
name?: string,
{ nodeName }: { nodeName?: string } = {}
) {
try {
const headers: AxiosRequestHeaders = {};
if (nodeName) {
headers['X-PortainerAgent-Target'] = nodeName;
}
const { data } = await axios.post<
PortainerResponse<{ Id: string; Warnings: Array<string> }>
>(urlBuilder(environmentId, undefined, 'create'), config, {
headers,
params: { name },
});
return data;
} catch (err) {
throw parseAxiosError(err, 'Unable to create container');
}
}
async function createContainerWebhook(
containerId: string,
environment: Environment,
registryId?: RegistryId
) {
const isNotEdgeAgentOnDockerEnvironment =
environment.Type !== EnvironmentType.EdgeAgentOnDocker;
if (!isNotEdgeAgentOnDockerEnvironment) {
return;
}
await createWebhook({
resourceId: containerId,
environmentId: environment.Id,
registryId,
webhookType: WebhookType.DockerContainer,
});
}
function connectToExtraNetworks(
environmentId: EnvironmentId,
nodeName: string,
containerId: string,
extraNetworks: Array<ExtraNetwork>
) {
if (!extraNetworks) {
return null;
}
return Promise.all(
extraNetworks.map(({ networkName, aliases }) =>
connectContainer({
networkId: networkName,
nodeName,
containerId,
environmentId,
aliases,
})
)
);
}
function stopContainerIfNeeded(
environmentId: EnvironmentId,
nodeName: string,
container: DockerContainer
) {
if (container.State !== 'running' || !container.Id) {
return null;
}
return stopContainer(environmentId, container.Id, { nodeName });
}

View file

@ -0,0 +1,167 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import {
BaseFormValues,
baseFormUtils,
} from '@/react/docker/containers/CreateView/BaseForm';
import {
CapabilitiesTabValues,
capabilitiesTabUtils,
} from '@/react/docker/containers/CreateView/CapabilitiesTab';
import {
CommandsTabValues,
commandsTabUtils,
} from '@/react/docker/containers/CreateView/CommandsTab';
import {
LabelsTabValues,
labelsTabUtils,
} from '@/react/docker/containers/CreateView/LabelsTab';
import {
NetworkTabValues,
networkTabUtils,
} from '@/react/docker/containers/CreateView/NetworkTab';
import {
ResourcesTabValues,
resourcesTabUtils,
} from '@/react/docker/containers/CreateView/ResourcesTab';
import {
RestartPolicy,
restartPolicyTabUtils,
} from '@/react/docker/containers/CreateView/RestartPolicyTab';
import {
VolumesTabValues,
volumesTabUtils,
} from '@/react/docker/containers/CreateView/VolumesTab';
import {
Values as EnvVarsTabValues,
envVarsTabUtils,
} from '@/react/docker/containers/CreateView/EnvVarsTab';
import { UserId } from '@/portainer/users/types';
import { getImageConfig } from '@/react/portainer/registries/utils/getImageConfig';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useWebhooks } from '@/react/portainer/webhooks/useWebhooks';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { useNetworksForSelector } from '../components/NetworkSelector';
import { useContainers } from '../queries/containers';
import { useContainer } from '../queries/container';
export interface Values extends BaseFormValues {
commands: CommandsTabValues;
volumes: VolumesTabValues;
network: NetworkTabValues;
labels: LabelsTabValues;
restartPolicy: RestartPolicy;
resources: ResourcesTabValues;
capabilities: CapabilitiesTabValues;
env: EnvVarsTabValues;
}
export function useInitialValues(submitting: boolean) {
const {
params: { nodeName, from },
} = useCurrentStateAndParams();
const environmentId = useEnvironmentId();
const { isAdmin, user } = useCurrentUser();
const networksQuery = useNetworksForSelector();
const fromContainerQuery = useContainer(environmentId, from, {
enabled: !submitting,
});
const runningContainersQuery = useContainers(environmentId, {
enabled: !!from,
});
const webhookQuery = useWebhooks(
{ endpointId: environmentId, resourceId: from },
{ enabled: !!from }
);
const registriesQuery = useEnvironmentRegistries(environmentId, {
enabled: !!from,
});
if (!networksQuery.data) {
return null;
}
if (!from) {
return {
initialValues: defaultValues(isAdmin, user.Id, nodeName),
};
}
const fromContainer = fromContainerQuery.data;
if (
!fromContainer ||
!registriesQuery.data ||
!runningContainersQuery.data ||
!webhookQuery.data
) {
return null;
}
const network = networkTabUtils.toViewModel(
fromContainer,
networksQuery.data,
runningContainersQuery.data
);
const extraNetworks = Object.entries(
fromContainer.NetworkSettings?.Networks || {}
)
.filter(([n]) => n !== network.networkMode)
.map(([networkName, network]) => ({
networkName,
aliases: (network.Aliases || []).filter(
(o) => !fromContainer.Id?.startsWith(o)
),
}));
const imageConfig = getImageConfig(
fromContainer?.Config?.Image || '',
registriesQuery.data
);
const initialValues: Values = {
commands: commandsTabUtils.toViewModel(fromContainer),
volumes: volumesTabUtils.toViewModel(fromContainer),
network: networkTabUtils.toViewModel(
fromContainer,
networksQuery.data,
runningContainersQuery.data
),
labels: labelsTabUtils.toViewModel(fromContainer),
restartPolicy: restartPolicyTabUtils.toViewModel(fromContainer),
resources: resourcesTabUtils.toViewModel(fromContainer),
capabilities: capabilitiesTabUtils.toViewModel(fromContainer),
env: envVarsTabUtils.toViewModel(fromContainer),
...baseFormUtils.toViewModel(
fromContainer,
isAdmin,
user.Id,
nodeName,
imageConfig,
(webhookQuery.data?.length || 0) > 0
),
};
return { initialValues, isDuplicating: true, extraNetworks };
}
function defaultValues(
isAdmin: boolean,
currentUserId: UserId,
nodeName: string
): Values {
return {
commands: commandsTabUtils.getDefaultViewModel(),
volumes: volumesTabUtils.getDefaultViewModel(),
network: networkTabUtils.getDefaultViewModel(),
labels: labelsTabUtils.getDefaultViewModel(),
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
resources: resourcesTabUtils.getDefaultViewModel(),
capabilities: capabilitiesTabUtils.getDefaultViewModel(),
env: envVarsTabUtils.getDefaultViewModel(),
...baseFormUtils.getDefaultViewModel(isAdmin, currentUserId, nodeName),
};
}

View file

@ -0,0 +1,58 @@
import { object, SchemaOf } from 'yup';
import { useMemo } from 'react';
import { baseFormUtils } from './BaseForm';
import { capabilitiesTabUtils } from './CapabilitiesTab';
import { commandsTabUtils } from './CommandsTab';
import { labelsTabUtils } from './LabelsTab';
import { networkTabUtils } from './NetworkTab';
import { resourcesTabUtils } from './ResourcesTab';
import { restartPolicyTabUtils } from './RestartPolicyTab';
import { volumesTabUtils } from './VolumesTab';
import { envVarsTabUtils } from './EnvVarsTab';
import { Values } from './useInitialValues';
export function useValidation({
isAdmin,
maxCpu,
maxMemory,
isDuplicating,
isDuplicatingPortainer,
isDockerhubRateLimited,
}: {
isAdmin: boolean;
maxCpu: number;
maxMemory: number;
isDuplicating: boolean | undefined;
isDuplicatingPortainer: boolean | undefined;
isDockerhubRateLimited: boolean;
}): SchemaOf<Values> {
return useMemo(
() =>
object({
commands: commandsTabUtils.validation(),
volumes: volumesTabUtils.validation(),
network: networkTabUtils.validation(),
labels: labelsTabUtils.validation(),
restartPolicy: restartPolicyTabUtils.validation(),
resources: resourcesTabUtils.validation({ maxCpu, maxMemory }),
capabilities: capabilitiesTabUtils.validation(),
env: envVarsTabUtils.validation(),
}).concat(
baseFormUtils.validation({
isAdmin,
isDuplicating,
isDuplicatingPortainer,
isDockerhubRateLimited,
})
),
[
isAdmin,
isDockerhubRateLimited,
isDuplicating,
isDuplicatingPortainer,
maxCpu,
maxMemory,
]
);
}