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

refactor(templates): migrate list view to react [EE-2296] (#10999)

This commit is contained in:
Chaim Lev-Ari 2024-04-11 09:29:30 +03:00 committed by GitHub
parent d38085a560
commit 6ff4fd3db2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 2628 additions and 1315 deletions

View file

@ -11,9 +11,7 @@ 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';
@ -23,6 +21,7 @@ import {
PortsMappingField,
Values as PortMappingValue,
} from './PortsMappingField';
import { NameField } from './NameField';
export interface Values {
name: string;
@ -74,19 +73,14 @@ export function BaseForm({
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"
data-cy="container-name-input"
/>
</FormControl>
<NameField
value={values.name}
onChange={(name) => {
setFieldValue('name', name);
onChangeName(name);
}}
error={errors?.name}
/>
<FormSection title="Image Configuration">
<ImageConfigFieldset

View file

@ -0,0 +1,32 @@
import { string } from 'yup';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
export function NameField({
value,
error,
onChange,
}: {
value: string;
error?: string;
onChange: (value: string) => void;
}) {
return (
<FormControl label="Name" inputId="name-input" errors={error}>
<Input
id="name-input"
value={value}
onChange={(e) => {
onChange(e.target.value);
}}
placeholder="e.g. myContainer"
data-cy="container-name-input"
/>
</FormControl>
);
}
export function nameValidation() {
return string().default('');
}

View file

@ -9,7 +9,7 @@ type PortKey = `${string}/${Protocol}`;
export function parsePortBindingRequest(portBindings: Values): PortMap {
const bindings: Record<
PortKey,
Array<{ HostIp: string; HostPort: string }>
Array<{ HostIp?: string; HostPort?: string }>
> = {};
_.forEach(portBindings, (portBinding) => {
if (!portBinding.containerPort) {
@ -17,9 +17,6 @@ export function parsePortBindingRequest(portBindings: Values): PortMap {
}
const portInfo = extractPortInfo(portBinding);
if (!portInfo) {
return;
}
let { hostPort } = portBinding;
const { endHostPort, endPort, hostIp, startHostPort, startPort } = portInfo;
@ -36,7 +33,9 @@ export function parsePortBindingRequest(portBindings: Values): PortMap {
hostPort += `-${endHostPort.toString()}`;
}
bindings[bindKey].push({ HostIp: hostIp, HostPort: hostPort });
bindings[bindKey].push(
hostIp || hostPort ? { HostIp: hostIp, HostPort: hostPort } : {}
);
});
});
return bindings;
@ -71,7 +70,13 @@ function parsePort(port: string) {
return 0;
}
function extractPortInfo(portBinding: PortMapping) {
function extractPortInfo(portBinding: PortMapping): {
startPort: number;
endPort: number;
hostIp: string;
startHostPort: number;
endHostPort: number;
} {
const containerPortRange = parsePortRange(portBinding.containerPort);
if (!isValidPortRange(containerPortRange)) {
throw new Error(`Invalid port specification: ${portBinding.containerPort}`);
@ -82,7 +87,13 @@ function extractPortInfo(portBinding: PortMapping) {
let hostIp = '';
let { hostPort } = portBinding;
if (!hostPort) {
return null;
return {
startPort,
endPort,
hostIp: '',
startHostPort: 0,
endHostPort: 0,
};
}
if (hostPort.includes('[')) {

View file

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

View file

@ -6,6 +6,7 @@ import { imageConfigValidation } from '@@/ImageConfigFieldset';
import { Values } from './BaseForm';
import { validationSchema as portsSchema } from './PortsMappingField.validation';
import { nameValidation } from './NameField';
export function validation(
{
@ -26,9 +27,10 @@ export function validation(
}
): SchemaOf<Values> {
return object({
name: string()
.default('')
.test('not-duplicate-portainer', () => !isDuplicatingPortainer),
name: nameValidation().test(
'not-duplicate-portainer',
() => !isDuplicatingPortainer
),
alwaysPull: boolean()
.default(true)
.test('rate-limits', 'Rate limit exceeded', (alwaysPull: boolean) =>

View file

@ -40,30 +40,30 @@ export function toRequest(
}
return config;
}
function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
switch (value) {
case 'both':
return { OpenStdin: true, Tty: true };
case 'interactive':
return { OpenStdin: true, Tty: false };
case 'tty':
return { OpenStdin: false, Tty: true };
case 'none':
default:
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'];
export function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
switch (value) {
case 'both':
return { OpenStdin: true, Tty: true };
case 'interactive':
return { OpenStdin: true, Tty: false };
case 'tty':
return { OpenStdin: false, Tty: true };
case 'none':
default:
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

@ -145,7 +145,21 @@ function CreateForm() {
const config = toRequest(values, registry, hideCapabilities);
return mutation.mutate(
{ config, environment, values, registry, oldContainer, extraNetworks },
{
config,
environment,
values: {
accessControl: values.accessControl,
imageName: values.image.image,
name: values.name,
alwaysPull: values.alwaysPull,
enableWebhook: values.enableWebhook,
nodeName: values.nodeName,
},
registry,
oldContainer,
extraNetworks,
},
{
onSuccess() {
sendAnalytics(values, registry);

View file

@ -101,11 +101,6 @@ export function InnerForm({
setFieldValue('volumes', value)
}
errors={errors.volumes}
allowBindMounts={
isEnvironmentAdminQuery.authorized ||
environment.SecuritySettings
.allowBindMountsForRegularUsers
}
/>
),
},

View file

@ -0,0 +1,27 @@
import { string } from 'yup';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
export function HostnameField({
value,
error,
onChange,
}: {
value: string;
error?: string;
onChange: (value: string) => void;
}) {
return (
<FormControl label="Hostname" errors={error}>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="e.g. web01"
data-cy="docker-container-hostname-input"
/>
</FormControl>
);
}
export const hostnameSchema = string().default('');

View file

@ -0,0 +1,56 @@
import { array, string } from 'yup';
import { FormError } from '@@/form-components/FormError';
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
import { ItemProps } from '@@/form-components/InputList';
import { ArrayError, InputList } from '@@/form-components/InputList/InputList';
export const hostFileSchema = array(
string().required('Entry is required')
).default([]);
export function HostsFileEntries({
values,
onChange,
errors,
}: {
values: string[];
onChange: (values: string[]) => void;
errors?: ArrayError<string>;
}) {
return (
<InputList
label="Hosts file entries"
value={values}
onChange={(hostsFileEntries) => onChange(hostsFileEntries)}
errors={errors}
item={HostsFileEntryItem}
itemBuilder={() => ''}
data-cy="hosts-file-entries"
/>
);
}
function HostsFileEntryItem({
item,
onChange,
disabled,
error,
readOnly,
index,
}: ItemProps<string>) {
return (
<div>
<InputLabeled
label="value"
value={item}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
readOnly={readOnly}
data-cy={`hosts-file-entry_${index}`}
/>
{error && <FormError>{error}</FormError>}
</div>
);
}

View file

@ -2,14 +2,13 @@ import { FormikErrors } from 'formik';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { InputList, ItemProps } from '@@/form-components/InputList';
import { InputGroup } from '@@/form-components/InputGroup';
import { FormError } from '@@/form-components/FormError';
import { NetworkSelector } from '../../components/NetworkSelector';
import { CONTAINER_MODE, Values } from './types';
import { ContainerSelector } from './ContainerSelector';
import { HostsFileEntries } from './HostsFileEntries';
import { HostnameField } from './HostnameField';
export function NetworkTab({
values,
@ -39,14 +38,10 @@ export function NetworkTab({
</FormControl>
)}
<FormControl label="Hostname" errors={errors?.hostname}>
<Input
value={values.hostname}
onChange={(e) => setFieldValue('hostname', e.target.value)}
placeholder="e.g. web01"
data-cy="docker-container-hostname-input"
/>
</FormControl>
<HostnameField
value={values.hostname}
onChange={(value) => setFieldValue('hostname', value)}
/>
<FormControl label="Domain Name" errors={errors?.domain}>
<Input
@ -102,43 +97,11 @@ export function NetworkTab({
/>
</FormControl>
<InputList
label="Hosts file entries"
value={values.hostsFileEntries}
onChange={(hostsFileEntries) =>
setFieldValue('hostsFileEntries', hostsFileEntries)
}
<HostsFileEntries
values={values.hostsFileEntries}
onChange={(v) => setFieldValue('hostsFileEntries', v)}
errors={errors?.hostsFileEntries}
item={HostsFileEntryItem}
itemBuilder={() => ''}
data-cy="docker-container-hosts-file-entries"
/>
</div>
);
}
function HostsFileEntryItem({
item,
onChange,
disabled,
error,
readOnly,
index,
}: ItemProps<string>) {
return (
<div>
<InputGroup>
<InputGroup.Addon>value</InputGroup.Addon>
<Input
value={item}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
readOnly={readOnly}
data-cy={`docker-container-hosts-file-entry_${index}`}
/>
</InputGroup>
{error && <FormError>{error}</FormError>}
</div>
);
}

View file

@ -1,18 +1,20 @@
import { array, object, SchemaOf, string } from 'yup';
import { object, SchemaOf, string } from 'yup';
import { Values } from './types';
import { hostnameSchema } from './HostnameField';
import { hostFileSchema } from './HostsFileEntries';
export function validation(): SchemaOf<Values> {
return object({
networkMode: string().default(''),
hostname: string().default(''),
hostname: hostnameSchema,
domain: string().default(''),
macAddress: string().default(''),
ipv4Address: string().default(''),
ipv6Address: string().default(''),
primaryDns: string().default(''),
secondaryDns: string().default(''),
hostsFileEntries: array(string().required('Entry is required')).default([]),
hostsFileEntries: hostFileSchema,
container: string()
.default('')
.when('network', {

View file

@ -11,13 +11,15 @@ export function RuntimeSelector({
onChange: (value: string) => void;
}) {
const environmentId = useEnvironmentId();
const infoQuery = useInfo(environmentId, (info) => [
{ label: 'Default', value: '' },
...Object.keys(info?.Runtimes || {}).map((runtime) => ({
label: runtime,
value: runtime,
})),
]);
const infoQuery = useInfo(environmentId, {
select: (info) => [
{ label: 'Default', value: '' },
...Object.keys(info?.Runtimes || {}).map((runtime) => ({
label: runtime,
value: runtime,
})),
],
});
return (
<PortainerSelect

View file

@ -18,7 +18,7 @@ export function Item({
error,
index,
}: ItemProps<Volume>) {
const allowBindMounts = useInputContext();
const { allowBindMounts, allowAuto } = useInputContext();
return (
<div>
@ -61,6 +61,7 @@ export function Item({
value={volume.name}
onChange={(name) => setValue({ name })}
inputId={`volume-${index}`}
allowAuto={allowAuto}
/>
</InputGroup>
)}

View file

@ -8,10 +8,12 @@ export function VolumeSelector({
value,
onChange,
inputId,
allowAuto,
}: {
value: string;
onChange: (value?: string) => void;
inputId?: string;
allowAuto: boolean;
}) {
const environmentId = useEnvironmentId();
const volumesQuery = useVolumes(environmentId, {
@ -24,7 +26,9 @@ export function VolumeSelector({
return null;
}
const volumes = volumesQuery.data;
const volumes = allowAuto
? [...volumesQuery.data, { Name: 'auto', Driver: '' }]
: volumesQuery.data;
const selectedValue = volumes.find((vol) => vol.Name === value);
@ -33,7 +37,9 @@ export function VolumeSelector({
placeholder="Select a volume"
options={volumes}
getOptionLabel={(vol) =>
`${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
vol.Name !== 'auto'
? `${truncate(vol.Name, 30)} - ${truncate(vol.Driver, 30)}`
: 'auto'
}
getOptionValue={(vol) => vol.Name}
isMulti={false}

View file

@ -1,3 +1,8 @@
import { useMemo } from 'react';
import { useIsEnvironmentAdmin } from '@/react/hooks/useUser';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { InputList } from '@@/form-components/InputList';
import { ArrayError } from '@@/form-components/InputList/InputList';
@ -8,16 +13,29 @@ import { Item } from './Item';
export function VolumesTab({
onChange,
values,
allowBindMounts,
errors,
allowAuto = false,
}: {
onChange: (values: Values) => void;
values: Values;
allowBindMounts: boolean;
errors?: ArrayError<Values>;
allowAuto?: boolean;
}) {
const isEnvironmentAdminQuery = useIsEnvironmentAdmin({ adminOnlyCE: true });
const envQuery = useCurrentEnvironment();
const allowBindMounts = !!(
isEnvironmentAdminQuery.authorized ||
envQuery.data?.SecuritySettings.allowBindMountsForRegularUsers
);
const inputContext = useMemo(
() => ({ allowBindMounts, allowAuto }),
[allowAuto, allowBindMounts]
);
return (
<InputContext.Provider value={allowBindMounts}>
<InputContext.Provider value={inputContext}>
<InputList<Volume>
errors={Array.isArray(errors) ? errors : []}
label="Volume mapping"

View file

@ -1,6 +1,9 @@
import { createContext, useContext } from 'react';
export const InputContext = createContext<boolean | null>(null);
export const InputContext = createContext<{
allowAuto: boolean;
allowBindMounts: boolean;
} | null>(null);
export function useInputContext() {
const value = useContext(InputContext);

View file

@ -62,7 +62,14 @@ export function useCreateOrReplaceMutation() {
interface CreateOptions {
config: CreateContainerRequest;
values: Values;
values: {
name: Values['name'];
imageName: string;
accessControl: Values['accessControl'];
nodeName?: Values['nodeName'];
alwaysPull?: Values['alwaysPull'];
enableWebhook?: Values['enableWebhook'];
};
registry?: Registry;
environment: Environment;
}
@ -90,14 +97,14 @@ async function create({
}: CreateOptions) {
await pullImageIfNeeded(
environment.Id,
values.alwaysPull || false,
values.imageName,
values.nodeName,
values.alwaysPull,
values.image.image,
registry
);
const containerResponse = await createAndStart(
environment,
environment.Id,
config,
values.name,
values.nodeName
@ -106,8 +113,8 @@ async function create({
await applyContainerSettings(
containerResponse.Id,
environment,
values.enableWebhook,
values.accessControl,
values.enableWebhook,
containerResponse.Portainer?.ResourceControl,
registry
);
@ -123,33 +130,34 @@ async function replace({
}: ReplaceOptions) {
await pullImageIfNeeded(
environment.Id,
values.alwaysPull || false,
values.imageName,
values.nodeName,
values.alwaysPull,
values.image.image,
registry
);
const containerResponse = await renameAndCreate(
environment,
values,
environment.Id,
values.name,
oldContainer,
config
config,
values.nodeName
);
await applyContainerSettings(
containerResponse.Id,
environment,
values.enableWebhook,
values.accessControl,
values.enableWebhook,
containerResponse.Portainer?.ResourceControl,
registry
);
await connectToExtraNetworks(
environment.Id,
values.nodeName,
containerResponse.Id,
extraNetworks
extraNetworks,
values.nodeName
);
await removeContainer(environment.Id, oldContainer.Id, {
@ -162,33 +170,29 @@ async function replace({
* on any failure, it will rename the old container to its original name
*/
async function renameAndCreate(
environment: Environment,
values: Values,
environmentId: EnvironmentId,
name: string,
oldContainer: DockerContainer,
config: CreateContainerRequest
config: CreateContainerRequest,
nodeName?: string
) {
let renamed = false;
try {
await stopContainerIfNeeded(environment.Id, values.nodeName, oldContainer);
await stopContainerIfNeeded(environmentId, oldContainer, nodeName);
await renameContainer(
environment.Id,
environmentId,
oldContainer.Id,
`${oldContainer.Names[0]}-old`,
{ nodeName: values.nodeName }
{ nodeName }
);
renamed = true;
return await createAndStart(
environment,
config,
values.name,
values.nodeName
);
return await createAndStart(environmentId, config, name, nodeName);
} catch (e) {
if (renamed) {
await renameContainer(environment.Id, oldContainer.Id, values.name, {
nodeName: values.nodeName,
await renameContainer(environmentId, oldContainer.Id, name, {
nodeName,
});
}
throw e;
@ -201,8 +205,8 @@ async function renameAndCreate(
async function applyContainerSettings(
containerId: string,
environment: Environment,
enableWebhook: boolean,
accessControl: AccessControlFormData,
enableWebhook?: boolean,
resourceControl?: ResourceControlResponse,
registry?: Registry
) {
@ -224,15 +228,15 @@ async function applyContainerSettings(
* on failure, it will remove the new container
*/
async function createAndStart(
environment: Environment,
environmentId: EnvironmentId,
config: CreateContainerRequest,
name: string,
nodeName: string
nodeName?: string
) {
let containerId = '';
try {
const containerResponse = await createContainer(
environment.Id,
environmentId,
config,
name,
{
@ -242,11 +246,11 @@ async function createAndStart(
containerId = containerResponse.Id;
await startContainer(environment.Id, containerResponse.Id, { nodeName });
await startContainer(environmentId, containerResponse.Id, { nodeName });
return containerResponse;
} catch (e) {
if (containerId) {
await removeContainer(environment.Id, containerId, {
await removeContainer(environmentId, containerId, {
nodeName,
});
}
@ -257,9 +261,9 @@ async function createAndStart(
async function pullImageIfNeeded(
environmentId: EnvironmentId,
nodeName: string,
pull: boolean,
image: string,
nodeName?: string,
registry?: Registry
) {
if (!pull) {
@ -322,9 +326,9 @@ async function createContainerWebhook(
function connectToExtraNetworks(
environmentId: EnvironmentId,
nodeName: string,
containerId: string,
extraNetworks: Array<ExtraNetwork>
extraNetworks: Array<ExtraNetwork>,
nodeName?: string
) {
if (!extraNetworks) {
return null;
@ -345,8 +349,8 @@ function connectToExtraNetworks(
function stopContainerIfNeeded(
environmentId: EnvironmentId,
nodeName: string,
container: DockerContainer
container: DockerContainer,
nodeName?: string
) {
if (container.State !== 'running' || !container.Id) {
return null;