mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 13:55:21 +02:00
refactor(gitops): migrate git form to react [EE-4849] (#8268)
This commit is contained in:
parent
afe6cd6df0
commit
273a3f9a10
130 changed files with 3194 additions and 1190 deletions
56
app/react/portainer/gitops/AdditionalFilesField.tsx
Normal file
56
app/react/portainer/gitops/AdditionalFilesField.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { InputList, ItemProps } from '@@/form-components/InputList';
|
||||
import { useCaretPosition } from '@@/form-components/useCaretPosition';
|
||||
|
||||
interface Props {
|
||||
value: Array<string>;
|
||||
onChange: (value: Array<string>) => void;
|
||||
errors?: FormikErrors<string>[] | string | string[];
|
||||
}
|
||||
|
||||
export function AdditionalFileField({ onChange, value, errors }: Props) {
|
||||
return (
|
||||
<InputList
|
||||
errors={errors}
|
||||
label="Additional paths"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
addLabel="Add file"
|
||||
item={Item}
|
||||
itemBuilder={() => ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
item,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
readOnly,
|
||||
}: ItemProps<string>) {
|
||||
const { ref, updateCaret } = useCaretPosition();
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup size="small" className="col-sm-5">
|
||||
<InputGroup.Addon>path</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
mRef={ref}
|
||||
required
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
updateCaret();
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
{error && <FormError>{error}</FormError>}
|
||||
</>
|
||||
);
|
||||
}
|
181
app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx
Normal file
181
app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx
Normal file
|
@ -0,0 +1,181 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { boolean, number, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { GitAuthModel } from '@/react/portainer/gitops/types';
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
import { GitCredential } from '@/portainer/views/account/git-credential/types';
|
||||
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
|
||||
import { CredentialSelector } from './CredentialSelector';
|
||||
import { NewCredentialForm } from './NewCredentialForm';
|
||||
|
||||
interface Props {
|
||||
value: GitAuthModel;
|
||||
onChange: (value: Partial<GitAuthModel>) => void;
|
||||
isExplanationVisible?: boolean;
|
||||
errors?: FormikErrors<GitAuthModel>;
|
||||
}
|
||||
|
||||
export function AuthFieldset({
|
||||
value,
|
||||
onChange,
|
||||
isExplanationVisible,
|
||||
errors,
|
||||
}: Props) {
|
||||
const [username, setUsername] = useDebounce(
|
||||
value.RepositoryUsername || '',
|
||||
(username) => handleChange({ RepositoryUsername: username })
|
||||
);
|
||||
const [password, setPassword] = useDebounce(
|
||||
value.RepositoryPassword || '',
|
||||
(password) => handleChange({ RepositoryPassword: password })
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Authentication"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
name="authentication"
|
||||
checked={value.RepositoryAuthentication}
|
||||
onChange={(value) =>
|
||||
handleChange({ RepositoryAuthentication: value })
|
||||
}
|
||||
data-cy="component-gitAuthToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value.RepositoryAuthentication && (
|
||||
<>
|
||||
{isExplanationVisible && (
|
||||
<TextTip color="orange">
|
||||
Enabling authentication will store the credentials and it is
|
||||
advisable to use a git service account
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
{isBE && (
|
||||
<CredentialSelector
|
||||
onChange={handleChangeGitCredential}
|
||||
value={value.RepositoryGitCredentialID}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<FormControl label="Username" errors={errors?.RepositoryUsername}>
|
||||
<Input
|
||||
value={username}
|
||||
name="repository_username"
|
||||
placeholder={
|
||||
value.RepositoryGitCredentialID ? '' : 'git username'
|
||||
}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
data-cy="component-gitUsernameInput"
|
||||
readOnly={!!value.RepositoryGitCredentialID}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group !mb-0">
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label="Personal Access Token"
|
||||
tooltip="Provide a personal access token or password"
|
||||
errors={errors?.RepositoryPassword}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
name="repository_password"
|
||||
placeholder="*******"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
data-cy="component-gitPasswordInput"
|
||||
readOnly={!!value.RepositoryGitCredentialID}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
{!value.RepositoryGitCredentialID &&
|
||||
value.RepositoryPassword &&
|
||||
isBE && (
|
||||
<NewCredentialForm
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChangeGitCredential(gitCredential?: GitCredential | null) {
|
||||
handleChange(
|
||||
gitCredential
|
||||
? {
|
||||
RepositoryGitCredentialID: gitCredential.id,
|
||||
RepositoryUsername: gitCredential?.username,
|
||||
RepositoryPassword: '',
|
||||
SaveCredential: false,
|
||||
NewCredentialName: '',
|
||||
}
|
||||
: {
|
||||
RepositoryGitCredentialID: 0,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleChange(partialValue: Partial<GitAuthModel>) {
|
||||
onChange(partialValue);
|
||||
}
|
||||
}
|
||||
|
||||
export function gitAuthValidation(
|
||||
gitCredentials: Array<GitCredential>
|
||||
): SchemaOf<GitAuthModel> {
|
||||
return object({
|
||||
RepositoryAuthentication: boolean().default(false),
|
||||
RepositoryGitCredentialID: number().default(0),
|
||||
RepositoryUsername: string()
|
||||
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
|
||||
is: (auth: boolean, id: number) => auth && !id,
|
||||
then: string().required('Username is required'),
|
||||
})
|
||||
.default(''),
|
||||
RepositoryPassword: string()
|
||||
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
|
||||
is: (auth: boolean, id: number) => auth && !id,
|
||||
then: string().required('Password is required'),
|
||||
})
|
||||
.default(''),
|
||||
SaveCredential: boolean().default(false),
|
||||
NewCredentialName: string()
|
||||
.default('')
|
||||
.when(['RepositoryAuthentication', 'SaveCredential'], {
|
||||
is: true,
|
||||
then: string()
|
||||
.required('Name is required')
|
||||
.test(
|
||||
'is-unique',
|
||||
'This name is already been used, please try another one',
|
||||
(name) => !!name && !gitCredentials.find((x) => x.name === name)
|
||||
)
|
||||
.matches(
|
||||
/^[-_a-z0-9]+$/,
|
||||
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import { useGitCredentials } from '@/portainer/views/account/git-credential/gitCredential.service';
|
||||
import { GitCredential } from '@/portainer/views/account/git-credential/types';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
export function CredentialSelector({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
value?: number;
|
||||
onChange(gitCredential?: GitCredential | null): void;
|
||||
error?: string;
|
||||
}) {
|
||||
const { user } = useUser();
|
||||
|
||||
const gitCredentialsQuery = useGitCredentials(user.Id);
|
||||
|
||||
const gitCredentials = gitCredentialsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label="Git Credentials"
|
||||
inputId="git-creds-selector"
|
||||
errors={error}
|
||||
>
|
||||
<Select
|
||||
placeholder="select git credential or fill in below"
|
||||
value={gitCredentials.find(
|
||||
(gitCredential) => gitCredential.id === value
|
||||
)}
|
||||
options={gitCredentials}
|
||||
getOptionLabel={(gitCredential) => gitCredential.name}
|
||||
getOptionValue={(gitCredential) => gitCredential.id.toString()}
|
||||
onChange={onChange}
|
||||
isClearable
|
||||
noOptionsMessage={() => 'no saved credentials'}
|
||||
inputId="git-creds-selector"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { GitAuthModel } from '../types';
|
||||
|
||||
export function NewCredentialForm({
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
value: GitAuthModel;
|
||||
onChange: (value: Partial<GitAuthModel>) => void;
|
||||
errors?: FormikErrors<GitAuthModel>;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<FormControl label="">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="repository-save-credential"
|
||||
label="save credential"
|
||||
checked={value.SaveCredential}
|
||||
className="[&+label]:mb-0"
|
||||
onChange={(e) => onChange({ SaveCredential: e.target.checked })}
|
||||
/>
|
||||
<Input
|
||||
value={value.NewCredentialName}
|
||||
name="new_credential_name"
|
||||
placeholder="credential name"
|
||||
className="ml-4 w-48"
|
||||
onChange={(e) => onChange({ NewCredentialName: e.target.value })}
|
||||
disabled={!value.SaveCredential}
|
||||
/>
|
||||
{errors?.NewCredentialName && (
|
||||
<div className="small text-danger">
|
||||
{errors.NewCredentialName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.SaveCredential && (
|
||||
<TextTip color="blue" className="!mb-0">
|
||||
This git credential can be managed through your account page
|
||||
</TextTip>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
1
app/react/portainer/gitops/AuthFieldset/index.ts
Normal file
1
app/react/portainer/gitops/AuthFieldset/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { AuthFieldset, gitAuthValidation } from './AuthFieldset';
|
24
app/react/portainer/gitops/AuthFieldset/utils.ts
Normal file
24
app/react/portainer/gitops/AuthFieldset/utils.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { GitAuthenticationResponse, GitAuthModel } from '../types';
|
||||
|
||||
export function parseAuthResponse(
|
||||
auth?: GitAuthenticationResponse
|
||||
): GitAuthModel {
|
||||
if (!auth) {
|
||||
return {
|
||||
RepositoryAuthentication: false,
|
||||
NewCredentialName: '',
|
||||
RepositoryGitCredentialID: 0,
|
||||
RepositoryPassword: '',
|
||||
RepositoryUsername: '',
|
||||
SaveCredential: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
RepositoryAuthentication: true,
|
||||
NewCredentialName: '',
|
||||
RepositoryGitCredentialID: auth.GitCredentialID,
|
||||
RepositoryPassword: '',
|
||||
RepositoryUsername: auth.Username,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
|
||||
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { AutoUpdateSettings } from './AutoUpdateSettings';
|
||||
|
||||
export function AutoUpdateFieldset({
|
||||
value,
|
||||
onChange,
|
||||
environmentType,
|
||||
isForcePullVisible = true,
|
||||
errors,
|
||||
baseWebhookUrl,
|
||||
}: {
|
||||
value: AutoUpdateModel;
|
||||
onChange: (value: AutoUpdateModel) => void;
|
||||
environmentType?: 'DOCKER' | 'KUBERNETES';
|
||||
isForcePullVisible?: boolean;
|
||||
errors?: FormikErrors<AutoUpdateModel>;
|
||||
baseWebhookUrl: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
When enabled, at each polling interval or webhook invocation, if the
|
||||
git repo differs from what was stored locally on the last git pull,
|
||||
the changes are deployed.
|
||||
</TextTip>
|
||||
<SwitchField
|
||||
name="autoUpdate"
|
||||
checked={!!value.RepositoryAutomaticUpdates}
|
||||
label="Automatic updates"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
onChange={(value) =>
|
||||
handleChange({ RepositoryAutomaticUpdates: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value.RepositoryAutomaticUpdates && (
|
||||
<AutoUpdateSettings
|
||||
baseWebhookUrl={baseWebhookUrl}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
environmentType={environmentType}
|
||||
showForcePullImage={isForcePullVisible}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChange(newValues: Partial<AutoUpdateModel>) {
|
||||
onChange({ ...value, ...newValues });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { type AutoUpdateModel } from '@/react/portainer/gitops/types';
|
||||
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ForceDeploymentSwitch } from './ForceDeploymentSwitch';
|
||||
import { IntervalField } from './IntervalField';
|
||||
import { WebhookSettings } from './WebhookSettings';
|
||||
|
||||
export function AutoUpdateSettings({
|
||||
value,
|
||||
onChange,
|
||||
environmentType,
|
||||
showForcePullImage,
|
||||
errors,
|
||||
baseWebhookUrl,
|
||||
}: {
|
||||
value: AutoUpdateModel;
|
||||
onChange: (value: Partial<AutoUpdateModel>) => void;
|
||||
environmentType?: 'DOCKER' | 'KUBERNETES';
|
||||
showForcePullImage: boolean;
|
||||
errors?: FormikErrors<AutoUpdateModel>;
|
||||
baseWebhookUrl: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TextTip color="orange">
|
||||
Any changes to this stack or application that have been made locally via
|
||||
Portainer or directly in the cluster will be overwritten by the git
|
||||
repository content, which may cause service interruption.
|
||||
</TextTip>
|
||||
|
||||
<FormControl label="Mechanism">
|
||||
<ButtonSelector
|
||||
size="small"
|
||||
options={[
|
||||
{ value: 'Interval', label: 'Polling' },
|
||||
{ value: 'Webhook', label: 'Webhook' },
|
||||
]}
|
||||
value={value.RepositoryMechanism || 'Interval'}
|
||||
onChange={(value) => onChange({ RepositoryMechanism: value })}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{value.RepositoryMechanism === 'Webhook' && (
|
||||
<WebhookSettings
|
||||
baseUrl={baseWebhookUrl}
|
||||
value={value.RepositoryWebhookId || ''}
|
||||
docsLink={
|
||||
environmentType === 'KUBERNETES'
|
||||
? 'https://docs.portainer.io/user/kubernetes/applications/webhooks'
|
||||
: 'https://docs.portainer.io/user/docker/stacks/webhooks'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{value.RepositoryMechanism === 'Interval' && (
|
||||
<IntervalField
|
||||
value={value.RepositoryFetchInterval || ''}
|
||||
onChange={(value) => onChange({ RepositoryFetchInterval: value })}
|
||||
errors={errors?.RepositoryFetchInterval}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showForcePullImage && (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
name="forcePullImage"
|
||||
featureId={FeatureId.STACK_PULL_IMAGE}
|
||||
checked={value.ForcePullImage || false}
|
||||
label="Re-pull image"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
tooltip="If enabled, then when redeploy is triggered via the webhook or polling, if there's a newer image with the tag that you've specified (e.g. changeable development builds), it's pulled and redeployed. If you haven't specified a tag, or have specified 'latest' as the tag, then the image with the tag 'latest' is pulled and redeployed."
|
||||
onChange={(value) => onChange({ ForcePullImage: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ForceDeploymentSwitch
|
||||
checked={value.RepositoryAutomaticUpdatesForce || false}
|
||||
onChange={(value) =>
|
||||
onChange({ RepositoryAutomaticUpdatesForce: value })
|
||||
}
|
||||
label={
|
||||
environmentType === 'KUBERNETES' ? 'Always apply manifest' : undefined
|
||||
}
|
||||
tooltip={
|
||||
environmentType === 'KUBERNETES' ? (
|
||||
<>
|
||||
<p>
|
||||
If enabled, then when redeploy is triggered via the webhook or
|
||||
polling, kubectl apply is always performed, even if Portainer
|
||||
detects no difference between the git repo and what was stored
|
||||
locally on last git pull.
|
||||
</p>
|
||||
<p>
|
||||
This is useful if you want your git repo to be the source of
|
||||
truth and are fine with changes made directly to resources in
|
||||
the cluster being overwritten.
|
||||
</p>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
export function ForceDeploymentSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
tooltip = '',
|
||||
label = 'Force redeployment',
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
tooltip?: ReactNode;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
name="forceUpdate"
|
||||
featureId={FeatureId.FORCE_REDEPLOYMENT}
|
||||
checked={checked}
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { string } from 'yup';
|
||||
import parse from 'parse-duration';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { useCaretPosition } from '@@/form-components/useCaretPosition';
|
||||
|
||||
export function IntervalField({
|
||||
onChange,
|
||||
value,
|
||||
errors,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
errors?: string;
|
||||
}) {
|
||||
const { ref, updateCaret } = useCaretPosition();
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
label="Fetch interval"
|
||||
inputId="repository_fetch_interval"
|
||||
tooltip="Specify how frequently polling occurs using syntax such as, 5m = 5 minutes, 24h = 24 hours, 6h40m = 6 hours and 40 minutes."
|
||||
required
|
||||
errors={errors}
|
||||
>
|
||||
<Input
|
||||
mRef={ref}
|
||||
id="repository_fetch_interval"
|
||||
name="repository_fetch_interval"
|
||||
placeholder="5m"
|
||||
required
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
updateCaret();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export function intervalValidation() {
|
||||
return (
|
||||
string()
|
||||
.required('This field is required.')
|
||||
// TODO: find a regex that validates time.Duration
|
||||
// .matches(
|
||||
// // validate golang time.Duration format
|
||||
// // https://cs.opensource.google/go/go/+/master:src/time/format.go;l=1590
|
||||
// /[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+/g,
|
||||
// 'Please enter a valid time interval.'
|
||||
// )
|
||||
.test(
|
||||
'minimumInterval',
|
||||
'Minimum interval is 1m',
|
||||
(value) => !!value && parse(value, 'minute') >= 1
|
||||
)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { truncateLeftRight } from '@/portainer/filters/filters';
|
||||
|
||||
import { CopyButton } from '@@/buttons';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
|
||||
export function WebhookSettings({
|
||||
value,
|
||||
baseUrl,
|
||||
docsLink,
|
||||
}: {
|
||||
docsLink: string;
|
||||
value: string;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const url = `${baseUrl}/${value}`;
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
label="Webhook"
|
||||
tooltip={
|
||||
<>
|
||||
See{' '}
|
||||
<a href={docsLink} target="_blank" rel="noreferrer">
|
||||
Portainer documentation on webhook usage
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted">{truncateLeftRight(url)}</span>
|
||||
<CopyButton copyText={url} color="light">
|
||||
Copy link
|
||||
</CopyButton>
|
||||
</div>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
1
app/react/portainer/gitops/AutoUpdateFieldset/index.ts
Normal file
1
app/react/portainer/gitops/AutoUpdateFieldset/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { AutoUpdateFieldset } from './AutoUpdateFieldset';
|
48
app/react/portainer/gitops/AutoUpdateFieldset/utils.ts
Normal file
48
app/react/portainer/gitops/AutoUpdateFieldset/utils.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { AutoUpdateResponse, AutoUpdateModel } from '../types';
|
||||
|
||||
export function parseAutoUpdateResponse(
|
||||
response?: AutoUpdateResponse
|
||||
): AutoUpdateModel {
|
||||
if (!response || (!response?.Interval && !response?.Webhook)) {
|
||||
return {
|
||||
RepositoryAutomaticUpdates: false,
|
||||
RepositoryAutomaticUpdatesForce: false,
|
||||
RepositoryMechanism: 'Interval',
|
||||
RepositoryFetchInterval: '5m',
|
||||
RepositoryWebhookId: uuid(),
|
||||
ForcePullImage: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
RepositoryAutomaticUpdates: true,
|
||||
RepositoryMechanism: response.Interval ? 'Interval' : 'Webhook',
|
||||
RepositoryFetchInterval: response.Interval || '',
|
||||
RepositoryWebhookId: response.Webhook || uuid(),
|
||||
RepositoryAutomaticUpdatesForce: response.ForceUpdate,
|
||||
ForcePullImage: response.ForcePullImage,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformAutoUpdateViewModel(
|
||||
viewModel?: AutoUpdateModel
|
||||
): AutoUpdateResponse | null {
|
||||
if (!viewModel || !viewModel.RepositoryAutomaticUpdates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
Interval:
|
||||
viewModel.RepositoryMechanism === 'Interval'
|
||||
? viewModel.RepositoryFetchInterval
|
||||
: '',
|
||||
Webhook:
|
||||
viewModel.RepositoryMechanism === 'Webhook'
|
||||
? viewModel.RepositoryWebhookId
|
||||
: '',
|
||||
ForceUpdate: viewModel.RepositoryAutomaticUpdatesForce,
|
||||
ForcePullImage: viewModel.ForcePullImage,
|
||||
};
|
||||
}
|
28
app/react/portainer/gitops/AutoUpdateFieldset/validation.ts
Normal file
28
app/react/portainer/gitops/AutoUpdateFieldset/validation.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { string, boolean, object, SchemaOf, mixed } from 'yup';
|
||||
|
||||
import { AutoUpdateMechanism, AutoUpdateModel } from '../types';
|
||||
|
||||
import { intervalValidation } from './IntervalField';
|
||||
|
||||
export function autoUpdateValidation(): SchemaOf<AutoUpdateModel> {
|
||||
return object({
|
||||
RepositoryAutomaticUpdates: boolean().default(false),
|
||||
RepositoryAutomaticUpdatesForce: boolean().default(false),
|
||||
RepositoryMechanism: mixed<AutoUpdateMechanism>()
|
||||
.oneOf(['Interval', 'Webhook'])
|
||||
.when('RepositoryAutomaticUpdates', {
|
||||
is: true,
|
||||
then: string().required(),
|
||||
})
|
||||
.default('Interval'),
|
||||
RepositoryFetchInterval: string()
|
||||
.default('')
|
||||
.when(['RepositoryAutomaticUpdates', 'RepositoryMechanism'], {
|
||||
is: (autoUpdates: boolean, mechanism: AutoUpdateMechanism) =>
|
||||
autoUpdates && mechanism === 'Interval',
|
||||
then: intervalValidation(),
|
||||
}),
|
||||
RepositoryWebhookId: string().default(''),
|
||||
ForcePullImage: boolean().default(false),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { useCaretPosition } from '@@/form-components/useCaretPosition';
|
||||
|
||||
import { GitFormModel } from '../types';
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
|
||||
import { PathSelector } from './PathSelector';
|
||||
|
||||
interface Props {
|
||||
errors?: string;
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
isCompose: boolean;
|
||||
model: GitFormModel;
|
||||
isDockerStandalone: boolean;
|
||||
}
|
||||
|
||||
export function ComposePathField({
|
||||
value,
|
||||
onChange,
|
||||
isCompose,
|
||||
model,
|
||||
isDockerStandalone,
|
||||
errors,
|
||||
}: Props) {
|
||||
const { ref, updateCaret } = useCaretPosition();
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<span className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
<span>
|
||||
Indicate the path to the {isCompose ? 'Compose' : 'Manifest'} file
|
||||
from the root of your repository (requires a yaml, yml, json, or hcl
|
||||
file extension).
|
||||
</span>
|
||||
{isDockerStandalone && (
|
||||
<span className="ml-2">
|
||||
To enable rebuilding of an image if already present on Docker
|
||||
standalone environments, include
|
||||
<code>pull_policy: build</code> in your compose file as per{' '}
|
||||
<a href="https://docs.docker.com/compose/compose-file/#pull_policy">
|
||||
Docker documentation
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
)}
|
||||
</TextTip>
|
||||
</span>
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label={isCompose ? 'Compose path' : 'Manifest path'}
|
||||
inputId="stack_repository_path"
|
||||
required
|
||||
errors={errors}
|
||||
>
|
||||
{isBE ? (
|
||||
<PathSelector
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'}
|
||||
model={model}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
mRef={ref}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
updateCaret();
|
||||
}}
|
||||
placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
.root [data-reach-combobox-popover] {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-form-control-color);
|
||||
background-color: var(--bg-dropdown-menu-color);
|
||||
color: var(--text-form-control-color);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.root [data-reach-combobox-option]:hover {
|
||||
background-color: var(--bg-dropdown-hover);
|
||||
}
|
||||
|
||||
.root [data-suggested-value] {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.root [data-user-value] {
|
||||
font-weight: bold;
|
||||
}
|
87
app/react/portainer/gitops/ComposePathField/PathSelector.tsx
Normal file
87
app/react/portainer/gitops/ComposePathField/PathSelector.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxList,
|
||||
ComboboxOption,
|
||||
ComboboxPopover,
|
||||
} from '@reach/combobox';
|
||||
import '@reach/combobox/styles.css';
|
||||
import { ChangeEvent } from 'react';
|
||||
|
||||
import { useSearch } from '@/react/portainer/gitops/queries/useSearch';
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
|
||||
import { useCaretPosition } from '@@/form-components/useCaretPosition';
|
||||
|
||||
import { getAuthentication } from '../utils';
|
||||
import { GitFormModel } from '../types';
|
||||
|
||||
import styles from './PathSelector.module.css';
|
||||
|
||||
export function PathSelector({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
model,
|
||||
}: {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
placeholder: string;
|
||||
model: GitFormModel;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useDebounce('', () => {});
|
||||
|
||||
const creds = getAuthentication(model);
|
||||
const payload = {
|
||||
repository: model.RepositoryURL,
|
||||
keyword: searchTerm,
|
||||
reference: model.RepositoryReferenceName,
|
||||
...creds,
|
||||
};
|
||||
const enabled = Boolean(
|
||||
model.RepositoryURL && model.RepositoryURLValid && searchTerm
|
||||
);
|
||||
const { data: searchResult } = useSearch(payload, enabled);
|
||||
const { ref, updateCaret } = useCaretPosition();
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
className={styles.root}
|
||||
aria-label="compose"
|
||||
onSelect={onSelect}
|
||||
data-cy="component-gitComposeInput"
|
||||
>
|
||||
<ComboboxInput
|
||||
ref={ref}
|
||||
className="form-control"
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
{searchResult && searchResult.length > 0 && searchTerm !== '' && (
|
||||
<ComboboxPopover>
|
||||
<ComboboxList>
|
||||
{searchResult.map((result: string, index: number) => (
|
||||
<ComboboxOption
|
||||
key={index}
|
||||
value={result}
|
||||
className={`[&[aria-selected="true"]]:th-highcontrast:!bg-black [&[aria-selected="true"]]:th-dark:!bg-black`}
|
||||
/>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxPopover>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
setSearchTerm(e.target.value);
|
||||
onChange(e.target.value);
|
||||
updateCaret();
|
||||
}
|
||||
|
||||
function onSelect(value: string) {
|
||||
setSearchTerm('');
|
||||
onChange(value);
|
||||
}
|
||||
}
|
1
app/react/portainer/gitops/ComposePathField/index.ts
Normal file
1
app/react/portainer/gitops/ComposePathField/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ComposePathField } from './ComposePathField';
|
107
app/react/portainer/gitops/GitForm.stories.tsx
Normal file
107
app/react/portainer/gitops/GitForm.stories.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { GitCredential } from '@/portainer/views/account/git-credential/types';
|
||||
|
||||
import { GitForm, buildGitValidationSchema } from './GitForm';
|
||||
import { GitFormModel } from './types';
|
||||
|
||||
export default {
|
||||
component: GitForm,
|
||||
title: 'Forms/GitForm',
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.get<Array<GitCredential>, { userId: string }>(
|
||||
'/api/users/:userId/gitcredentials',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json<Array<GitCredential>>([
|
||||
{
|
||||
id: 1,
|
||||
name: 'credential-1',
|
||||
username: 'username-1',
|
||||
userId: parseInt(req.params.userId, 10),
|
||||
creationDate: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'credential-2',
|
||||
username: 'username-2',
|
||||
userId: parseInt(req.params.userId, 10),
|
||||
creationDate: 0,
|
||||
},
|
||||
])
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const WrappedComponent = withUserProvider(GitForm);
|
||||
|
||||
interface Args {
|
||||
isAdditionalFilesFieldVisible: boolean;
|
||||
isAuthExplanationVisible: boolean;
|
||||
isDockerStandalone: boolean;
|
||||
deployMethod: 'compose' | 'nomad' | 'manifest';
|
||||
isForcePullVisible: boolean;
|
||||
}
|
||||
|
||||
export function Primary({
|
||||
deployMethod,
|
||||
isAdditionalFilesFieldVisible,
|
||||
isAuthExplanationVisible,
|
||||
isDockerStandalone,
|
||||
isForcePullVisible,
|
||||
}: Args) {
|
||||
const initialValues: GitFormModel = {
|
||||
RepositoryURL: '',
|
||||
RepositoryURLValid: false,
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
AdditionalFiles: [],
|
||||
RepositoryReferenceName: '',
|
||||
ComposeFilePathInRepository: '',
|
||||
NewCredentialName: '',
|
||||
SaveCredential: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => buildGitValidationSchema([])}
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
{({ values, errors, setValues }) => (
|
||||
<Form className="form-horizontal">
|
||||
<WrappedComponent
|
||||
value={values}
|
||||
errors={errors}
|
||||
onChange={(value) => setValues({ ...values, ...value })}
|
||||
isAdditionalFilesFieldVisible={isAdditionalFilesFieldVisible}
|
||||
isAuthExplanationVisible={isAuthExplanationVisible}
|
||||
isDockerStandalone={isDockerStandalone}
|
||||
isForcePullVisible={isForcePullVisible}
|
||||
deployMethod={deployMethod}
|
||||
baseWebhookUrl="ws://localhost:9000"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
Primary.args = {
|
||||
isAdditionalFilesFieldVisible: true,
|
||||
isAuthExplanationVisible: true,
|
||||
isAutoUpdateVisible: true,
|
||||
isDockerStandalone: true,
|
||||
isForcePullVisible: true,
|
||||
deployMethod: 'compose',
|
||||
};
|
133
app/react/portainer/gitops/GitForm.tsx
Normal file
133
app/react/portainer/gitops/GitForm.tsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
import { array, boolean, object, SchemaOf, string } from 'yup';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { ComposePathField } from '@/react/portainer/gitops/ComposePathField';
|
||||
import { RefField } from '@/react/portainer/gitops/RefField';
|
||||
import { GitFormUrlField } from '@/react/portainer/gitops/GitFormUrlField';
|
||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { GitCredential } from '@/portainer/views/account/git-credential/types';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TimeWindowDisplay } from '@@/TimeWindowDisplay';
|
||||
import { validateForm } from '@@/form-components/validate-form';
|
||||
|
||||
import { AdditionalFileField } from './AdditionalFilesField';
|
||||
import { gitAuthValidation, AuthFieldset } from './AuthFieldset';
|
||||
import { AutoUpdateFieldset } from './AutoUpdateFieldset';
|
||||
import { autoUpdateValidation } from './AutoUpdateFieldset/validation';
|
||||
import { refFieldValidation } from './RefField/RefField';
|
||||
|
||||
interface Props {
|
||||
value: GitFormModel;
|
||||
onChange: (value: Partial<GitFormModel>) => void;
|
||||
deployMethod?: 'compose' | 'nomad' | 'manifest';
|
||||
isDockerStandalone?: boolean;
|
||||
isAdditionalFilesFieldVisible?: boolean;
|
||||
isForcePullVisible?: boolean;
|
||||
isAuthExplanationVisible?: boolean;
|
||||
errors: FormikErrors<GitFormModel>;
|
||||
baseWebhookUrl: string;
|
||||
}
|
||||
|
||||
export function GitForm({
|
||||
value,
|
||||
onChange,
|
||||
deployMethod = 'compose',
|
||||
isDockerStandalone = false,
|
||||
isAdditionalFilesFieldVisible,
|
||||
isForcePullVisible,
|
||||
isAuthExplanationVisible,
|
||||
errors = {},
|
||||
baseWebhookUrl,
|
||||
}: Props) {
|
||||
return (
|
||||
<FormSection title="Git repository">
|
||||
<AuthFieldset
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
isExplanationVisible={isAuthExplanationVisible}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
<GitFormUrlField
|
||||
value={value.RepositoryURL}
|
||||
onChange={(value) => handleChange({ RepositoryURL: value })}
|
||||
onChangeRepositoryValid={(value) =>
|
||||
handleChange({ RepositoryURLValid: value })
|
||||
}
|
||||
model={value}
|
||||
errors={errors.RepositoryURL}
|
||||
/>
|
||||
|
||||
<RefField
|
||||
value={value.RepositoryReferenceName || ''}
|
||||
onChange={(value) => handleChange({ RepositoryReferenceName: value })}
|
||||
model={value}
|
||||
error={errors.RepositoryReferenceName}
|
||||
isUrlValid={value.RepositoryURLValid}
|
||||
/>
|
||||
|
||||
<ComposePathField
|
||||
value={value.ComposeFilePathInRepository}
|
||||
onChange={(value) =>
|
||||
handleChange({ ComposeFilePathInRepository: value })
|
||||
}
|
||||
isCompose={deployMethod === 'compose'}
|
||||
model={value}
|
||||
isDockerStandalone={isDockerStandalone}
|
||||
errors={errors.ComposeFilePathInRepository}
|
||||
/>
|
||||
|
||||
{isAdditionalFilesFieldVisible && (
|
||||
<AdditionalFileField
|
||||
value={value.AdditionalFiles}
|
||||
onChange={(value) => handleChange({ AdditionalFiles: value })}
|
||||
errors={errors.AdditionalFiles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{value.AutoUpdate && (
|
||||
<AutoUpdateFieldset
|
||||
baseWebhookUrl={baseWebhookUrl}
|
||||
value={value.AutoUpdate}
|
||||
onChange={(value) => handleChange({ AutoUpdate: value })}
|
||||
isForcePullVisible={isForcePullVisible}
|
||||
errors={errors.AutoUpdate as FormikErrors<GitFormModel['AutoUpdate']>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TimeWindowDisplay />
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
function handleChange(partialValue: Partial<GitFormModel>) {
|
||||
onChange(partialValue);
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateGitForm(
|
||||
gitCredentials: Array<GitCredential>,
|
||||
formValues: GitFormModel
|
||||
) {
|
||||
return validateForm<GitFormModel>(
|
||||
() => buildGitValidationSchema(gitCredentials),
|
||||
formValues
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGitValidationSchema(
|
||||
gitCredentials: Array<GitCredential>
|
||||
): SchemaOf<GitFormModel> {
|
||||
return object({
|
||||
RepositoryURL: string()
|
||||
.url('Invalid Url')
|
||||
.required('Repository URL is required'),
|
||||
RepositoryReferenceName: refFieldValidation(),
|
||||
ComposeFilePathInRepository: string().required(
|
||||
'Compose file path is required'
|
||||
),
|
||||
AdditionalFiles: array(string().required()).default([]),
|
||||
RepositoryURLValid: boolean().default(false),
|
||||
AutoUpdate: autoUpdateValidation().nullable(),
|
||||
}).concat(gitAuthValidation(gitCredentials));
|
||||
}
|
122
app/react/portainer/gitops/GitFormUrlField.tsx
Normal file
122
app/react/portainer/gitops/GitFormUrlField.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
import { ChangeEvent, useState } from 'react';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { string, StringSchema } from 'yup';
|
||||
|
||||
import {
|
||||
checkRepo,
|
||||
useCheckRepo,
|
||||
} from '@/react/portainer/gitops/queries/useCheckRepo';
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
import { isPortainerError } from '@/portainer/error';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Button } from '@@/buttons';
|
||||
import { useCachedValidation } from '@@/form-components/useCachedTest';
|
||||
|
||||
import { GitFormModel } from './types';
|
||||
import { getAuthentication } from './utils';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
onChangeRepositoryValid(value: boolean): void;
|
||||
model: GitFormModel;
|
||||
errors?: string;
|
||||
}
|
||||
|
||||
export function GitFormUrlField({
|
||||
value,
|
||||
onChange,
|
||||
onChangeRepositoryValid,
|
||||
model,
|
||||
errors,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const creds = getAuthentication(model);
|
||||
const [force, setForce] = useState(false);
|
||||
const repoStatusQuery = useCheckRepo(value, creds, force, {
|
||||
onSettled(isValid) {
|
||||
onChangeRepositoryValid(!!isValid);
|
||||
setForce(false);
|
||||
},
|
||||
});
|
||||
|
||||
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
|
||||
|
||||
const errorMessage = isPortainerError(repoStatusQuery.error)
|
||||
? repoStatusQuery.error.message
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<span className="col-sm-12">
|
||||
<TextTip color="blue">You can use the URL of a git repository.</TextTip>
|
||||
</span>
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label="Repository URL"
|
||||
inputId="stack_repository_url"
|
||||
errors={errorMessage || errors}
|
||||
required
|
||||
>
|
||||
<span className="flex">
|
||||
<Input
|
||||
value={debouncedValue}
|
||||
type="text"
|
||||
name="repoUrlField"
|
||||
className="form-control"
|
||||
placeholder="https://github.com/portainer/portainer-compose"
|
||||
data-cy="component-gitUrlInput"
|
||||
required
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
size="medium"
|
||||
className="vertical-center"
|
||||
color="light"
|
||||
icon={RefreshCcw}
|
||||
title="refreshGitRepo"
|
||||
disabled={!model.RepositoryURLValid}
|
||||
/>
|
||||
</span>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
debouncedOnChange(e.target.value);
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
setForce(true);
|
||||
queryClient.invalidateQueries(['git_repo_refs', 'git_repo_search_results']);
|
||||
}
|
||||
}
|
||||
|
||||
// Todo: once git form is used only in react, it should be used for validation instead of L40-52
|
||||
export function useUrlValidation(force: boolean) {
|
||||
const existenceTest = useCachedValidation<string, GitFormModel>(
|
||||
(url, context) => {
|
||||
if (!url) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const model = context.parent as GitFormModel;
|
||||
|
||||
const creds = getAuthentication(model);
|
||||
return checkRepo(url, creds, force);
|
||||
}
|
||||
);
|
||||
|
||||
return (string() as StringSchema<string, GitFormModel>)
|
||||
.url('Invalid Url')
|
||||
.required('Repository URL is required')
|
||||
.test('repo-exists', 'Repository does not exist', existenceTest);
|
||||
}
|
32
app/react/portainer/gitops/InfoPanel.tsx
Normal file
32
app/react/portainer/gitops/InfoPanel.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
configFilePath: string;
|
||||
additionalFiles?: string[];
|
||||
className: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function InfoPanel({
|
||||
url,
|
||||
configFilePath,
|
||||
additionalFiles = [],
|
||||
className,
|
||||
type,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={clsx('form-group', className)}>
|
||||
<div className="col-sm-12">
|
||||
<p>
|
||||
This {type} was deployed from the git repository <code>{url}</code>.
|
||||
</p>
|
||||
<p>
|
||||
Update
|
||||
<code>{[configFilePath, ...additionalFiles].join(', ')}</code>
|
||||
in git and pull from here to update the {type}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
104
app/react/portainer/gitops/RefField/RefField.tsx
Normal file
104
app/react/portainer/gitops/RefField/RefField.tsx
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import { SchemaOf, string } from 'yup';
|
||||
|
||||
import { StackId } from '@/react/docker/stacks/types';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
|
||||
import { RefSelector } from './RefSelector';
|
||||
import { RefFieldModel } from './types';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
model: RefFieldModel;
|
||||
error?: string;
|
||||
isUrlValid: boolean;
|
||||
stackId?: StackId;
|
||||
}
|
||||
|
||||
export function RefField({
|
||||
value,
|
||||
onChange,
|
||||
model,
|
||||
error,
|
||||
isUrlValid,
|
||||
stackId,
|
||||
}: Props) {
|
||||
return isBE ? (
|
||||
<Wrapper
|
||||
errors={error}
|
||||
tip={
|
||||
<>
|
||||
Specify a reference of the repository using the following syntax:
|
||||
branches with <code>refs/heads/branch_name</code> or tags with{' '}
|
||||
<code>refs/tags/tag_name</code>.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<RefSelector
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
model={model}
|
||||
isUrlValid={isUrlValid}
|
||||
stackId={stackId}
|
||||
/>
|
||||
</Wrapper>
|
||||
) : (
|
||||
<Wrapper
|
||||
errors={error}
|
||||
tip={
|
||||
<>
|
||||
Specify a reference of the repository using the following syntax:
|
||||
branches with <code>refs/heads/branch_name</code> or tags with{' '}
|
||||
<code>refs/tags/tag_name</code>. If not specified, will use the
|
||||
default <code>HEAD</code> reference normally the <code>main</code>{' '}
|
||||
branch.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="refs/heads/main"
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
tip,
|
||||
children,
|
||||
errors,
|
||||
}: PropsWithChildren<{ tip: ReactNode; errors?: string }>) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<span className="col-sm-12">
|
||||
<TextTip color="blue">{tip}</TextTip>
|
||||
</span>
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label="Repository reference"
|
||||
inputId="stack_repository_reference_name"
|
||||
required
|
||||
errors={errors}
|
||||
>
|
||||
{children}
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function refFieldValidation(): SchemaOf<string> {
|
||||
return string()
|
||||
.when({
|
||||
is: isBE,
|
||||
then: string().required('Repository reference name is required'),
|
||||
})
|
||||
.default('');
|
||||
}
|
68
app/react/portainer/gitops/RefField/RefSelector.tsx
Normal file
68
app/react/portainer/gitops/RefField/RefSelector.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { StackId } from '@/react/docker/stacks/types';
|
||||
import { useGitRefs } from '@/react/portainer/gitops/queries/useGitRefs';
|
||||
|
||||
import { Select } from '@@/form-components/Input';
|
||||
|
||||
import { getAuthentication } from '../utils';
|
||||
|
||||
import { RefFieldModel } from './types';
|
||||
|
||||
export function RefSelector({
|
||||
model,
|
||||
value,
|
||||
onChange,
|
||||
isUrlValid,
|
||||
stackId,
|
||||
}: {
|
||||
model: RefFieldModel;
|
||||
value: string;
|
||||
stackId?: StackId;
|
||||
onChange: (value: string) => void;
|
||||
isUrlValid: boolean;
|
||||
}) {
|
||||
const creds = getAuthentication(model);
|
||||
const payload = {
|
||||
repository: model.RepositoryURL,
|
||||
stackId,
|
||||
...creds,
|
||||
};
|
||||
|
||||
const { data: refs } = useGitRefs<Array<{ label: string; value: string }>>(
|
||||
payload,
|
||||
{
|
||||
enabled: !!(model.RepositoryURL && isUrlValid),
|
||||
select: (refs) => {
|
||||
if (refs.length === 0) {
|
||||
return [{ value: 'refs/heads/main', label: 'refs/heads/main' }];
|
||||
}
|
||||
|
||||
// put refs/heads/main first if it is present in repository
|
||||
if (refs.includes('refs/heads/main')) {
|
||||
refs.splice(refs.indexOf('refs/heads/main'), 1);
|
||||
refs.unshift('refs/heads/main');
|
||||
}
|
||||
|
||||
return refs.map((t: string) => ({
|
||||
value: t,
|
||||
label: t,
|
||||
}));
|
||||
},
|
||||
|
||||
onSuccess(refs) {
|
||||
if (refs && !refs.some((ref) => ref.value === value)) {
|
||||
onChange(refs[0].value);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
options={refs || [{ value: 'refs/heads/main', label: 'refs/heads/main' }]}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-cy="component-gitRefInput"
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
1
app/react/portainer/gitops/RefField/index.ts
Normal file
1
app/react/portainer/gitops/RefField/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { RefField } from './RefField';
|
5
app/react/portainer/gitops/RefField/types.ts
Normal file
5
app/react/portainer/gitops/RefField/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { GitCredentialsModel } from '../types';
|
||||
|
||||
export interface RefFieldModel extends GitCredentialsModel {
|
||||
RepositoryURL: string;
|
||||
}
|
62
app/react/portainer/gitops/queries/useCheckRepo.ts
Normal file
62
app/react/portainer/gitops/queries/useCheckRepo.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
interface Creds {
|
||||
username?: string;
|
||||
password?: string;
|
||||
gitCredentialId?: number;
|
||||
}
|
||||
|
||||
export function useCheckRepo(
|
||||
url: string,
|
||||
creds: Creds,
|
||||
force: boolean,
|
||||
{
|
||||
enabled,
|
||||
onSettled,
|
||||
}: { enabled?: boolean; onSettled?(isValid?: boolean): void } = {}
|
||||
) {
|
||||
return useQuery(
|
||||
['git_repo_valid', url, creds, force],
|
||||
() => checkRepo(url, creds, force),
|
||||
{
|
||||
enabled: !!url && enabled,
|
||||
onSettled,
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkRepo(
|
||||
repository: string,
|
||||
creds: Creds,
|
||||
force: boolean
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await axios.post<string[]>(
|
||||
'/gitops/repo/refs',
|
||||
{ repository, ...creds },
|
||||
force ? { params: { force } } : {}
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error, '', (axiosError: AxiosError) => {
|
||||
let details = axiosError.response?.data.details;
|
||||
|
||||
// If no credentials were provided alter error from git to indicate repository is not found or is private
|
||||
if (
|
||||
!(creds.username && creds.password) &&
|
||||
details ===
|
||||
'Authentication failed, please ensure that the git credentials are correct.'
|
||||
) {
|
||||
details =
|
||||
'Git repository could not be found or is private, please ensure that the URL is correct or credentials are provided.';
|
||||
}
|
||||
|
||||
const error = new Error(details);
|
||||
return { error, details };
|
||||
});
|
||||
}
|
||||
}
|
39
app/react/portainer/gitops/queries/useGitRefs.ts
Normal file
39
app/react/portainer/gitops/queries/useGitRefs.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
interface RefsPayload {
|
||||
repository: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export function useGitRefs<T = string[]>(
|
||||
payload: RefsPayload,
|
||||
{
|
||||
enabled,
|
||||
select,
|
||||
onSuccess,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
select?: (data: string[]) => T;
|
||||
onSuccess?(data: T): void;
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(['git_repo_refs', { payload }], () => listRefs(payload), {
|
||||
enabled,
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
select,
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRefs(payload: RefsPayload) {
|
||||
try {
|
||||
const { data } = await axios.post<string[]>('/gitops/repo/refs', payload);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
35
app/react/portainer/gitops/queries/useSearch.ts
Normal file
35
app/react/portainer/gitops/queries/useSearch.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
interface SearchPayload {
|
||||
repository: string;
|
||||
keyword: string;
|
||||
reference?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export function useSearch(payload: SearchPayload, enabled: boolean) {
|
||||
return useQuery(
|
||||
['git_repo_search_results', { payload }],
|
||||
() => searchRepo(payload),
|
||||
{
|
||||
enabled,
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchRepo(payload: SearchPayload) {
|
||||
try {
|
||||
const { data } = await axios.post<string[] | null>(
|
||||
'/gitops/repo/files/search',
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
71
app/react/portainer/gitops/types.ts
Normal file
71
app/react/portainer/gitops/types.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
export type AutoUpdateMechanism = 'Webhook' | 'Interval';
|
||||
|
||||
export interface AutoUpdateResponse {
|
||||
/* Auto update interval */
|
||||
Interval: string;
|
||||
|
||||
/* A UUID generated from client */
|
||||
Webhook: string;
|
||||
|
||||
/* Force update ignores repo changes */
|
||||
ForceUpdate: boolean;
|
||||
|
||||
/* Pull latest image */
|
||||
ForcePullImage: boolean;
|
||||
}
|
||||
|
||||
export interface GitAuthenticationResponse {
|
||||
Username: string;
|
||||
Password: string;
|
||||
GitCredentialID: number;
|
||||
}
|
||||
|
||||
export interface RepoConfigResponse {
|
||||
URL: string;
|
||||
ReferenceName: string;
|
||||
ConfigFilePath: string;
|
||||
Authentication?: GitAuthenticationResponse;
|
||||
ConfigHash: string;
|
||||
}
|
||||
|
||||
export type AutoUpdateModel = {
|
||||
RepositoryAutomaticUpdates: boolean;
|
||||
RepositoryMechanism: AutoUpdateMechanism;
|
||||
RepositoryWebhookId: string;
|
||||
RepositoryFetchInterval: string;
|
||||
ForcePullImage: boolean;
|
||||
RepositoryAutomaticUpdatesForce: boolean;
|
||||
};
|
||||
|
||||
export type GitCredentialsModel = {
|
||||
RepositoryAuthentication: boolean;
|
||||
RepositoryUsername?: string;
|
||||
RepositoryPassword?: string;
|
||||
RepositoryGitCredentialID?: number;
|
||||
};
|
||||
|
||||
export type GitNewCredentialModel = {
|
||||
NewCredentialName?: string;
|
||||
SaveCredential?: boolean;
|
||||
};
|
||||
|
||||
export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel;
|
||||
|
||||
export interface GitFormModel extends GitAuthModel {
|
||||
RepositoryURL: string;
|
||||
RepositoryURLValid: boolean;
|
||||
ComposeFilePathInRepository: string;
|
||||
RepositoryAuthentication: boolean;
|
||||
RepositoryReferenceName?: string;
|
||||
AdditionalFiles: string[];
|
||||
|
||||
SaveCredential?: boolean;
|
||||
NewCredentialName?: string;
|
||||
|
||||
/**
|
||||
* Auto update
|
||||
*
|
||||
* if undefined, GitForm won't show the AutoUpdate fieldset
|
||||
*/
|
||||
AutoUpdate?: AutoUpdateModel;
|
||||
}
|
27
app/react/portainer/gitops/utils.ts
Normal file
27
app/react/portainer/gitops/utils.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { GitFormModel } from './types';
|
||||
|
||||
export function getAuthentication(
|
||||
model: Pick<
|
||||
GitFormModel,
|
||||
| 'RepositoryAuthentication'
|
||||
| 'RepositoryPassword'
|
||||
| 'RepositoryUsername'
|
||||
| 'RepositoryGitCredentialID'
|
||||
>
|
||||
) {
|
||||
if (model.RepositoryAuthentication) {
|
||||
return {
|
||||
username: model.RepositoryUsername,
|
||||
password: model.RepositoryPassword,
|
||||
};
|
||||
}
|
||||
|
||||
if (model.RepositoryGitCredentialID) {
|
||||
return { gitCredentialId: model.RepositoryGitCredentialID };
|
||||
}
|
||||
|
||||
return {
|
||||
username: model.RepositoryUsername,
|
||||
password: model.RepositoryPassword,
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue