fix(container): fix various creating container issues EE-6287 (#10595)

* fix(container): show placeholder for image field EE-6287

* fix(container): correct query params for search button field EE-6287

* fix(container): use btoa to encode registry credential EE-6287

* fix(container): allow creating non-existing option EE-6287

* fix(ui/forms): typeahead component

* fix(container): select the default registry EE-6287

* fix(container): always enable deploy button when always pull is off EE-6287

* fix(container): reset command fields outside current event to avoid validation on broken values EE-6287

* fix(container): query registry with endpoint ID param EE-6287

---------

Co-authored-by: Chaim Lev-Ari <chaim.levi-ari@portainer.io>
pull/10642/head
cmeng 2023-11-16 08:50:23 +13:00 committed by GitHub
parent e43d076269
commit d089dfbca0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 190 additions and 137 deletions

View File

@ -1,5 +1,5 @@
import _ from 'lodash-es';
import { hideShaSum, joinCommand, nodeStatusBadge, taskStatusBadge, trimSHA } from './utils';
import { hideShaSum, joinCommand, nodeStatusBadge, taskStatusBadge, trimSHA, trimVersionTag } from './utils';
function includeString(text, values) {
return values.some(function (val) {
@ -184,20 +184,7 @@ angular
})
.filter('trimversiontag', function () {
'use strict';
return function trimversiontag(fullName) {
if (!fullName) {
return fullName;
}
var versionIdx = fullName.lastIndexOf(':');
if (versionIdx < 0) {
return fullName;
}
var hostIdx = fullName.indexOf('/');
if (hostIdx > versionIdx) {
return fullName;
}
return fullName.substring(0, versionIdx);
};
return trimVersionTag;
})
.filter('unique', function () {
return _.uniqBy;

View File

@ -1,6 +1,24 @@
import { NodeStatus, TaskState } from 'docker-types/generated/1.41';
import _ from 'lodash';
export function trimVersionTag(fullName: string) {
if (!fullName) {
return fullName;
}
const versionIdx = fullName.lastIndexOf(':');
if (versionIdx < 0) {
return fullName;
}
const hostIdx = fullName.indexOf('/');
if (hostIdx > versionIdx) {
return fullName;
}
return fullName.substring(0, versionIdx);
}
export function trimSHA(imageName: string) {
if (!imageName) {
return '';

View File

@ -30,7 +30,7 @@ export function AdvancedForm({
onChange={(e) => {
const { value } = e.target;
setFieldValue('image', value);
onChangeImage?.(value);
setTimeout(() => onChangeImage?.(value), 0);
}}
placeholder="e.g. registry:port/my-image:my-tag"
required

View File

@ -1,52 +1,39 @@
import { useMemo } from 'react';
import { AutomationTestingProps } from '@/types';
import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect';
import { Option } from '@@/form-components/PortainerSelect';
import { Select } from '@@/form-components/ReactSelect';
export function InputSearch({
value,
onChange,
options,
placeholder,
'data-cy': dataCy,
inputId,
}: {
value: string;
onChange: (value: string) => void;
options: Option<string>[];
placeholder?: string;
inputId?: string;
inputId: string;
} & AutomationTestingProps) {
const selectValue = options.find((option) => option.value === value) || {
value: '',
label: value,
};
const searchResults = useMemo(() => {
if (!value) {
return [];
}
return options.filter((option) =>
option.value.toLowerCase().includes(value.toLowerCase())
);
}, [options, value]);
return (
<Select
options={options}
value={selectValue}
onChange={(option) => option && onChange(option.value)}
<AutocompleteSelect
searchResults={searchResults}
value={value}
onChange={onChange}
placeholder={placeholder}
data-cy={dataCy}
inputId={inputId}
onInputChange={(value, actionMeta) => {
if (
actionMeta.action !== 'input-change' &&
actionMeta.action !== 'set-value'
) {
return;
}
onChange(value);
}}
openMenuOnClick={false}
openMenuOnFocus={false}
components={{ DropdownIndicator: () => null }}
onBlur={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
);
}

View File

@ -1,6 +1,7 @@
import { FormikErrors } from 'formik';
import _ from 'lodash';
import { useMemo } from 'react';
import { trimSHA, trimVersionTag } from 'Docker/filters/utils';
import DockerIcon from '@/assets/ico/vendor/docker.svg?c';
import { useImages } from '@/react/docker/proxy/queries/images/useImages';
@ -83,7 +84,9 @@ export function SimpleForm({
title="Search image on Docker Hub"
color="default"
props={{
href: 'https://hub.docker.com/search?type=image&q={ $ctrl.model.Image | trimshasum | trimversiontag }',
href: `https://hub.docker.com/search?type=image&q=${trimVersionTag(
trimSHA(values.image)
)}`,
target: '_blank',
rel: 'noreferrer',
}}
@ -140,6 +143,14 @@ function RegistrySelector({
label: registry.Name,
value: registry.Id,
})),
onSuccess: (options) => {
if (options && options.length) {
const idx = options.findIndex((v) => v.value === value);
if (idx === -1) {
onChange(options[0].value);
}
}
},
});
return (
@ -164,7 +175,7 @@ function ImageField({
onChange: (value: string) => void;
registry?: Registry;
autoComplete?: boolean;
inputId?: string;
inputId: string;
}) {
return autoComplete ? (
<ImageFieldAutoComplete
@ -191,7 +202,7 @@ function ImageFieldAutoComplete({
value: string;
onChange: (value: string) => void;
registry?: Registry;
inputId?: string;
inputId: string;
}) {
const environmentId = useEnvironmentId();

View File

@ -2,10 +2,10 @@ import { bool, number, object, SchemaOf, string } from 'yup';
import { Values } from './types';
export function validation(rateLimitExceeded: boolean): SchemaOf<Values> {
export function validation(): SchemaOf<Values> {
return object({
image: string().required('Image is required'),
registryId: number().default(0),
useRegistry: bool().default(false),
}).test('rate-limits', 'Rate limit exceeded', () => !rateLimitExceeded);
});
}

View File

@ -0,0 +1,86 @@
import '@reach/combobox/styles.css';
import { useState, ChangeEvent } from 'react';
import {
Combobox,
ComboboxInput,
ComboboxList,
ComboboxOption,
ComboboxPopover,
} from '@reach/combobox';
import clsx from 'clsx';
import { useDebounce } from '@/react/hooks/useDebounce';
import { Option } from '@@/form-components/PortainerSelect';
import styles from './AutocompleteSelect.module.css';
export function AutocompleteSelect({
value,
onChange,
placeholder,
searchResults,
readOnly,
inputId,
}: {
value: string;
/**
* onChange is called whenever the input is changed or an option is selected
*
* when the input is changed, the call is debounced
*/
onChange(value: string): void;
placeholder?: string;
searchResults?: Option<string>[];
readOnly?: boolean;
inputId: string;
}) {
const [searchTerm, setSearchTerm] = useDebounce(value, onChange);
const [selected, setSelected] = useState(false);
return (
<Combobox
className={styles.root}
aria-label="compose"
onSelect={onSelect}
data-cy="component-gitComposeInput"
>
<ComboboxInput
value={searchTerm}
className="form-control"
onChange={handleChange}
placeholder={placeholder}
readOnly={readOnly}
id={inputId}
/>
{!selected && searchResults && searchResults.length > 0 && (
<ComboboxPopover>
<ComboboxList>
{searchResults.map((option: Option<string>) => (
<ComboboxOption
key={option.value}
value={option.value}
className={clsx(
`[&[aria-selected="true"]]:th-highcontrast:!bg-black [&[aria-selected="true"]]:th-dark:!bg-black`,
`hover:th-highcontrast:!bg-black hover:th-dark:!bg-black`,
'th-highcontrast:bg-gray-10 th-dark:bg-gray-10 '
)}
/>
))}
</ComboboxList>
</ComboboxPopover>
)}
</Combobox>
);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setSearchTerm(e.target.value);
setSelected(false);
}
function onSelect(value: string) {
onChange(value);
setSelected(true);
}
}

View File

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

View File

@ -29,14 +29,18 @@ export function validation(
name: string()
.default('')
.test('not-duplicate-portainer', () => !isDuplicatingPortainer),
alwaysPull: boolean().default(true),
alwaysPull: boolean()
.default(true)
.test('rate-limits', 'Rate limit exceeded', (alwaysPull: boolean) =>
alwaysPull ? !isDockerhubRateLimited : true
),
accessControl: accessControlSchema(isAdmin),
autoRemove: boolean().default(false),
enableWebhook: boolean().default(false),
nodeName: string().default(''),
ports: portsSchema(),
publishAllPorts: boolean().default(false),
image: imageConfigValidation(isDockerhubRateLimited).test(
image: imageConfigValidation().test(
'duplicate-must-have-registry',
'Duplicate is only possible when registry is selected',
(value) => !isDuplicating || typeof value.registryId !== 'undefined'

View File

@ -10,6 +10,5 @@ export function encodeRegistryCredentials(registryId: Registry['Id']) {
registryId,
};
const buf = Buffer.from(JSON.stringify(credentials));
return buf.toString('base64url');
return window.btoa(JSON.stringify(credentials));
}

View File

@ -276,7 +276,7 @@ function InnerForm({
errors={errors.authentication}
/>
{isBE && <RelativePathFieldset value={values.relativePath} readonly />}
{isBE && <RelativePathFieldset value={values.relativePath} isEditing />}
<EnvironmentVariablesPanel
onChange={(value) => setFieldValue('envVars', value)}

View File

@ -63,6 +63,7 @@ export function ComposePathField({
onChange={onChange}
placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'}
model={model}
inputId="stack_repository_path"
/>
) : (
<Input
@ -71,6 +72,7 @@ export function ComposePathField({
updateInputValue(e.target.value);
}}
placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'}
id="stack_repository_path"
/>
)}
</FormControl>

View File

@ -1,22 +1,10 @@
import { ChangeEvent } from 'react';
import {
Combobox,
ComboboxInput,
ComboboxList,
ComboboxOption,
ComboboxPopover,
} from '@reach/combobox';
import '@reach/combobox/styles.css';
import clsx from 'clsx';
import { useSearch } from '@/react/portainer/gitops/queries/useSearch';
import { useDebounce } from '@/react/hooks/useDebounce';
import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect';
import { getAuthentication } from '../utils';
import { GitFormModel } from '../types';
import styles from './PathSelector.module.css';
export function PathSelector({
value,
onChange,
@ -24,6 +12,7 @@ export function PathSelector({
model,
dirOnly,
readOnly,
inputId,
}: {
value: string;
onChange(value: string): void;
@ -31,62 +20,33 @@ export function PathSelector({
model: GitFormModel;
dirOnly?: boolean;
readOnly?: boolean;
inputId: string;
}) {
const [searchTerm, setSearchTerm] = useDebounce(value, onChange);
const creds = getAuthentication(model);
const payload = {
repository: model.RepositoryURL,
keyword: searchTerm,
keyword: value,
reference: model.RepositoryReferenceName,
tlsSkipVerify: model.TLSSkipVerify,
dirOnly,
...creds,
};
const enabled = Boolean(
model.RepositoryURL && model.RepositoryURLValid && searchTerm
model.RepositoryURL && model.RepositoryURLValid && value
);
const { data: searchResults } = useSearch(payload, enabled);
return (
<Combobox
className={styles.root}
aria-label="compose"
onSelect={onSelect}
data-cy="component-gitComposeInput"
>
<ComboboxInput
value={searchTerm}
className="form-control"
onChange={handleChange}
placeholder={placeholder}
readOnly={readOnly}
/>
{searchResults && searchResults.length > 0 && (
<ComboboxPopover>
<ComboboxList>
{searchResults.map((result: string, index: number) => (
<ComboboxOption
key={index}
value={result}
className={clsx(
`[&[aria-selected="true"]]:th-highcontrast:!bg-black [&[aria-selected="true"]]:th-dark:!bg-black`,
`hover:th-highcontrast:!bg-black hover:th-dark:!bg-black`,
'th-highcontrast:bg-gray-10 th-dark:bg-gray-10 '
)}
/>
))}
</ComboboxList>
</ComboboxPopover>
)}
</Combobox>
<AutocompleteSelect
searchResults={searchResults?.map((result) => ({
value: result,
label: result,
}))}
value={value}
onChange={onChange}
placeholder={placeholder}
readOnly={readOnly}
inputId={inputId}
/>
);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setSearchTerm(e.target.value);
}
function onSelect(value: string) {
onChange(value);
}
}

View File

@ -16,14 +16,14 @@ interface Props {
value: RelativePathModel;
gitModel?: GitFormModel;
onChange?: (value: Partial<RelativePathModel>) => void;
readonly?: boolean;
isEditing?: boolean;
}
export function RelativePathFieldset({
value,
gitModel,
onChange,
readonly,
isEditing,
}: Props) {
const innerOnChange = useCallback(
(value: Partial<RelativePathModel>) => onChange && onChange(value),
@ -41,7 +41,7 @@ export function RelativePathFieldset({
label="Enable relative path volumes"
labelClass="col-sm-3 col-lg-2"
tooltip="Enabling this means you can specify relative path volumes in your Compose files, with Portainer pulling the content from your git repository to the environment the stack is deployed to."
disabled={readonly}
disabled={isEditing}
checked={value.SupportRelativePath}
onChange={(value) => innerOnChange({ SupportRelativePath: value })}
/>
@ -68,7 +68,7 @@ export function RelativePathFieldset({
<Input
name="FilesystemPath"
placeholder="/mnt"
disabled={readonly}
disabled={isEditing}
value={value.FilesystemPath}
onChange={(e) =>
innerOnChange({ FilesystemPath: e.target.value })
@ -94,7 +94,7 @@ export function RelativePathFieldset({
label="GitOps Edge configurations"
labelClass="col-sm-3 col-lg-2"
tooltip="By enabling the GitOps Edge Configurations feature, you gain the ability to define relative path volumes in your configuration files. Portainer will then automatically fetch the content from your git repository by matching the folder name or file name with the Portainer Edge ID, and apply it to the environment where the stack is deployed"
disabled={readonly}
disabled={isEditing}
checked={!!value.SupportPerDeviceConfigs}
onChange={(value) =>
innerOnChange({ SupportPerDeviceConfigs: value })
@ -120,6 +120,7 @@ export function RelativePathFieldset({
<FormControl
label="Directory"
errors={errors.PerDeviceConfigsPath}
inputId="per_device_configs_path_input"
>
<PathSelector
value={value.PerDeviceConfigsPath || ''}
@ -128,8 +129,9 @@ export function RelativePathFieldset({
}
placeholder="config"
model={gitModel || dummyGitForm}
readOnly={readonly}
readOnly={isEditing}
dirOnly
inputId="per_device_configs_path_input"
/>
</FormControl>
</div>
@ -174,7 +176,7 @@ export function RelativePathFieldset({
value: 'dir',
},
]}
disabled={readonly}
disabled={isEditing}
/>
</FormControl>
</div>
@ -205,7 +207,7 @@ export function RelativePathFieldset({
value: 'dir',
},
]}
disabled={readonly}
disabled={isEditing}
/>
</FormControl>
</div>

View File

@ -3,29 +3,18 @@ import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
export const ngModule = angular
.module('portainer.app.react.gitops', [])
.component(
'pathSelector',
r2a(withUIRouter(withReactQuery(PathSelector)), [
'value',
'onChange',
'placeholder',
'model',
'dirOnly',
'readOnly',
])
)
.component(
'relativePathFieldset',
r2a(withUIRouter(withReactQuery(RelativePathFieldset)), [
'value',
'gitModel',
'onChange',
'readonly',
'isEditing',
])
);

View File

@ -1,6 +1,7 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Registry } from '../types/registry';
@ -8,20 +9,26 @@ import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export function useRegistry(registryId?: Registry['Id']) {
const environmentId = useEnvironmentId();
return useQuery(
registryId ? queryKeys.item(registryId) : [],
() => (registryId ? getRegistry(registryId) : undefined),
() => (registryId ? getRegistry(registryId, environmentId) : undefined),
{
enabled: !!registryId,
}
);
}
async function getRegistry(registryId: Registry['Id']) {
async function getRegistry(registryId: Registry['Id'], environmentId: number) {
try {
const { data } = await axios.get<Registry>(buildUrl(registryId));
const { data } = await axios.get<Registry>(buildUrl(registryId), {
params: {
endpointId: environmentId,
},
});
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Unable to retrieve registry');
throw parseAxiosError(err as Error, 'XXXUnable to retrieve registry');
}
}