1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-23 07:19:41 +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'
),
});
}