1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +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

@ -0,0 +1,58 @@
import { FormikErrors } from 'formik';
import { SchemaOf, string } from 'yup';
import { useMemo } from 'react';
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { useStacks } from '../queries/useStacks';
export function NameField({
onChange,
value,
errors,
}: {
onChange(value: string): void;
value: string;
errors?: FormikErrors<string>;
}) {
return (
<FormControl inputId="name-input" label="Name" errors={errors} required>
<Input
id="name-input"
onChange={(e) => onChange(e.target.value)}
value={value}
required
data-cy="stack-name-input"
/>
</FormControl>
);
}
export function useNameValidation(
environmentId: EnvironmentId
): SchemaOf<string> {
const stacksQuery = useStacks();
return useMemo(
() =>
string()
.required('Name is required')
.test(
'unique',
'Name should be unique',
(value) =>
stacksQuery.data?.every(
(s) => s.EndpointId !== environmentId || s.Name !== value
) ?? true
)
.matches(
new RegExp(STACK_NAME_VALIDATION_REGEX),
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
),
[environmentId, stacksQuery.data]
);
}

View file

@ -0,0 +1,7 @@
import { Stack } from '../types';
export function buildStackUrl(id?: Stack['Id'], action?: string) {
const baseUrl = '/stacks';
const url = id ? `${baseUrl}/${id}` : baseUrl;
return action ? `${url}/${action}` : url;
}

View file

@ -0,0 +1,3 @@
export const queryKeys = {
base: () => ['stacks'],
};

View file

@ -0,0 +1,14 @@
import { buildStackUrl } from '../buildUrl';
export function buildCreateUrl(
stackType: 'kubernetes',
method: 'repository' | 'url' | 'string'
): string;
export function buildCreateUrl(
stackType: 'swarm' | 'standalone',
method: 'repository' | 'string' | 'file'
): string;
export function buildCreateUrl(stackType: string, method: string) {
return buildStackUrl(undefined, `create/${stackType}/${method}`);
}

View file

@ -0,0 +1,36 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export interface KubernetesFileContentPayload {
/** Name of the stack */
stackName: string;
/** Content of the Stack file */
stackFileContent: string;
composeFormat: boolean;
namespace: string;
/** Whether the stack is from an app template */
fromAppTemplate?: boolean;
environmentId: EnvironmentId;
}
export async function createKubernetesStackFromFileContent({
environmentId,
...payload
}: KubernetesFileContentPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('kubernetes', 'string'),
payload,
{
params: { endpointId: environmentId },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,54 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export type KubernetesGitRepositoryPayload = {
/** Name of the stack */
stackName: string;
composeFormat: boolean;
namespace: string;
/** URL of a Git repository hosting the Stack file */
repositoryUrl: string;
/** Reference name of a Git repository hosting the Stack file */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the binded git credential */
repositoryGitCredentialId?: number;
/** Path to the Stack file inside the Git repository */
manifestFile?: string;
additionalFiles?: Array<string>;
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
tlsSkipVerify?: boolean;
/** Optional GitOps update configuration */
autoUpdate?: AutoUpdateModel;
environmentId: EnvironmentId;
};
export async function createKubernetesStackFromGit({
environmentId,
...payload
}: KubernetesGitRepositoryPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('kubernetes', 'repository'),
payload,
{
params: { endpointId: environmentId },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,32 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export interface KubernetesUrlPayload {
stackName: string;
composeFormat: boolean;
namespace: string;
manifestURL: string;
environmentId: EnvironmentId;
}
export async function createKubernetesStackFromUrl({
environmentId,
...payload
}: KubernetesUrlPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('kubernetes', 'url'),
payload,
{
params: { endpointId: environmentId },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,44 @@
import axios, {
json2formData,
parseAxiosError,
} from '@/portainer/services/axios';
import { Pair } from '@/react/portainer/settings/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export type StandaloneFileUploadPayload = {
/** Name of the stack */
Name: string;
file: File;
/** List of environment variables */
Env?: Array<Pair>;
/** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
Webhook?: string;
environmentId: EnvironmentId;
};
export async function createStandaloneStackFromFile({
environmentId,
...payload
}: StandaloneFileUploadPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('standalone', 'file'),
json2formData(payload),
{
headers: {
'Content-Type': 'multipart/form-data',
},
params: { endpointId: environmentId },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,40 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Pair } from '@/react/portainer/settings/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export interface StandaloneFileContentPayload {
/** Name of the stack */
name: string;
stackFileContent: string;
/** List of environment variables */
env?: Array<Pair>;
/** Whether the stack is from an app template */
fromAppTemplate?: boolean;
/** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
webhook?: string;
environmentId: EnvironmentId;
}
export async function createStandaloneStackFromFileContent({
environmentId,
...payload
}: StandaloneFileContentPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('standalone', 'string'),
payload,
{
params: { endpointId: environmentId },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,63 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Pair } from '@/react/portainer/settings/types';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export type StandaloneGitRepositoryPayload = {
/** Name of the stack */
name: string;
/** List of environment variables */
env?: Array<Pair>;
/** Whether the stack is from an app template */
fromAppTemplate?: boolean;
/** URL of a Git repository hosting the Stack file */
repositoryUrl: string;
/** Reference name of a Git repository hosting the Stack file */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the binded git credential */
repositoryGitCredentialId?: number;
/** Path to the Stack file inside the Git repository */
composeFile?: string;
additionalFiles?: Array<string>;
/** Optional GitOps update configuration */
autoUpdate?: AutoUpdateModel;
/** Whether the stack supports relative path volume */
supportRelativePath?: boolean;
/** Local filesystem path */
filesystemPath?: string;
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
tlsSkipVerify?: boolean;
environmentId: EnvironmentId;
};
export async function createStandaloneStackFromGit({
environmentId,
...payload
}: StandaloneGitRepositoryPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('standalone', 'repository'),
payload,
{
params: { endpointId: environmentId },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,50 @@
import axios, {
json2formData,
parseAxiosError,
} from '@/portainer/services/axios';
import { Pair } from '@/react/portainer/settings/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export type SwarmFileUploadPayload = {
/** Name of the stack */
Name: string;
/** List of environment variables */
Env?: Array<Pair>;
/** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
Webhook?: string;
/** Swarm cluster identifier */
SwarmID: string;
file: File;
environmentId: EnvironmentId;
};
export async function createSwarmStackFromFile({
environmentId,
...payload
}: SwarmFileUploadPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('swarm', 'file'),
json2formData(payload),
{
headers: {
'Content-Type': 'multipart/form-data',
},
params: {
endpointId: environmentId,
},
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,43 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Pair } from '@/react/portainer/settings/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export interface SwarmFileContentPayload {
/** Name of the stack */
name: string;
stackFileContent: string;
/** List of environment variables */
env?: Array<Pair>;
/** Whether the stack is from an app template */
fromAppTemplate?: boolean;
/** A UUID to identify a webhook. The stack will be force updated and pull the latest image when the webhook was invoked. */
webhook?: string;
/** Swarm cluster identifier */
swarmID: string;
environmentId: EnvironmentId;
}
export async function createSwarmStackFromFileContent({
environmentId,
...payload
}: SwarmFileContentPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('swarm', 'string'),
payload,
{
params: { endpointId: environmentId },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,65 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Pair } from '@/react/portainer/settings/types';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Stack } from '../../types';
import { buildCreateUrl } from './buildUrl';
export type SwarmGitRepositoryPayload = {
/** Name of the stack */
name: string;
/** List of environment variables */
env?: Array<Pair>;
/** Whether the stack is from an app template */
fromAppTemplate?: boolean;
/** Swarm cluster identifier */
swarmID: string;
/** URL of a Git repository hosting the Stack file */
repositoryUrl: string;
/** Reference name of a Git repository hosting the Stack file */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the binded git credential */
repositoryGitCredentialId?: number;
/** Path to the Stack file inside the Git repository */
composeFile?: string;
additionalFiles?: Array<string>;
/** Optional GitOps update configuration */
autoUpdate?: AutoUpdateModel;
/** Whether the stack supports relative path volume */
supportRelativePath?: boolean;
/** Local filesystem path */
filesystemPath?: string;
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
tlsSkipVerify?: boolean;
environmentId: EnvironmentId;
};
export async function createSwarmStackFromGit({
environmentId,
...payload
}: SwarmGitRepositoryPayload) {
try {
const { data } = await axios.post<Stack>(
buildCreateUrl('standalone', 'repository'),
payload,
{
params: { endpointId: environmentId },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -0,0 +1,302 @@
import { useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Pair } from '@/react/portainer/settings/types';
import {
GitFormModel,
RelativePathModel,
} from '@/react/portainer/gitops/types';
import { applyResourceControl } from '@/react/portainer/access-control/access-control.service';
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import PortainerError from '@/portainer/error';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { queryKeys } from '../query-keys';
import { createSwarmStackFromFile } from './createSwarmStackFromFile';
import { createSwarmStackFromGit } from './createSwarmStackFromGit';
import { createSwarmStackFromFileContent } from './createSwarmStackFromFileContent';
import { createStandaloneStackFromFile } from './createStandaloneStackFromFile';
import { createStandaloneStackFromGit } from './createStandaloneStackFromGit';
import { createStandaloneStackFromFileContent } from './createStandaloneStackFromFileContent';
import { createKubernetesStackFromUrl } from './createKubernetesStackFromUrl';
import { createKubernetesStackFromGit } from './createKubernetesStackFromGit';
import { createKubernetesStackFromFileContent } from './createKubernetesStackFromFileContent';
export function useCreateStack() {
const queryClient = useQueryClient();
return useMutation(createStack, {
...withError('Failed to create stack'),
...withInvalidate(queryClient, [queryKeys.base()]),
});
}
type BasePayload = {
name: string;
environmentId: EnvironmentId;
};
type DockerBasePayload = BasePayload & {
env?: Array<Pair>;
accessControl: AccessControlFormData;
};
type SwarmBasePayload = DockerBasePayload & {
swarmId: string;
};
type KubernetesBasePayload = BasePayload & {
namespace: string;
composeFormat: boolean;
};
export type SwarmCreatePayload =
| {
method: 'file';
payload: SwarmBasePayload & {
/** File to upload */
file: File;
/** Optional webhook configuration */
webhook?: string;
};
}
| {
method: 'string';
payload: SwarmBasePayload & {
/** Content of the Stack file */
fileContent: string;
/** Optional webhook configuration */
webhook?: string;
fromAppTemplate?: boolean;
};
}
| {
method: 'git';
payload: SwarmBasePayload & {
git: GitFormModel;
relativePathSettings?: RelativePathModel;
fromAppTemplate?: boolean;
};
};
type StandaloneCreatePayload =
| {
method: 'file';
payload: DockerBasePayload & {
/** File to upload */
file: File;
/** Optional webhook configuration */
webhook?: string;
};
}
| {
method: 'string';
payload: DockerBasePayload & {
/** Content of the Stack file */
fileContent: string;
/** Optional webhook configuration */
webhook?: string;
fromAppTemplate?: boolean;
};
}
| {
method: 'git';
payload: DockerBasePayload & {
git: GitFormModel;
relativePathSettings?: RelativePathModel;
fromAppTemplate?: boolean;
};
};
type KubernetesCreatePayload =
| {
method: 'string';
payload: KubernetesBasePayload & {
/** Content of the Stack file */
fileContent: string;
/** Optional webhook configuration */
webhook?: string;
};
}
| {
method: 'git';
payload: KubernetesBasePayload & {
git: GitFormModel;
relativePathSettings?: RelativePathModel;
};
}
| {
method: 'url';
payload: KubernetesBasePayload & {
manifestUrl: string;
};
};
export type CreateStackPayload =
| ({ type: 'swarm' } & SwarmCreatePayload)
| ({ type: 'standalone' } & StandaloneCreatePayload)
| ({ type: 'kubernetes' } & KubernetesCreatePayload);
async function createStack(payload: CreateStackPayload) {
const stack = await createActualStack(payload);
if (payload.type === 'standalone' || payload.type === 'swarm') {
const resourceControl = stack.ResourceControl;
// 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(
payload.payload.accessControl,
resourceControl.Id
);
}
}
function createActualStack(payload: CreateStackPayload) {
switch (payload.type) {
case 'swarm':
return createSwarmStack(payload);
case 'standalone':
return createStandaloneStack(payload);
case 'kubernetes':
return createKubernetesStack(payload);
default:
throw new Error('Invalid type');
}
}
function createSwarmStack({ method, payload }: SwarmCreatePayload) {
switch (method) {
case 'file':
return createSwarmStackFromFile({
environmentId: payload.environmentId,
file: payload.file,
Name: payload.name,
SwarmID: payload.swarmId,
Env: payload.env,
Webhook: payload.webhook,
});
case 'git':
return createSwarmStackFromGit({
name: payload.name,
env: payload.env,
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
composeFile: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: payload.git.AutoUpdate,
environmentId: payload.environmentId,
swarmID: payload.swarmId,
additionalFiles: payload.git.AdditionalFiles,
fromAppTemplate: payload.fromAppTemplate,
});
case 'string':
return createSwarmStackFromFileContent({
name: payload.name,
env: payload.env,
environmentId: payload.environmentId,
stackFileContent: payload.fileContent,
webhook: payload.webhook,
swarmID: payload.swarmId,
fromAppTemplate: payload.fromAppTemplate,
});
default:
throw new Error('Invalid method');
}
}
function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
switch (method) {
case 'file':
return createStandaloneStackFromFile({
environmentId: payload.environmentId,
file: payload.file,
Name: payload.name,
Env: payload.env,
Webhook: payload.webhook,
});
case 'git':
return createStandaloneStackFromGit({
name: payload.name,
env: payload.env,
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
composeFile: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: payload.git.AutoUpdate,
environmentId: payload.environmentId,
additionalFiles: payload.git.AdditionalFiles,
fromAppTemplate: payload.fromAppTemplate,
});
case 'string':
return createStandaloneStackFromFileContent({
name: payload.name,
env: payload.env,
environmentId: payload.environmentId,
stackFileContent: payload.fileContent,
webhook: payload.webhook,
fromAppTemplate: payload.fromAppTemplate,
});
default:
throw new Error('Invalid method');
}
}
function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
switch (method) {
case 'string':
return createKubernetesStackFromFileContent({
stackName: payload.name,
environmentId: payload.environmentId,
stackFileContent: payload.fileContent,
composeFormat: payload.composeFormat,
namespace: payload.namespace,
});
case 'git':
return createKubernetesStackFromGit({
stackName: payload.name,
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
manifestFile: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: payload.git.AutoUpdate,
environmentId: payload.environmentId,
additionalFiles: payload.git.AdditionalFiles,
composeFormat: payload.composeFormat,
namespace: payload.namespace,
});
case 'url':
return createKubernetesStackFromUrl({
stackName: payload.name,
composeFormat: payload.composeFormat,
environmentId: payload.environmentId,
manifestURL: payload.manifestUrl,
namespace: payload.namespace,
});
default:
throw new Error('Invalid method');
}
}

View file

@ -0,0 +1,23 @@
import { useQuery } from 'react-query';
import { withError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Stack } from '@/react/common/stacks/types';
import { buildStackUrl } from './buildUrl';
import { queryKeys } from './query-keys';
export function useStacks() {
return useQuery(queryKeys.base(), () => getStacks(), {
...withError('Failed loading stack'),
});
}
export async function getStacks() {
try {
const { data } = await axios.get<Stack[]>(buildStackUrl());
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View file

@ -30,8 +30,8 @@ export interface Stack {
Id: number;
Name: string;
Type: StackType;
EndpointID: number;
SwarmID: string;
EndpointId: number;
SwarmId: string;
EntryPoint: string;
Env: {
name: string;

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;

View file

@ -13,7 +13,9 @@ interface Props {
export function ListView({ endpoint: environment }: Props) {
const isAgent = isAgentEnvironment(environment.Type);
const envInfoQuery = useInfo(environment.Id, (info) => !!info.Swarm?.NodeID);
const envInfoQuery = useInfo(environment.Id, {
select: (info) => !!info.Swarm?.NodeID,
});
const isSwarmManager = !!envInfoQuery.data;
const isHostColumnVisible = isAgent && isSwarmManager;

View file

@ -2,7 +2,7 @@ import _ from 'lodash';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useInfo } from '@/react/docker/proxy/queries/useInfo';
import { useIsStandAlone } from '@/react/docker/proxy/queries/useInfo';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { DockerContainer, ContainerStatus } from './types';
@ -95,14 +95,11 @@ function createStatus(statusText = ''): ContainerStatus {
return ContainerStatus.Running;
}
export function useShowGPUsColumn(environmentID: EnvironmentId) {
const isDockerStandaloneQuery = useInfo(
environmentID,
(info) => !(!!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable) // is not a swarm environment, therefore docker standalone
);
export function useShowGPUsColumn(environmentId: EnvironmentId) {
const isDockerStandalone = useIsStandAlone(environmentId);
const enableGPUManagementQuery = useEnvironment(
environmentID,
environmentId,
(env) => env?.EnableGPUManagement
);
return isDockerStandaloneQuery.data && enableGPUManagementQuery.data;
return isDockerStandalone && enableGPUManagementQuery.data;
}

View file

@ -18,26 +18,38 @@ export async function getInfo(environmentId: EnvironmentId) {
}
export function useInfo<TSelect = SystemInfo>(
environmentId: EnvironmentId,
select?: (info: SystemInfo) => TSelect
environmentId?: EnvironmentId,
{
enabled,
select,
}: { select?: (info: SystemInfo) => TSelect; enabled?: boolean } = {}
) {
return useQuery(
['environment', environmentId, 'docker', 'info'],
() => getInfo(environmentId),
() => getInfo(environmentId!),
{
select,
enabled: !!environmentId && enabled,
}
);
}
export function useIsStandAlone(environmentId: EnvironmentId) {
const query = useInfo(environmentId, (info) => !info.Swarm?.NodeID);
const query = useInfo(environmentId, {
select: (info) => !info.Swarm?.NodeID,
});
return !!query.data;
}
export function useIsSwarm(environmentId: EnvironmentId) {
const query = useInfo(environmentId, (info) => !!info.Swarm?.NodeID);
export function useIsSwarm(
environmentId?: EnvironmentId,
{ enabled }: { enabled?: boolean } = {}
) {
const query = useInfo(environmentId, {
select: (info) => !!info.Swarm?.NodeID,
enabled,
});
return !!query.data;
}

View file

@ -41,7 +41,9 @@ export function useServicePlugins(
pluginType: keyof PluginsInfo,
pluginVersion: string
) {
const systemPluginsQuery = useInfo(environmentId, (info) => info.Plugins);
const systemPluginsQuery = useInfo(environmentId, {
select: (info) => info.Plugins,
});
const pluginsQuery = usePlugins(environmentId, { enabled: !systemOnly });
return {

View file

@ -0,0 +1,38 @@
import { useQuery } from 'react-query';
import { Swarm } from 'docker-types/generated/1.41';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
import { useIsSwarm } from './useInfo';
export function useSwarm<T = Swarm>(
environmentId: EnvironmentId,
{ select }: { select?(value: Swarm): T } = {}
) {
const isSwarm = useIsSwarm(environmentId);
return useQuery({
queryKey: [...queryKeys.base(environmentId), 'swarm'] as const,
queryFn: () => getSwarm(environmentId),
select,
enabled: isSwarm,
});
}
async function getSwarm(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<Swarm>(buildUrl(environmentId, 'swarm'));
return data;
} catch (err) {
throw parseAxiosError(err, 'Unable to retrieve swarm information');
}
}
export function useSwarmId(environmentId: EnvironmentId) {
return useSwarm(environmentId, {
select: (swarm) => swarm.ID,
});
}

View file

@ -18,19 +18,20 @@ export async function getVersion(environmentId: EnvironmentId) {
}
export function useVersion<TSelect = SystemVersion>(
environmentId: EnvironmentId,
environmentId?: EnvironmentId,
select?: (info: SystemVersion) => TSelect
) {
return useQuery(
['environment', environmentId, 'docker', 'version'],
() => getVersion(environmentId),
['environment', environmentId!, 'docker', 'version'],
() => getVersion(environmentId!),
{
select,
enabled: !!environmentId,
}
);
}
export function useApiVersion(environmentId: EnvironmentId) {
export function useApiVersion(environmentId?: EnvironmentId) {
const query = useVersion(environmentId, (info) => info.ApiVersion);
return query.data ? parseFloat(query.data) : 0;
}

View file

@ -67,8 +67,8 @@ export class StackViewModel implements IResource {
this.Id = stack.Id;
this.Type = stack.Type;
this.Name = stack.Name;
this.EndpointId = stack.EndpointID;
this.SwarmId = stack.SwarmID;
this.EndpointId = stack.EndpointId;
this.SwarmId = stack.SwarmId;
this.Env = stack.Env ? stack.Env : [];
this.Option = stack.Option;
this.IsComposeFormat = stack.IsComposeFormat;

View file

@ -0,0 +1,243 @@
import { useRouter } from '@uirouter/react';
import { Formik, Form } from 'formik';
import { notifySuccess } from '@/portainer/services/notifications';
import {
CreateStackPayload,
useCreateStack,
} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control';
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { NameField } from '@/react/common/stacks/CreateView/NameField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import {
isTemplateVariablesEnabled,
renderTemplate,
} from '@/react/portainer/custom-templates/components/utils';
import {
CustomTemplatesVariablesField,
getVariablesFieldDefaultValues,
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { StackType } from '@/react/common/stacks/types';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { AdvancedSettings } from '@/react/portainer/templates/app-templates/DeployFormWidget/AdvancedSettings';
import { Button } from '@@/buttons';
import { FormActions } from '@@/form-components/FormActions';
import { FormSection } from '@@/form-components/FormSection';
import { WebEditorForm } from '@@/WebEditorForm';
import { useSwarmId } from '../../proxy/queries/useSwarm';
import { FormValues } from './types';
import { useValidation } from './useValidation';
export function DeployForm({
template,
unselect,
templateFile,
isDeployable,
}: {
template: CustomTemplate;
templateFile: string;
unselect: () => void;
isDeployable: boolean;
}) {
const router = useRouter();
const { user } = useCurrentUser();
const isEdgeAdminQuery = useIsEdgeAdmin();
const environmentId = useEnvironmentId();
const swarmIdQuery = useSwarmId(environmentId);
const mutation = useCreateStack();
const validation = useValidation({
isDeployable,
variableDefs: template.Variables,
isAdmin: isEdgeAdminQuery.isAdmin,
environmentId,
});
if (isEdgeAdminQuery.isLoading) {
return null;
}
const isGit = !!template.GitConfig;
const initialValues: FormValues = {
name: template.Title || '',
variables: getVariablesFieldDefaultValues(template.Variables),
accessControl: parseAccessControlFormData(
isEdgeAdminQuery.isAdmin,
user.Id
),
fileContent: templateFile,
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
>
{({ values, errors, setFieldValue, isValid }) => (
<Form className="form-horizontal">
<FormSection title="Configuration">
<NameField
value={values.name}
onChange={(v) => setFieldValue('name', v)}
errors={errors.name}
/>
</FormSection>
{isTemplateVariablesEnabled && (
<CustomTemplatesVariablesField
definitions={template.Variables}
onChange={(v) => {
setFieldValue('variables', v);
const newFile = renderTemplate(
templateFile,
v,
template.Variables
);
setFieldValue('fileContent', newFile);
}}
value={values.variables}
errors={errors.variables}
/>
)}
<AdvancedSettings
label={(isOpen) => advancedSettingsLabel(isOpen, isGit)}
>
<WebEditorForm
id="custom-template-creation-editor"
value={values.fileContent}
onChange={(value) => {
if (isGit) {
return;
}
setFieldValue('fileContent', value);
}}
yaml
error={errors.fileContent}
placeholder="Define or paste the content of your docker compose file here"
readonly={isGit}
data-cy="custom-template-creation-editor"
>
<p>
You can get more information about Compose file format in the{' '}
<a
href="https://docs.docker.com/compose/compose-file/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</p>
</WebEditorForm>
</AdvancedSettings>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
environmentId={environmentId}
/>
<FormActions
isLoading={mutation.isLoading}
isValid={isValid}
loadingText="Deployment in progress..."
submitLabel="Deploy the stack"
data-cy="deploy-stack-button"
>
<Button
type="reset"
onClick={() => unselect()}
color="default"
data-cy="cancel-stack-creation"
>
Hide
</Button>
</FormActions>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
const payload = getPayload(values);
return mutation.mutate(payload, {
onSuccess() {
notifySuccess('Success', 'Stack created');
router.stateService.go('docker.stacks');
},
});
}
function getPayload(values: FormValues): CreateStackPayload {
const type =
template.Type === StackType.DockerCompose ? 'standalone' : 'swarm';
const isGit = !!template.GitConfig;
if (isGit) {
return type === 'standalone'
? {
type,
method: 'git',
payload: {
name: values.name,
environmentId,
git: toGitFormModel(template.GitConfig),
accessControl: values.accessControl,
},
}
: {
type,
method: 'git',
payload: {
name: values.name,
environmentId,
swarmId: swarmIdQuery.data || '',
git: toGitFormModel(template.GitConfig),
accessControl: values.accessControl,
},
};
}
return type === 'standalone'
? {
type,
method: 'string',
payload: {
name: values.name,
environmentId,
fileContent: values.fileContent,
accessControl: values.accessControl,
},
}
: {
type,
method: 'string',
payload: {
name: values.name,
environmentId,
swarmId: swarmIdQuery.data || '',
fileContent: values.fileContent,
accessControl: values.accessControl,
},
};
}
}
function advancedSettingsLabel(isOpen: boolean, isGit: boolean) {
if (isGit) {
return isOpen ? 'Hide stack' : 'View stack';
}
return isOpen ? 'Hide custom stack' : 'Customize stack';
}

View file

@ -0,0 +1,57 @@
import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
import { TextTip } from '@@/Tip/TextTip';
import { useIsDeployable } from './useIsDeployable';
import { DeployForm } from './DeployForm';
import { TemplateLoadError } from './TemplateLoadError';
export function StackFromCustomTemplateFormWidget({
template,
unselect,
}: {
template: CustomTemplate;
unselect: () => void;
}) {
const isDeployable = useIsDeployable(template.Type);
const fileQuery = useCustomTemplateFile(template.Id);
if (fileQuery.isLoading) {
return null;
}
return (
<DeployWidget
logo={template.Logo}
note={template.Note}
title={template.Title}
>
{fileQuery.isError && (
<TemplateLoadError
creatorId={template.CreatedByUserId}
templateId={template.Id}
/>
)}
{!isDeployable && (
<div className="form-group">
<div className="col-sm-12">
<TextTip>
This template type cannot be deployed on this environment.
</TextTip>
</div>
</div>
)}
{fileQuery.isSuccess && isDeployable && (
<DeployForm
template={template}
unselect={unselect}
templateFile={fileQuery.data}
isDeployable={isDeployable}
/>
)}
</DeployWidget>
);
}

View file

@ -0,0 +1,46 @@
import { UserId } from '@/portainer/users/types';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { Link } from '@@/Link';
import { FormError } from '@@/form-components/FormError';
export function TemplateLoadError({
templateId,
creatorId,
}: {
templateId: CustomTemplate['Id'];
creatorId: UserId;
}) {
const { user } = useCurrentUser();
const isEdgeAdminQuery = useIsEdgeAdmin();
if (isEdgeAdminQuery.isLoading) {
return null;
}
const isAdminOrWriter = isEdgeAdminQuery.isAdmin || user.Id === creatorId;
return (
<FormError>
{isAdminOrWriter ? (
<>
Custom template could not be loaded, please{' '}
<Link
to=".edit"
params={{ id: templateId }}
data-cy="edit-custom-template-link"
>
click here
</Link>{' '}
for configuration
</>
) : (
<>
Custom template could not be loaded, please contact your
administrator.
</>
)}
</FormError>
);
}

View file

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

View file

@ -0,0 +1,9 @@
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
export interface FormValues {
name: string;
variables: VariablesFieldValue;
accessControl: AccessControlFormData;
fileContent: string;
}

View file

@ -0,0 +1,20 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { StackType } from '@/react/common/stacks/types';
import { useIsSwarm } from '../../proxy/queries/useInfo';
export function useIsDeployable(type: StackType) {
const environmentId = useEnvironmentId();
const isSwarm = useIsSwarm(environmentId);
switch (type) {
case StackType.DockerCompose:
return !isSwarm;
case StackType.DockerSwarm:
return isSwarm;
case StackType.Kubernetes:
default:
return false;
}
}

View file

@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { object, string } from 'yup';
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { EnvironmentId } from '@/react/portainer/environments/types';
export function useValidation({
environmentId,
isAdmin,
variableDefs,
isDeployable,
}: {
variableDefs: Array<VariableDefinition>;
isAdmin: boolean;
environmentId: EnvironmentId;
isDeployable: boolean;
}) {
const name = useNameValidation(environmentId);
return useMemo(
() =>
object({
name: name.test({
name: 'is-deployable',
message: 'This template cannot be deployed on this environment',
test: () => isDeployable,
}),
accessControl: accessControlFormValidation(isAdmin),
fileContent: string().required('Required'),
variables: variablesFieldValidation(variableDefs),
}),
[isAdmin, isDeployable, name, variableDefs]
);
}

View file

@ -0,0 +1,19 @@
import { buildUrl as buildProxyUrl } from '@/react/docker/proxy/queries/build-url';
import { EnvironmentId } from '@/react/portainer/environments/types';
export function buildUrl(
environmentId: EnvironmentId,
{ action, id }: { id?: string; action?: string } = {}
) {
let url = buildProxyUrl(environmentId, 'volumes');
if (id) {
url += `/${id}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

View file

@ -0,0 +1,26 @@
import { Volume, VolumeCreateOptions } from 'docker-types/generated/1.41';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildUrl } from './build-url';
export async function createVolume(
environmentId: EnvironmentId,
volume: VolumeCreateOptions
) {
try {
const { data } = await axios.post<Volume>(
buildUrl(environmentId, { action: 'create' }),
volume,
{
headers: {
'X-Portainer-VolumeName': volume.Name || '',
},
}
);
return data;
} catch (error) {
throw parseAxiosError(error);
}
}

View file

@ -2,10 +2,10 @@ import { useQuery } from 'react-query';
import { Volume } from 'docker-types/generated/1.41';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildUrl as buildDockerUrl } from '@/react/docker/proxy/queries/build-url';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
export function useVolumes<T = Volume[]>(
environmentId: EnvironmentId,
@ -24,22 +24,10 @@ interface VolumesResponse {
export async function getVolumes(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<VolumesResponse>(
buildUrl(environmentId, 'volumes')
);
const { data } = await axios.get<VolumesResponse>(buildUrl(environmentId));
return data.Volumes;
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to retrieve volumes');
}
}
function buildUrl(environmentId: EnvironmentId, action: string, id?: string) {
let url = buildDockerUrl(environmentId, action);
if (id) {
url += `/${id}`;
}
return url;
}

View file

@ -1,9 +1,11 @@
import { FormikErrors } from 'formik';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { EnvVarsFieldset } from './EnvVarsFieldset';
import { TemplateNote } from './TemplateNote';
import {
EnvVarsFieldset,
EnvVarsValue,
} from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
export function AppTemplateFieldset({
template,
@ -12,16 +14,16 @@ export function AppTemplateFieldset({
errors,
}: {
template: TemplateViewModel;
values: Record<string, string>;
onChange: (value: Record<string, string>) => void;
errors?: FormikErrors<Record<string, string>>;
values: EnvVarsValue;
onChange: (value: EnvVarsValue) => void;
errors?: FormikErrors<EnvVarsValue>;
}) {
return (
<>
<TemplateNote note={template.Note} />
<EnvVarsFieldset
options={template.Env || []}
value={values}
values={values}
onChange={onChange}
errors={errors}
/>

View file

@ -1,10 +1,10 @@
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { Values } from './types';
import { TemplateNote } from './TemplateNote';
export function CustomTemplateFieldset({
errors,

View file

@ -3,7 +3,8 @@ import { FormikErrors } from 'formik';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { getDefaultValues as getAppVariablesDefaultValues } from './EnvVarsFieldset';
import { getDefaultValues as getAppVariablesDefaultValues } from '../../../../portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { TemplateSelector } from './TemplateSelector';
import { SelectedTemplateValue, Values } from './types';
import { CustomTemplateFieldset } from './CustomTemplateFieldset';

View file

@ -1,25 +0,0 @@
import { vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TemplateNote } from './TemplateNote';
vi.mock('sanitize-html', () => ({
default: (note: string) => note, // Mock the sanitize-html library to return the input as is
}));
test('renders template note', async () => {
render(<TemplateNote note="Test note" />);
const templateNoteElement = screen.getByText(/Information/);
expect(templateNoteElement).toBeInTheDocument();
const noteElement = screen.getByText(/Test note/);
expect(noteElement).toBeInTheDocument();
});
test('does not render template note when note is undefined', async () => {
render(<TemplateNote note={undefined} />);
const templateNoteElement = screen.queryByText(/Information/);
expect(templateNoteElement).not.toBeInTheDocument();
});

View file

@ -2,17 +2,19 @@ import { mixed, object, SchemaOf, string } from 'yup';
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { envVarsFieldsetValidation } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
import { envVarsFieldsetValidation } from './EnvVarsFieldset';
export function validation({
definitions,
function validation({
customVariablesDefinitions,
envVarDefinitions,
}: {
definitions: VariableDefinition[];
customVariablesDefinitions: VariableDefinition[];
envVarDefinitions: Array<TemplateEnv>;
}) {
return object({
type: string().oneOf(['custom', 'app']).required(),
envVars: envVarsFieldsetValidation()
envVars: envVarsFieldsetValidation(envVarDefinitions)
.optional()
.when('type', {
is: 'app',
@ -20,7 +22,7 @@ export function validation({
}),
file: mixed().optional(),
template: object().optional().default(null),
variables: variablesFieldValidation(definitions)
variables: variablesFieldValidation(customVariablesDefinitions)
.optional()
.when('type', {
is: 'custom',

View file

@ -1,26 +0,0 @@
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { PageHeader } from '@@/PageHeader';
export function AppTemplatesView() {
const templatesQuery = useAppTemplates();
return (
<>
<PageHeader title="Application templates list" breadcrumbs="Templates" />
<AppTemplatesList
templates={templatesQuery.data}
templateLinkParams={(template) => ({
to: 'edge.stacks.new',
params: { templateId: template.Id, templateType: 'app' },
})}
disabledTypes={[TemplateType.Container]}
fixedCategories={['edge']}
storageKey="edge-app-templates"
/>
</>
);
}

View file

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

View file

@ -1 +1,2 @@
export { AccessControlForm } from './AccessControlForm';
export { validationSchema as accessControlFormValidation } from './AccessControlForm.validation';

View file

@ -0,0 +1,92 @@
import { useParamState } from '@/react/hooks/useParamState';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useInfo } from '@/react/docker/proxy/queries/useInfo';
import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
import { useAuthorizations } from '@/react/hooks/useUser';
import { PageHeader } from '@@/PageHeader';
import { TemplateType } from './types';
import { useAppTemplates } from './queries/useAppTemplates';
import { AppTemplatesList } from './AppTemplatesList';
import { DeployForm } from './DeployFormWidget/DeployFormWidget';
export function AppTemplatesView() {
const envId = useEnvironmentId(false);
const hasCreateAuthQuery = useAuthorizations([
'DockerContainerCreate',
'PortainerStackCreate',
]);
const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
'template',
(param) => (param ? parseInt(param, 10) : 0)
);
const templatesQuery = useAppTemplates();
const selectedTemplate = selectedTemplateId
? templatesQuery.data?.find(
(template) => template.Id === selectedTemplateId
)
: undefined;
const { disabledTypes, fixedCategories, tableKey } = useViewFilter(envId);
return (
<>
<PageHeader title="Application templates list" breadcrumbs="Templates" />
{selectedTemplate && (
<DeployForm
template={selectedTemplate}
unselect={() => setSelectedTemplateId()}
/>
)}
<AppTemplatesList
templates={templatesQuery.data}
selectedId={selectedTemplateId}
onSelect={
envId && hasCreateAuthQuery.authorized
? (template) => setSelectedTemplateId(template.Id)
: undefined
}
disabledTypes={disabledTypes}
fixedCategories={fixedCategories}
storageKey={tableKey}
templateLinkParams={
!envId
? (template) => ({
to: 'edge.stacks.new',
params: { templateId: template.Id, templateType: 'app' },
})
: undefined
}
/>
</>
);
}
function useViewFilter(envId: number | undefined) {
const envInfoQuery = useInfo(envId);
const apiVersion = useApiVersion(envId);
if (!envId) {
// edge
return {
disabledTypes: [TemplateType.Container],
fixedCategories: ['edge'],
tableKey: 'edge-app-templates',
};
}
const showSwarmStacks =
apiVersion >= 1.25 &&
envInfoQuery.data &&
envInfoQuery.data.Swarm &&
envInfoQuery.data.Swarm.NodeID &&
envInfoQuery.data.Swarm.ControlAvailable;
return {
disabledTypes: !showSwarmStacks ? [TemplateType.SwarmStack] : [],
tableKey: 'docker-app-templates',
};
}

View file

@ -0,0 +1,52 @@
import { Minus, Plus } from 'lucide-react';
import { PropsWithChildren, ReactNode, useState } from 'react';
import { Icon } from '@@/Icon';
import { Button } from '@@/buttons';
export function AdvancedSettings({
children,
label,
}: PropsWithChildren<{
label: (isOpen: boolean) => ReactNode;
}>) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<AdvancedSettingsToggle
isOpen={isOpen}
onClick={() => setIsOpen((value) => !value)}
label={label(isOpen)}
/>
{isOpen ? children : null}
</>
);
}
function AdvancedSettingsToggle({
label,
onClick,
isOpen,
}: {
isOpen: boolean;
onClick: () => void;
label: ReactNode;
}) {
const icon = isOpen ? Minus : Plus;
return (
<div className="form-group">
<div className="col-sm-12">
<Button
color="none"
onClick={() => onClick()}
data-cy="advanced-settings-toggle-button"
>
<Icon icon={icon} />
{label}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,168 @@
import { Formik, Form } from 'formik';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control';
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { NameField } from '@/react/docker/containers/CreateView/BaseForm/NameField';
import { NetworkSelector } from '@/react/docker/containers/components/NetworkSelector';
import { PortsMappingField } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField';
import { VolumesTab } from '@/react/docker/containers/CreateView/VolumesTab';
import { HostsFileEntries } from '@/react/docker/containers/CreateView/NetworkTab/HostsFileEntries';
import { LabelsTab } from '@/react/docker/containers/CreateView/LabelsTab';
import { HostnameField } from '@/react/docker/containers/CreateView/NetworkTab/HostnameField';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { FormActions } from '@@/form-components/FormActions';
import { Button } from '@@/buttons';
import { TemplateViewModel } from '../../view-model';
import { AdvancedSettings } from '../AdvancedSettings';
import { EnvVarsFieldset } from '../EnvVarsFieldset';
import { useValidation } from './useValidation';
import { FormValues } from './types';
import { useCreate } from './useCreate';
export function ContainerDeployForm({
template,
unselect,
}: {
template: TemplateViewModel;
unselect: () => void;
}) {
const { user } = useCurrentUser();
const isEdgeAdminQuery = useIsEdgeAdmin();
const environmentId = useEnvironmentId();
const validation = useValidation({
isAdmin: isEdgeAdminQuery.isAdmin,
envVarDefinitions: template.Env,
});
const createMutation = useCreate(template);
if (!createMutation || isEdgeAdminQuery.isLoading) {
return null;
}
const initialValues: FormValues = {
name: template.Name || '',
envVars:
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
{},
accessControl: parseAccessControlFormData(
isEdgeAdminQuery.isAdmin,
user.Id
),
hostname: '',
hosts: [],
labels: [],
network: '',
ports: template.Ports.map((p) => ({ ...p, hostPort: p.hostPort || '' })),
volumes: template.Volumes.map((v) => ({
containerPath: v.container,
type: v.type === 'bind' ? 'bind' : 'volume',
readOnly: v.readonly,
name: v.type === 'bind' ? v.bind || '' : 'auto',
})),
};
return (
<Formik
initialValues={initialValues}
onSubmit={createMutation.onSubmit}
validationSchema={validation}
validateOnMount
>
{({ values, errors, setFieldValue, isValid }) => (
<Form className="form-horizontal">
<FormSection title="Configuration">
<NameField
value={values.name}
onChange={(v) => setFieldValue('name', v)}
error={errors.name}
/>
<FormControl label="Network" errors={errors?.network}>
<NetworkSelector
value={values.network}
onChange={(v) => setFieldValue('network', v)}
/>
</FormControl>
<EnvVarsFieldset
values={values.envVars}
onChange={(values) => setFieldValue('envVars', values)}
errors={errors.envVars}
options={template.Env || []}
/>
</FormSection>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
environmentId={environmentId}
/>
<AdvancedSettings
label={(isOpen) =>
isOpen ? 'Hide advanced options' : 'Show advanced options'
}
>
<PortsMappingField
value={values.ports}
onChange={(v) => setFieldValue('ports', v)}
errors={errors.ports}
/>
<VolumesTab
onChange={(v) => setFieldValue('volumes', v)}
values={values.volumes}
errors={errors.volumes}
allowAuto
/>
<HostsFileEntries
values={values.hosts}
onChange={(v) => setFieldValue('hosts', v)}
errors={errors?.hosts}
/>
<LabelsTab
values={values.labels}
onChange={(v) => setFieldValue('labels', v)}
errors={errors?.labels}
/>
<HostnameField
value={values.hostname}
onChange={(v) => setFieldValue('hostname', v)}
error={errors.hostname}
/>
</AdvancedSettings>
<FormActions
isLoading={createMutation.isLoading}
isValid={isValid}
loadingText="Deployment in progress..."
submitLabel="Deploy the container"
data-cy="deploy-container-button"
>
<Button
type="reset"
onClick={() => unselect()}
color="default"
data-cy="cancel-deploy-container-button"
>
Hide
</Button>
</FormActions>
</Form>
)}
</Formik>
);
}

View file

@ -0,0 +1,64 @@
import { commandStringToArray } from '@/docker/helpers/containers';
import { parsePortBindingRequest } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField.requestModel';
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
import { CreateContainerRequest } from '@/react/docker/containers/CreateView/types';
import { TemplateViewModel } from '../../view-model';
import { FormValues } from './types';
export function createContainerConfiguration(
template: TemplateViewModel,
values: FormValues
): CreateContainerRequest {
let configuration: CreateContainerRequest = {
Env: [],
OpenStdin: false,
Tty: false,
ExposedPorts: {},
HostConfig: {
RestartPolicy: {
Name: 'no',
},
PortBindings: {},
Binds: [],
Privileged: false,
ExtraHosts: [],
},
Volumes: {},
Labels: {},
NetworkingConfig: {},
};
configuration = volumesTabUtils.toRequest(configuration, values.volumes);
configuration.HostConfig.NetworkMode = values.network;
configuration.HostConfig.Privileged = template.Privileged;
configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy };
configuration.HostConfig.ExtraHosts = values.hosts ? values.hosts : [];
configuration.Hostname = values.hostname;
configuration.Env = Object.entries(values.envVars).map(
([name, value]) => `${name}=${value}`
);
configuration.Cmd = commandStringToArray(template.Command);
const portBindings = parsePortBindingRequest(values.ports);
configuration.HostConfig.PortBindings = portBindings;
configuration.ExposedPorts = Object.fromEntries(
Object.keys(portBindings).map((key) => [key, {}])
);
const consoleConfiguration = getConsoleConfiguration(template.Interactive);
configuration.OpenStdin = consoleConfiguration.openStdin;
configuration.Tty = consoleConfiguration.tty;
configuration.Labels = Object.fromEntries(
values.labels.filter((l) => !!l.name).map((l) => [l.name, l.value])
);
configuration.Image = template.RegistryModel.Image;
return configuration;
}
function getConsoleConfiguration(interactiveFlag: boolean) {
return {
openStdin: interactiveFlag,
tty: interactiveFlag,
};
}

View file

@ -0,0 +1,18 @@
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import { PortMapping } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField';
import { VolumesTabValues } from '@/react/docker/containers/CreateView/VolumesTab';
import { LabelsTabValues } from '@/react/docker/containers/CreateView/LabelsTab';
import { EnvVarsValue } from '../EnvVarsFieldset';
export interface FormValues {
name: string;
network: string;
accessControl: AccessControlFormData;
ports: Array<PortMapping>;
volumes: VolumesTabValues;
hosts: Array<string>;
labels: LabelsTabValues;
hostname: string;
envVars: EnvVarsValue;
}

View file

@ -0,0 +1,68 @@
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useCreateOrReplaceMutation } from '@/react/docker/containers/CreateView/useCreateMutation';
import { TemplateViewModel } from '../../view-model';
import { FormValues } from './types';
import { createContainerConfiguration } from './createContainerConfig';
import { useCreateLocalVolumes } from './useCreateLocalVolumes';
export function useCreate(template: TemplateViewModel) {
const router = useRouter();
const createVolumesMutation = useCreateLocalVolumes();
const createContainerMutation = useCreateOrReplaceMutation();
const environmentQuery = useCurrentEnvironment();
if (!environmentQuery.data) {
return null;
}
const environment = environmentQuery.data;
return {
onSubmit,
isLoading:
createVolumesMutation.isLoading || createContainerMutation.isLoading,
};
function onSubmit(values: FormValues) {
const autoVolumesCount = values.volumes.filter(
(v) => v.type === 'volume' && v.name === 'auto'
).length;
createVolumesMutation.mutate(autoVolumesCount, {
onSuccess(autoVolumes) {
let index = 0;
const volumes = values.volumes.map((v) =>
v.type === 'volume' && v.name === 'auto'
? { ...v, name: autoVolumes[index++].Name }
: v
);
createContainerMutation.mutate(
{
config: createContainerConfiguration(template, {
...values,
volumes,
}),
values: {
name: values.name,
accessControl: values.accessControl,
imageName: template.RegistryModel.Image,
alwaysPull: true,
},
environment,
},
{
onSuccess() {
notifySuccess('Success', 'Container successfully created');
router.stateService.go('docker.containers');
},
}
);
},
});
}
}

View file

@ -0,0 +1,16 @@
import { useMutation } from 'react-query';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { createVolume } from '@/react/docker/volumes/queries/useCreateVolume';
export function useCreateLocalVolumes() {
const environmentId = useEnvironmentId();
return useMutation(async (count: number) =>
Promise.all(
Array.from({ length: count }).map(() =>
createVolume(environmentId, { Driver: 'local' })
)
)
);
}

View file

@ -0,0 +1,37 @@
import { object, string } from 'yup';
import { useMemo } from 'react';
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
import { hostnameSchema } from '@/react/docker/containers/CreateView/NetworkTab/HostnameField';
import { hostFileSchema } from '@/react/docker/containers/CreateView/NetworkTab/HostsFileEntries';
import { labelsTabUtils } from '@/react/docker/containers/CreateView/LabelsTab';
import { nameValidation } from '@/react/docker/containers/CreateView/BaseForm/NameField';
import { validationSchema as portSchema } from '@/react/docker/containers/CreateView/BaseForm/PortsMappingField.validation';
import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab';
import { envVarsFieldsetValidation } from '../EnvVarsFieldset';
import { TemplateEnv } from '../../types';
export function useValidation({
isAdmin,
envVarDefinitions,
}: {
isAdmin: boolean;
envVarDefinitions: Array<TemplateEnv>;
}) {
return useMemo(
() =>
object({
accessControl: accessControlFormValidation(isAdmin),
envVars: envVarsFieldsetValidation(envVarDefinitions),
hostname: hostnameSchema,
hosts: hostFileSchema,
labels: labelsTabUtils.validation(),
name: nameValidation(),
network: string().default(''),
ports: portSchema(),
volumes: volumesTabUtils.validation(),
}),
[envVarDefinitions, isAdmin]
);
}

View file

@ -0,0 +1,42 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { DeployWidget } from '@/react/portainer/templates/components/DeployWidget';
import { ContainerDeployForm } from './ContainerDeployForm/ContainerDeployForm';
import { StackDeployForm } from './StackDeployForm/StackDeployForm';
export function DeployForm({
template,
unselect,
}: {
template: TemplateViewModel;
unselect: () => void;
}) {
const Form = useForm(template);
return (
<DeployWidget
logo={template.Logo}
note={template.Note}
title={template.Title}
>
<Form template={template} unselect={unselect} />
</DeployWidget>
);
}
function useForm(template: TemplateViewModel) {
const envId = useEnvironmentId(false);
if (!envId) {
// for edge templates, return empty form
return () => null;
}
if (template.Type === TemplateType.Container) {
return ContainerDeployForm;
}
return StackDeployForm;
}

View file

@ -15,14 +15,13 @@ test('renders EnvVarsFieldset component', () => {
{ name: 'VAR2', label: 'Variable 2', preset: false },
] as const;
const value = { VAR1: 'Value 1', VAR2: 'Value 2' };
const errors = {};
render(
<EnvVarsFieldset
onChange={onChange}
options={[...options]}
value={value}
errors={errors}
values={value}
errors={{}}
/>
);
@ -40,14 +39,13 @@ test('calls onChange when input value changes', async () => {
const onChange = vi.fn();
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
const value = { VAR1: 'Value 1' };
const errors = {};
render(
<EnvVarsFieldset
onChange={onChange}
options={options}
value={value}
errors={errors}
values={value}
errors={{}}
/>
);
@ -63,15 +61,14 @@ test('calls onChange when input value changes', async () => {
test('renders error message when there are errors', () => {
const onChange = vi.fn();
const options = [{ name: 'VAR1', label: 'Variable 1', preset: false }];
const value = { VAR1: 'Value 1' };
const errors = { VAR1: 'Required' };
const value = { VAR1: '' };
render(
<EnvVarsFieldset
onChange={onChange}
options={options}
value={value}
errors={errors}
values={value}
errors={{ VAR1: 'Required' }}
/>
);
@ -104,7 +101,10 @@ test('returns default values', () => {
});
test('validates env vars fieldset', () => {
const schema = envVarsFieldsetValidation();
const schema = envVarsFieldsetValidation([
{ name: 'VAR1' },
{ name: 'VAR2' },
]);
const validData = { VAR1: 'Value 1', VAR2: 'Value 2' };
const invalidData = { VAR1: '', VAR2: 'Value 2' };

View file

@ -1,5 +1,5 @@
import { FormikErrors } from 'formik';
import { SchemaOf, array, string } from 'yup';
import { SchemaOf, object, string } from 'yup';
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
@ -8,15 +8,17 @@ import { Input, Select } from '@@/form-components/Input';
type Value = Record<string, string>;
export { type Value as EnvVarsValue };
export function EnvVarsFieldset({
onChange,
options,
value,
values,
errors,
}: {
options: Array<TemplateEnv>;
onChange: (value: Value) => void;
value: Value;
values: Value;
errors?: FormikErrors<Value>;
}) {
return (
@ -25,7 +27,7 @@ export function EnvVarsFieldset({
<Item
key={env.name}
option={env}
value={value[env.name]}
value={values[env.name]}
onChange={(value) => handleChange(env.name, value)}
errors={errors?.[env.name]}
/>
@ -34,7 +36,7 @@ export function EnvVarsFieldset({
);
function handleChange(name: string, envValue: string) {
onChange({ ...value, [name]: envValue });
onChange({ ...values, [name]: envValue });
}
}
@ -94,11 +96,12 @@ export function getDefaultValues(definitions: Array<TemplateEnv>): Value {
);
}
export function envVarsFieldsetValidation(): SchemaOf<Value> {
return (
array()
.transform((_, orig) => Object.values(orig))
// casting to return the correct type - validation works as expected
.of(string().required('Required')) as unknown as SchemaOf<Value>
export function envVarsFieldsetValidation(
definitions: Array<TemplateEnv>
): SchemaOf<Value> {
return object(
Object.fromEntries(
definitions.map((v) => [v.name, string().required('Required')])
)
);
}

View file

@ -0,0 +1,163 @@
import { useRouter } from '@uirouter/react';
import { Formik, Form } from 'formik';
import { notifySuccess } from '@/portainer/services/notifications';
import {
SwarmCreatePayload,
useCreateStack,
} from '@/react/common/stacks/queries/useCreateStack/useCreateStack';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control';
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { NameField } from '@/react/common/stacks/CreateView/NameField';
import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm';
import { Button } from '@@/buttons';
import { FormActions } from '@@/form-components/FormActions';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { EnvVarsFieldset } from '../EnvVarsFieldset';
import { FormValues } from './types';
import { useValidation } from './useValidation';
import { useIsDeployable } from './useIsDeployable';
export function StackDeployForm({
template,
unselect,
}: {
template: TemplateViewModel;
unselect: () => void;
}) {
const isDeployable = useIsDeployable(template.Type);
const router = useRouter();
const isEdgeAdminQuery = useIsEdgeAdmin();
const { user } = useCurrentUser();
const environmentId = useEnvironmentId();
const swarmIdQuery = useSwarmId(environmentId);
const mutation = useCreateStack();
const validation = useValidation({
isAdmin: isEdgeAdminQuery.isAdmin,
environmentId,
envVarDefinitions: template.Env,
});
if (isEdgeAdminQuery.isLoading) {
return null;
}
const initialValues: FormValues = {
name: template.Name || '',
envVars:
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
{},
accessControl: parseAccessControlFormData(
isEdgeAdminQuery.isAdmin,
user.Id
),
};
if (!isDeployable) {
return (
<div className="form-group">
<TextTip>
This template type cannot be deployed on this environment.
</TextTip>
</div>
);
}
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
>
{({ values, errors, setFieldValue, isValid }) => (
<Form className="form-horizontal">
<FormSection title="Configuration">
<NameField
value={values.name}
onChange={(v) => setFieldValue('name', v)}
errors={errors.name}
/>
<EnvVarsFieldset
values={values.envVars}
onChange={(values) => setFieldValue('envVars', values)}
errors={errors.envVars}
options={template.Env || []}
/>
</FormSection>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
environmentId={environmentId}
/>
<FormActions
isLoading={mutation.isLoading}
isValid={isValid}
loadingText="Deployment in progress..."
submitLabel="Deploy the stack"
data-cy="deploy-stack-button"
>
<Button
type="reset"
onClick={() => unselect()}
color="default"
data-cy="cancel-deploy-stack-button"
>
Hide
</Button>
</FormActions>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
const type =
template.Type === TemplateType.ComposeStack ? 'standalone' : 'swarm';
const payload: SwarmCreatePayload['payload'] = {
name: values.name,
environmentId,
env: Object.entries(values.envVars).map(([name, value]) => ({
name,
value,
})),
swarmId: swarmIdQuery.data || '',
git: {
RepositoryURL: template.Repository.url,
ComposeFilePathInRepository: template.Repository.stackfile,
},
fromAppTemplate: true,
accessControl: values.accessControl,
};
return mutation.mutate(
{
type,
method: 'git',
payload,
},
{
onSuccess() {
notifySuccess('Success', 'Stack created');
router.stateService.go('docker.stacks');
},
}
);
}
}

View file

@ -0,0 +1,7 @@
import { AccessControlFormData } from '@/react/portainer/access-control/types';
export interface FormValues {
name: string;
envVars: Record<string, string>;
accessControl: AccessControlFormData;
}

View file

@ -0,0 +1,19 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
export function useIsDeployable(type: TemplateType) {
const environmentId = useEnvironmentId();
const isSwarm = useIsSwarm(environmentId);
switch (type) {
case TemplateType.ComposeStack:
case TemplateType.Container:
return true;
case TemplateType.SwarmStack:
return isSwarm;
default:
return false;
}
}

View file

@ -0,0 +1,33 @@
import { useMemo } from 'react';
import { SchemaOf, object } from 'yup';
import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm';
import { useNameValidation } from '@/react/common/stacks/CreateView/NameField';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { envVarsFieldsetValidation } from '../EnvVarsFieldset';
import { TemplateEnv } from '../../types';
import { FormValues } from './types';
export function useValidation({
environmentId,
isAdmin,
envVarDefinitions,
}: {
isAdmin: boolean;
environmentId: EnvironmentId;
envVarDefinitions: Array<TemplateEnv>;
}): SchemaOf<FormValues> {
const name = useNameValidation(environmentId);
return useMemo(
() =>
object({
name,
accessControl: accessControlFormValidation(isAdmin),
envVars: envVarsFieldsetValidation(envVarDefinitions),
}),
[envVarDefinitions, isAdmin, name]
);
}

View file

@ -1,3 +1,5 @@
import { RestartPolicy } from 'docker-types/generated/1.41';
import { BasicTableSettings } from '@@/datatables/types';
import { Pair } from '../../settings/types';
@ -152,7 +154,7 @@ export interface AppTemplate {
* Container restart policy.
* @example "on-failure"
*/
restart_policy?: string;
restart_policy?: RestartPolicy['Name'];
/**
* Container hostname.
@ -181,7 +183,7 @@ export interface TemplateRepository {
/**
* TemplateVolume represents a template volume configuration.
*/
interface TemplateVolume {
export interface TemplateVolume {
/**
* Path inside the container.
* @example "/data"

View file

@ -1,4 +1,5 @@
import _ from 'lodash';
import { RestartPolicy } from 'docker-types/generated/1.41';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
@ -47,7 +48,7 @@ export class TemplateViewModel {
Interactive!: boolean;
RestartPolicy!: string;
RestartPolicy!: RestartPolicy['Name'];
Hosts!: string[];
@ -58,14 +59,14 @@ export class TemplateViewModel {
Volumes!: {
container: string;
readonly: boolean;
type: string;
type: 'bind' | 'auto';
bind: string | null;
}[];
Ports!: {
hostPort: string | undefined;
containerPort: string;
protocol: string;
protocol: 'tcp' | 'udp';
}[];
constructor(template: AppTemplate, version: string) {
@ -134,7 +135,7 @@ function templatePorts(data: AppTemplate) {
hostAndContainerPort.length > 1
? hostAndContainerPort[1]
: hostAndContainerPort[0],
protocol: portAndProtocol[1],
protocol: portAndProtocol[1] as 'tcp' | 'udp',
};
}) || []
);
@ -145,7 +146,7 @@ function templateVolumes(data: AppTemplate) {
data.volumes?.map((v) => ({
container: v.container,
readonly: v.readonly || false,
type: v.bind ? 'bind' : 'auto',
type: (v.bind ? 'bind' : 'auto') as 'bind' | 'auto',
bind: v.bind ? v.bind : null,
})) || []
);

View file

@ -0,0 +1,40 @@
import { Rocket } from 'lucide-react';
import { PropsWithChildren } from 'react';
import { FallbackImage } from '@@/FallbackImage';
import { Icon } from '@@/Icon';
import { Widget } from '@@/Widget';
import { TemplateNote } from './TemplateNote';
export function DeployWidget({
logo,
note,
title,
children,
}: PropsWithChildren<{
logo?: string;
note?: string;
title: string;
}>) {
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title
icon={
<FallbackImage src={logo} fallbackIcon={<Icon icon={Rocket} />} />
}
title={title}
/>
<Widget.Body>
<div className="form-horizontal">
<TemplateNote note={note} />
{children}
</div>
</Widget.Body>
</Widget>
</div>
</div>
);
}

View file

@ -1,16 +1,18 @@
import sanitize from 'sanitize-html';
export function TemplateNote({ note }: { note: string | undefined }) {
import { FormSection } from '@@/form-components/FormSection';
export function TemplateNote({ note }: { note?: string }) {
if (!note) {
return null;
}
return (
<div>
<div className="col-sm-12 form-section-title"> Information </div>
<FormSection title="Information">
<div className="form-group">
<div className="col-sm-12">
<div
className="template-note"
className="text-xs"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: sanitize(note),
@ -18,6 +20,6 @@ export function TemplateNote({ note }: { note: string | undefined }) {
/>
</div>
</div>
</div>
</FormSection>
);
}

View file

@ -17,9 +17,11 @@ import { InnerForm } from './InnerForm';
export function CreateForm({
environmentId,
viewType,
defaultType,
}: {
environmentId?: EnvironmentId;
viewType: 'kube' | 'docker' | 'edge';
defaultType: StackType;
}) {
const isEdge = !environmentId;
const router = useRouter();
@ -28,8 +30,7 @@ export function CreateForm({
const buildMethods = useBuildMethods();
const initialValues = useInitialValues({
defaultType:
viewType === 'kube' ? StackType.Kubernetes : StackType.DockerCompose,
defaultType,
isEdge,
buildMethods: buildMethods.map((method) => method.value),
});

View file

@ -1,15 +1,19 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { StackType } from '@/react/common/stacks/types';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { useViewType } from '../useViewType';
import { TemplateViewType, useViewType } from '../useViewType';
import { CreateForm } from './CreateForm';
export function CreateView() {
const viewType = useViewType();
const environmentId = useEnvironmentId(false);
const isSwarm = useIsSwarm(environmentId, { enabled: viewType === 'docker' });
const defaultType = getDefaultType(viewType, isSwarm);
return (
<div>
@ -25,7 +29,11 @@ export function CreateView() {
<div className="col-sm-12">
<Widget>
<Widget.Body>
<CreateForm viewType={viewType} environmentId={environmentId} />
<CreateForm
viewType={viewType}
environmentId={environmentId}
defaultType={defaultType}
/>
</Widget.Body>
</Widget>
</div>
@ -33,3 +41,17 @@ export function CreateView() {
</div>
);
}
function getDefaultType(
viewType: TemplateViewType,
isSwarm: boolean
): StackType {
switch (viewType) {
case 'docker':
return isSwarm ? StackType.DockerSwarm : StackType.DockerCompose;
case 'kube':
return StackType.Kubernetes;
default:
return StackType.DockerCompose;
}
}

View file

@ -30,7 +30,7 @@ export function useCustomTemplates<T = Array<CustomTemplate>>({
});
}
async function getCustomTemplates({ type, edge = false }: Params = {}) {
async function getCustomTemplates({ type, edge }: Params = {}) {
try {
const { data } = await axios.get<CustomTemplate[]>(buildUrl(), {
params: {

View file

@ -38,10 +38,9 @@ export function DockerSidebar({ environmentId, environment }: Props) {
isEnvironmentAdmin ||
environment.SecuritySettings.allowStackManagementForRegularUsers;
const envInfoQuery = useInfo(
environmentId,
(info) => !!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable
);
const envInfoQuery = useInfo(environmentId, {
select: (info) => !!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable,
});
const apiVersion = useApiVersion(environmentId);