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:
parent
bc0050a7b4
commit
d970f0e2bc
71 changed files with 2612 additions and 1399 deletions
199
app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx
Normal file
199
app/react/docker/containers/CreateView/BaseForm/BaseForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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']),
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
|
@ -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';
|
||||
}
|
12
app/react/docker/containers/CreateView/BaseForm/index.ts
Normal file
12
app/react/docker/containers/CreateView/BaseForm/index.ts
Normal 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,
|
||||
};
|
24
app/react/docker/containers/CreateView/BaseForm/toRequest.ts
Normal file
24
app/react/docker/containers/CreateView/BaseForm/toRequest.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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'
|
||||
),
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue