mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 05:45:22 +02:00
feat(edge/stacks): sync EE codechanges [EE-498] (#8580)
This commit is contained in:
parent
0ec7dfce69
commit
93bf630105
53 changed files with 1572 additions and 424 deletions
|
@ -4,7 +4,7 @@ import clsx from 'clsx';
|
|||
|
||||
import { Icon, IconMode } from '@@/Icon';
|
||||
|
||||
type Color = 'orange' | 'blue';
|
||||
type Color = 'orange' | 'blue' | 'red' | 'green';
|
||||
|
||||
export interface Props {
|
||||
icon?: React.ReactNode;
|
||||
|
@ -33,6 +33,10 @@ function getMode(color: Color): IconMode {
|
|||
switch (color) {
|
||||
case 'blue':
|
||||
return 'primary';
|
||||
case 'red':
|
||||
return 'danger';
|
||||
case 'green':
|
||||
return 'success';
|
||||
case 'orange':
|
||||
default:
|
||||
return 'warning';
|
||||
|
|
111
app/react/components/WebEditorForm.tsx
Normal file
111
app/react/components/WebEditorForm.tsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { BROWSER_OS_PLATFORM } from '@/react/constants';
|
||||
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
import { FormSectionTitle } from './form-components/FormSectionTitle';
|
||||
|
||||
const otherEditorConfig = {
|
||||
tooltip: (
|
||||
<>
|
||||
<div>Ctrl+F - Start searching</div>
|
||||
<div>Ctrl+G - Find next</div>
|
||||
<div>Ctrl+Shift+G - Find previous</div>
|
||||
<div>Ctrl+Shift+F - Replace</div>
|
||||
<div>Ctrl+Shift+R - Replace all</div>
|
||||
<div>Alt+G - Jump to line</div>
|
||||
<div>Persistent search:</div>
|
||||
<div className="ml-5">Enter - Find next</div>
|
||||
<div className="ml-5">Shift+Enter - Find previous</div>
|
||||
</>
|
||||
),
|
||||
searchCmdLabel: 'Ctrl+F for search',
|
||||
} as const;
|
||||
|
||||
const editorConfig = {
|
||||
mac: {
|
||||
tooltip: (
|
||||
<>
|
||||
<div>Cmd+F - Start searching</div>
|
||||
<div>Cmd+G - Find next</div>
|
||||
<div>Cmd+Shift+G - Find previous</div>
|
||||
<div>Cmd+Option+F - Replace</div>
|
||||
<div>Cmd+Option+R - Replace all</div>
|
||||
<div>Option+G - Jump to line</div>
|
||||
<div>Persistent search:</div>
|
||||
<div className="ml-5">Enter - Find next</div>
|
||||
<div className="ml-5">Shift+Enter - Find previous</div>
|
||||
</>
|
||||
),
|
||||
searchCmdLabel: 'Cmd+F for search',
|
||||
},
|
||||
|
||||
lin: otherEditorConfig,
|
||||
win: otherEditorConfig,
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
|
||||
id: string;
|
||||
placeholder?: string;
|
||||
yaml?: boolean;
|
||||
readonly?: boolean;
|
||||
hideTitle?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function WebEditorForm({
|
||||
id,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
hideTitle,
|
||||
readonly,
|
||||
yaml,
|
||||
children,
|
||||
error,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div>
|
||||
<div className="web-editor overflow-x-hidden">
|
||||
{!hideTitle && (
|
||||
<FormSectionTitle htmlFor={id}>
|
||||
Web editor
|
||||
<div className="text-muted small vertical-center ml-auto">
|
||||
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
|
||||
|
||||
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
|
||||
</div>
|
||||
</FormSectionTitle>
|
||||
)}
|
||||
|
||||
{children && (
|
||||
<div className="form-group text-muted small">
|
||||
<div className="col-sm-12 col-lg-12">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 col-lg-12">
|
||||
<CodeEditor
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
readonly={readonly}
|
||||
yaml={yaml}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 col-lg-12">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { Registry } from '@/react/portainer/environments/environment.service/registries';
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { Modal, OnSubmit, openModal } from '@@/modals';
|
||||
import { Button } from '@@/buttons';
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
|
||||
import { DeploymentType } from '../../types';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function ComposeForm({
|
||||
handleContentChange,
|
||||
hasKubeEndpoint,
|
||||
}: {
|
||||
hasKubeEndpoint: boolean;
|
||||
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||
}) {
|
||||
const { errors, values } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasKubeEndpoint && (
|
||||
<TextTip>
|
||||
<p>
|
||||
Portainer no longer supports{' '}
|
||||
<a
|
||||
href="https://docs.docker.com/compose/compose-file/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docker-compose
|
||||
</a>{' '}
|
||||
format manifests for Kubernetes deployments, and we have removed the{' '}
|
||||
<a href="https://kompose.io/" target="_blank" rel="noreferrer">
|
||||
Kompose
|
||||
</a>{' '}
|
||||
conversion tool which enables this. The reason for this is because
|
||||
Kompose now poses a security risk, since it has a number of Common
|
||||
Vulnerabilities and Exposures (CVEs).
|
||||
</p>
|
||||
<p>
|
||||
Unfortunately, while the Kompose project has a maintainer and is
|
||||
part of the CNCF, it is not being actively maintained. Releases are
|
||||
very infrequent and new pull requests to the project (including ones
|
||||
we've submitted) are taking months to be merged, with new CVEs
|
||||
arising in the meantime.
|
||||
</p>
|
||||
<p>
|
||||
We advise installing your own instance of Kompose in a sandbox
|
||||
environment, performing conversions of your Docker Compose files to
|
||||
Kubernetes manifests and using those manifests to set up
|
||||
applications.
|
||||
</p>
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<WebEditorForm
|
||||
value={values.content}
|
||||
yaml
|
||||
id="compose-editor"
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
|
||||
error={errors.content}
|
||||
readonly={hasKubeEndpoint}
|
||||
>
|
||||
<div>
|
||||
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>
|
||||
.
|
||||
</div>
|
||||
</WebEditorForm>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,267 @@
|
|||
import { Form, Formik, useFormikContext } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { array, boolean, number, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { WebhookSettings } from '@/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings';
|
||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { PrivateRegistryFieldsetWrapper } from './PrivateRegistryFieldsetWrapper';
|
||||
import { FormValues } from './types';
|
||||
import { ComposeForm } from './ComposeForm';
|
||||
import { KubernetesForm } from './KubernetesForm';
|
||||
import { NomadForm } from './NomadForm';
|
||||
import { GitForm } from './GitForm';
|
||||
import { useValidateEnvironmentTypes } from './useEdgeGroupHasType';
|
||||
import { atLeastTwo } from './atLeastTwo';
|
||||
|
||||
interface Props {
|
||||
edgeStack: EdgeStack;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
onEditorChange: (content: string) => void;
|
||||
fileContent: string;
|
||||
allowKubeToSelectCompose: boolean;
|
||||
}
|
||||
|
||||
const forms = {
|
||||
[DeploymentType.Compose]: ComposeForm,
|
||||
[DeploymentType.Kubernetes]: KubernetesForm,
|
||||
[DeploymentType.Nomad]: NomadForm,
|
||||
};
|
||||
|
||||
export function EditEdgeStackForm({
|
||||
isSubmitting,
|
||||
edgeStack,
|
||||
onSubmit,
|
||||
onEditorChange,
|
||||
fileContent,
|
||||
allowKubeToSelectCompose,
|
||||
}: Props) {
|
||||
if (edgeStack.GitConfig) {
|
||||
return <GitForm stack={edgeStack} />;
|
||||
}
|
||||
|
||||
const formValues: FormValues = {
|
||||
edgeGroups: edgeStack.EdgeGroups,
|
||||
deploymentType: edgeStack.DeploymentType,
|
||||
privateRegistryId: edgeStack.Registries?.[0],
|
||||
content: fileContent,
|
||||
useManifestNamespaces: edgeStack.UseManifestNamespaces,
|
||||
prePullImage: edgeStack.PrePullImage,
|
||||
retryDeploy: edgeStack.RetryDeploy,
|
||||
webhookEnabled: !!edgeStack.Webhook,
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={formValues}
|
||||
onSubmit={onSubmit}
|
||||
validationSchema={formValidation()}
|
||||
>
|
||||
<InnerForm
|
||||
edgeStack={edgeStack}
|
||||
isSubmitting={isSubmitting}
|
||||
onEditorChange={onEditorChange}
|
||||
allowKubeToSelectCompose={allowKubeToSelectCompose}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerForm({
|
||||
onEditorChange,
|
||||
edgeStack,
|
||||
isSubmitting,
|
||||
allowKubeToSelectCompose,
|
||||
}: {
|
||||
edgeStack: EdgeStack;
|
||||
isSubmitting: boolean;
|
||||
onEditorChange: (content: string) => void;
|
||||
allowKubeToSelectCompose: boolean;
|
||||
}) {
|
||||
const {
|
||||
values,
|
||||
setFieldValue,
|
||||
isValid,
|
||||
|
||||
errors,
|
||||
setFieldError,
|
||||
} = useFormikContext<FormValues>();
|
||||
const { getCachedContent, setContentCache } = useCachedContent();
|
||||
const { hasType } = useValidateEnvironmentTypes(values.edgeGroups);
|
||||
|
||||
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||
const hasNomadEndpoint = hasType(EnvironmentType.EdgeAgentOnNomad);
|
||||
|
||||
const DeploymentForm = forms[values.deploymentType];
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<EdgeGroupsSelector
|
||||
value={values.edgeGroups}
|
||||
onChange={(value) => setFieldValue('edgeGroups', value)}
|
||||
error={errors.edgeGroups}
|
||||
/>
|
||||
|
||||
{atLeastTwo(hasKubeEndpoint, hasDockerEndpoint, hasNomadEndpoint) && (
|
||||
<TextTip>
|
||||
There are no available deployment types when there is more than one
|
||||
type of environment in your edge group selection (e.g. Kubernetes and
|
||||
Docker environments). Please select edge groups that have environments
|
||||
of the same type.
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && (
|
||||
<FormError>
|
||||
Edge groups with kubernetes environments no longer support compose
|
||||
deployment types in Portainer. Please select edge groups that only
|
||||
have docker environments when using compose deployment types.
|
||||
</FormError>
|
||||
)}
|
||||
|
||||
<EdgeStackDeploymentTypeSelector
|
||||
allowKubeToSelectCompose={allowKubeToSelectCompose}
|
||||
value={values.deploymentType}
|
||||
hasDockerEndpoint={hasType(EnvironmentType.EdgeAgentOnDocker)}
|
||||
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||
hasNomadEndpoint={hasType(EnvironmentType.EdgeAgentOnNomad)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('content', getCachedContent(value));
|
||||
setFieldValue('deploymentType', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeploymentForm
|
||||
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||
handleContentChange={handleContentChange}
|
||||
/>
|
||||
|
||||
{isBE && (
|
||||
<>
|
||||
<FormSection title="Webhooks">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Create an Edge stack webhook"
|
||||
checked={values.webhookEnabled}
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
onChange={(value) => setFieldValue('webhookEnabled', value)}
|
||||
tooltip="Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{edgeStack.Webhook && (
|
||||
<WebhookSettings
|
||||
baseUrl={baseEdgeStackWebhookUrl()}
|
||||
value={edgeStack.Webhook}
|
||||
docsLink="todo"
|
||||
/>
|
||||
)}
|
||||
</FormSection>
|
||||
<PrivateRegistryFieldsetWrapper
|
||||
value={values.privateRegistryId}
|
||||
onChange={(value) => setFieldValue('privateRegistryId', value)}
|
||||
isValid={isValid}
|
||||
values={values}
|
||||
stackName={edgeStack.Name}
|
||||
onFieldError={(error) => setFieldError('privateRegistryId', error)}
|
||||
error={errors.privateRegistryId}
|
||||
/>
|
||||
{values.deploymentType === DeploymentType.Compose && (
|
||||
<>
|
||||
<SwitchField
|
||||
checked={values.prePullImage}
|
||||
name="prePullImage"
|
||||
label="Pre-pull images"
|
||||
tooltip="When enabled, redeployment will be executed when image(s) is pulled successfully"
|
||||
label-Class="col-sm-3 col-lg-2"
|
||||
onChange={(value) => setFieldValue('prePullImage', value)}
|
||||
/>
|
||||
|
||||
<SwitchField
|
||||
checked={values.retryDeploy}
|
||||
name="retryDeploy"
|
||||
label="Retry deployment"
|
||||
tooltip="When enabled, this will allow edge agent keep retrying deployment if failure occur"
|
||||
label-Class="col-sm-3 col-lg-2"
|
||||
onChange={(value) => setFieldValue('retryDeploy', value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormSection title="Actions">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="!ml-0"
|
||||
size="small"
|
||||
disabled={!isValid}
|
||||
isLoading={isSubmitting}
|
||||
button-spinner="$ctrl.actionInProgress"
|
||||
loadingText="Update in progress..."
|
||||
>
|
||||
Update the stack
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</Form>
|
||||
);
|
||||
|
||||
function handleContentChange(type: DeploymentType, content: string) {
|
||||
setFieldValue('content', content);
|
||||
setContentCache(type, content);
|
||||
onEditorChange(content);
|
||||
}
|
||||
}
|
||||
|
||||
function useCachedContent() {
|
||||
const [cachedContent, setCachedContent] = useState({
|
||||
[DeploymentType.Compose]: '',
|
||||
[DeploymentType.Kubernetes]: '',
|
||||
[DeploymentType.Nomad]: '',
|
||||
});
|
||||
|
||||
function handleChangeContent(type: DeploymentType, content: string) {
|
||||
setCachedContent((cache) => ({ ...cache, [type]: content }));
|
||||
}
|
||||
|
||||
return {
|
||||
setContentCache: handleChangeContent,
|
||||
getCachedContent: (type: DeploymentType) => cachedContent[type],
|
||||
};
|
||||
}
|
||||
|
||||
function formValidation(): SchemaOf<FormValues> {
|
||||
return object({
|
||||
content: string().required('Content is required'),
|
||||
deploymentType: number()
|
||||
.oneOf([0, 1, 2])
|
||||
.required('Deployment type is required'),
|
||||
privateRegistryId: number().optional(),
|
||||
prePullImage: boolean().default(false),
|
||||
retryDeploy: boolean().default(false),
|
||||
useManifestNamespaces: boolean().default(false),
|
||||
edgeGroups: array()
|
||||
.of(number().required())
|
||||
.required()
|
||||
.min(1, 'At least one edge group is required'),
|
||||
webhookEnabled: boolean().default(false),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,279 @@
|
|||
import { Form, Formik, useFormikContext } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
|
||||
import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
|
||||
import {
|
||||
parseAutoUpdateResponse,
|
||||
transformAutoUpdateViewModel,
|
||||
} from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { InfoPanel } from '@/react/portainer/gitops/InfoPanel';
|
||||
import { RefField } from '@/react/portainer/gitops/RefField';
|
||||
import { AutoUpdateModel, GitAuthModel } from '@/react/portainer/gitops/types';
|
||||
import {
|
||||
baseEdgeStackWebhookUrl,
|
||||
createWebhookId,
|
||||
} from '@/portainer/helpers/webhookHelper';
|
||||
import {
|
||||
parseAuthResponse,
|
||||
transformGitAuthenticationViewModel,
|
||||
} from '@/react/portainer/gitops/AuthFieldset/utils';
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useCreateGitCredentialMutation } from '@/react/portainer/account/git-credentials/git-credentials.service';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { useValidateEnvironmentTypes } from '../useEdgeGroupHasType';
|
||||
import { atLeastTwo } from '../atLeastTwo';
|
||||
|
||||
import { useUpdateEdgeStackGitMutation } from './useUpdateEdgeStackGitMutation';
|
||||
|
||||
interface FormValues {
|
||||
groupIds: EdgeGroup['Id'][];
|
||||
deploymentType: DeploymentType;
|
||||
autoUpdate: AutoUpdateModel;
|
||||
refName: string;
|
||||
authentication: GitAuthModel;
|
||||
}
|
||||
|
||||
export function GitForm({ stack }: { stack: EdgeStack }) {
|
||||
const router = useRouter();
|
||||
const updateStackMutation = useUpdateEdgeStackGitMutation();
|
||||
const saveCredentialsMutation = useCreateGitCredentialMutation();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
if (!stack.GitConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gitConfig = stack.GitConfig;
|
||||
|
||||
const initialValues: FormValues = {
|
||||
groupIds: stack.EdgeGroups,
|
||||
deploymentType: stack.DeploymentType,
|
||||
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
|
||||
refName: stack.GitConfig.ReferenceName,
|
||||
authentication: parseAuthResponse(stack.GitConfig.Authentication),
|
||||
};
|
||||
|
||||
const webhookId = stack.AutoUpdate?.Webhook || createWebhookId();
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
{({ values, isValid }) => {
|
||||
return (
|
||||
<InnerForm
|
||||
webhookId={webhookId}
|
||||
onUpdateSettingsClick={handleUpdateSettings}
|
||||
gitPath={gitConfig.ConfigFilePath}
|
||||
gitUrl={gitConfig.URL}
|
||||
isLoading={updateStackMutation.isLoading}
|
||||
isUpdateVersion={!!updateStackMutation.variables?.updateVersion}
|
||||
/>
|
||||
);
|
||||
|
||||
async function handleUpdateSettings() {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialId = await saveCredentialsIfRequired(
|
||||
values.authentication
|
||||
);
|
||||
|
||||
updateStackMutation.mutate(getPayload(values, credentialId, false), {
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Stack updated successfully');
|
||||
router.stateService.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const credentialId = await saveCredentialsIfRequired(values.authentication);
|
||||
|
||||
updateStackMutation.mutate(getPayload(values, credentialId, true), {
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Stack updated successfully');
|
||||
router.stateService.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getPayload(
|
||||
{ authentication, autoUpdate, ...values }: FormValues,
|
||||
credentialId: number | undefined,
|
||||
updateVersion: boolean
|
||||
) {
|
||||
return {
|
||||
updateVersion,
|
||||
id: stack.Id,
|
||||
authentication: transformGitAuthenticationViewModel({
|
||||
...authentication,
|
||||
RepositoryGitCredentialID: credentialId,
|
||||
}),
|
||||
autoUpdate: transformAutoUpdateViewModel(autoUpdate, webhookId),
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveCredentialsIfRequired(authentication: GitAuthModel) {
|
||||
if (
|
||||
!authentication.SaveCredential ||
|
||||
!authentication.RepositoryPassword ||
|
||||
!authentication.NewCredentialName
|
||||
) {
|
||||
return authentication.RepositoryGitCredentialID;
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await saveCredentialsMutation.mutateAsync({
|
||||
userId: user.Id,
|
||||
username: authentication.RepositoryUsername,
|
||||
password: authentication.RepositoryPassword,
|
||||
name: authentication.NewCredentialName,
|
||||
});
|
||||
return credential.id;
|
||||
} catch (err) {
|
||||
notifyError('Error', err as Error, 'Unable to save credentials');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function InnerForm({
|
||||
gitUrl,
|
||||
gitPath,
|
||||
isLoading,
|
||||
isUpdateVersion,
|
||||
onUpdateSettingsClick,
|
||||
webhookId,
|
||||
}: {
|
||||
gitUrl: string;
|
||||
gitPath: string;
|
||||
|
||||
isLoading: boolean;
|
||||
isUpdateVersion: boolean;
|
||||
onUpdateSettingsClick(): void;
|
||||
webhookId: string;
|
||||
}) {
|
||||
const { values, setFieldValue, isValid, handleSubmit, errors, dirty } =
|
||||
useFormikContext<FormValues>();
|
||||
|
||||
const { hasType } = useValidateEnvironmentTypes(values.groupIds);
|
||||
|
||||
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||
const hasNomadEndpoint = hasType(EnvironmentType.EdgeAgentOnNomad);
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit}>
|
||||
<EdgeGroupsSelector
|
||||
value={values.groupIds}
|
||||
onChange={(value) => setFieldValue('groupIds', value)}
|
||||
error={errors.groupIds}
|
||||
/>
|
||||
|
||||
{atLeastTwo(hasKubeEndpoint, hasDockerEndpoint, hasNomadEndpoint) && (
|
||||
<TextTip>
|
||||
There are no available deployment types when there is more than one
|
||||
type of environment in your edge group selection (e.g. Kubernetes and
|
||||
Docker environments). Please select edge groups that have environments
|
||||
of the same type.
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && (
|
||||
<FormError>
|
||||
Edge groups with kubernetes environments no longer support compose
|
||||
deployment types in Portainer. Please select edge groups that only
|
||||
have docker environments when using compose deployment types.
|
||||
</FormError>
|
||||
)}
|
||||
<EdgeStackDeploymentTypeSelector
|
||||
value={values.deploymentType}
|
||||
hasDockerEndpoint={hasType(EnvironmentType.EdgeAgentOnDocker)}
|
||||
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||
hasNomadEndpoint={hasType(EnvironmentType.EdgeAgentOnNomad)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('deploymentType', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormSection title="Update from git repository">
|
||||
<InfoPanel
|
||||
className="text-muted small"
|
||||
url={gitUrl}
|
||||
type="Edge stack"
|
||||
configFilePath={gitPath}
|
||||
/>
|
||||
|
||||
<AutoUpdateFieldset
|
||||
webhookId={webhookId}
|
||||
value={values.autoUpdate}
|
||||
onChange={(value) =>
|
||||
setFieldValue('autoUpdate', {
|
||||
...values.autoUpdate,
|
||||
...value,
|
||||
})
|
||||
}
|
||||
baseWebhookUrl={baseEdgeStackWebhookUrl()}
|
||||
errors={errors.autoUpdate}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="Advanced configuration" isFoldable>
|
||||
<RefField
|
||||
value={values.refName}
|
||||
onChange={(value) => setFieldValue('refName', value)}
|
||||
model={{ ...values.authentication, RepositoryURL: gitUrl }}
|
||||
error={errors.refName}
|
||||
isUrlValid
|
||||
/>
|
||||
|
||||
<AuthFieldset
|
||||
value={values.authentication}
|
||||
isAuthExplanationVisible
|
||||
onChange={(value) =>
|
||||
Object.entries(value).forEach(([key, value]) => {
|
||||
setFieldValue(`authentication.${key}`, value);
|
||||
})
|
||||
}
|
||||
errors={errors.authentication}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="Actions">
|
||||
<LoadingButton
|
||||
disabled={!dirty || !isValid || isLoading}
|
||||
isLoading={isUpdateVersion && isLoading}
|
||||
loadingText="updating stack..."
|
||||
>
|
||||
Pull and update stack
|
||||
</LoadingButton>
|
||||
|
||||
<LoadingButton
|
||||
type="button"
|
||||
disabled={!dirty || !isValid || isLoading}
|
||||
isLoading={!isUpdateVersion && isLoading}
|
||||
loadingText="updating settings..."
|
||||
onClick={onUpdateSettingsClick}
|
||||
>
|
||||
Update settings
|
||||
</LoadingButton>
|
||||
</FormSection>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { GitForm } from './GitForm';
|
|
@ -0,0 +1,39 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { mutationOptions, withError } from '@/react-tools/react-query';
|
||||
import {
|
||||
AutoUpdateResponse,
|
||||
GitAuthenticationResponse,
|
||||
} from '@/react/portainer/gitops/types';
|
||||
import { buildUrl } from '@/react/edge/edge-stacks/queries/buildUrl';
|
||||
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
|
||||
interface UpdateEdgeStackGitPayload {
|
||||
id: EdgeStack['Id'];
|
||||
autoUpdate: AutoUpdateResponse | null;
|
||||
refName: string;
|
||||
authentication: GitAuthenticationResponse | null;
|
||||
groupIds: EdgeGroup['Id'][];
|
||||
deploymentType: DeploymentType;
|
||||
updateVersion: boolean;
|
||||
}
|
||||
|
||||
export function useUpdateEdgeStackGitMutation() {
|
||||
return useMutation(
|
||||
updateEdgeStackGit,
|
||||
mutationOptions(withError('Failed updating stack'))
|
||||
);
|
||||
}
|
||||
|
||||
async function updateEdgeStackGit({
|
||||
id,
|
||||
...payload
|
||||
}: UpdateEdgeStackGitPayload) {
|
||||
try {
|
||||
await axios.put(buildUrl(id, 'git'), payload);
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Failed updating stack');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
|
||||
import { DeploymentType } from '../../types';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function KubernetesForm({
|
||||
handleContentChange,
|
||||
}: {
|
||||
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||
}) {
|
||||
const { errors, values, setFieldValue } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Use namespace(s) specified from manifest"
|
||||
tooltip="If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment"
|
||||
checked={values.useManifestNamespaces}
|
||||
onChange={(value) => setFieldValue('useManifestNamespaces', value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebEditorForm
|
||||
value={values.content}
|
||||
yaml
|
||||
id="kube-manifest-editor"
|
||||
placeholder="Define or paste the content of your manifest here"
|
||||
onChange={(value) =>
|
||||
handleContentChange(DeploymentType.Kubernetes, value)
|
||||
}
|
||||
error={errors.content}
|
||||
>
|
||||
<p>
|
||||
You can get more information about Kubernetes file format in the{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</WebEditorForm>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
|
||||
import { DeploymentType } from '../../types';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function NomadForm({
|
||||
handleContentChange,
|
||||
}: {
|
||||
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||
}) {
|
||||
const { errors, values } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<WebEditorForm
|
||||
value={values.content}
|
||||
yaml
|
||||
id="kube-manifest-editor"
|
||||
placeholder="Define or paste the content of your manifest here"
|
||||
onChange={(value) => handleContentChange(DeploymentType.Nomad, value)}
|
||||
error={errors.content}
|
||||
>
|
||||
<p>
|
||||
You can get more information about Nomad file format in the{' '}
|
||||
<a
|
||||
href="https://www.nomadproject.io/docs/job-specification"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</WebEditorForm>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
|
||||
import { useCreateStackFromFileContent } from '@/react/edge/edge-stacks/queries/useCreateStackFromFileContent';
|
||||
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function PrivateRegistryFieldsetWrapper({
|
||||
value,
|
||||
isValid,
|
||||
error,
|
||||
onChange,
|
||||
values,
|
||||
stackName,
|
||||
onFieldError,
|
||||
}: {
|
||||
value: FormValues['privateRegistryId'];
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
onChange: (value?: number) => void;
|
||||
values: FormValues;
|
||||
stackName: string;
|
||||
onFieldError: (message: string) => void;
|
||||
}) {
|
||||
const dryRunMutation = useCreateStackFromFileContent();
|
||||
|
||||
const registriesQuery = useRegistries();
|
||||
|
||||
if (!registriesQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PrivateRegistryFieldset
|
||||
value={value}
|
||||
formInvalid={!isValid}
|
||||
errorMessage={error}
|
||||
registries={registriesQuery.data}
|
||||
onChange={() => matchRegistry()}
|
||||
onSelect={(value) => onChange(value)}
|
||||
isActive={!!value}
|
||||
clearRegistries={() => onChange(undefined)}
|
||||
/>
|
||||
);
|
||||
|
||||
async function matchRegistry() {
|
||||
try {
|
||||
const response = await dryRunMutation.mutateAsync({
|
||||
name: `${stackName}-dryrun`,
|
||||
stackFileContent: values.content,
|
||||
edgeGroups: values.edgeGroups,
|
||||
deploymentType: values.deploymentType,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
if (response.Registries.length === 0) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const validRegistry = onlyOne(response.Registries);
|
||||
if (validRegistry) {
|
||||
onChange(response.Registries[0]);
|
||||
} else {
|
||||
onChange(undefined);
|
||||
onFieldError(
|
||||
'Images need to be from a single registry, please edit and reload'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError('Failure', err as Error, 'Unable to retrieve registries');
|
||||
}
|
||||
}
|
||||
|
||||
function onlyOne<T extends string | number>(arr: Array<T>) {
|
||||
return _.uniq(arr).length === 1;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export function atLeastTwo(a: boolean, b: boolean, c: boolean) {
|
||||
return (a && b) || (a && c) || (b && c);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { DeploymentType } from '@/react/edge/edge-stacks/types';
|
||||
|
||||
export interface FormValues {
|
||||
edgeGroups: EdgeGroup['Id'][];
|
||||
deploymentType: DeploymentType;
|
||||
privateRegistryId?: number;
|
||||
content: string;
|
||||
useManifestNamespaces: boolean;
|
||||
prePullImage: boolean;
|
||||
retryDeploy: boolean;
|
||||
webhookEnabled: boolean;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import _ from 'lodash';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
export function useValidateEnvironmentTypes(groupIds: Array<EdgeGroup['Id']>) {
|
||||
const edgeGroupsQuery = useEdgeGroups();
|
||||
|
||||
const edgeGroups = edgeGroupsQuery.data || [];
|
||||
|
||||
const modelEdgeGroups = _.compact(
|
||||
groupIds.map((id) => edgeGroups.find((e) => e.Id === id))
|
||||
);
|
||||
const endpointTypes = modelEdgeGroups.flatMap((group) => group.EndpointTypes);
|
||||
|
||||
const hasType = useCallback(
|
||||
(type: EnvironmentType) => endpointTypes.includes(type),
|
||||
[endpointTypes]
|
||||
);
|
||||
|
||||
return {
|
||||
hasType,
|
||||
};
|
||||
}
|
|
@ -1,4 +1,8 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
||||
import NomadIcon from '@/assets/ico/vendor/nomad.svg?c';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
||||
|
@ -12,6 +16,7 @@ interface Props {
|
|||
onChange(value: number): void;
|
||||
hasDockerEndpoint: boolean;
|
||||
hasKubeEndpoint: boolean;
|
||||
hasNomadEndpoint: boolean;
|
||||
allowKubeToSelectCompose?: boolean;
|
||||
}
|
||||
|
||||
|
@ -20,29 +25,45 @@ export function EdgeStackDeploymentTypeSelector({
|
|||
onChange,
|
||||
hasDockerEndpoint,
|
||||
hasKubeEndpoint,
|
||||
hasNomadEndpoint,
|
||||
allowKubeToSelectCompose,
|
||||
}: Props) {
|
||||
const deploymentOptions: BoxSelectorOption<number>[] = [
|
||||
const deploymentOptions: BoxSelectorOption<number>[] = _.compact([
|
||||
{
|
||||
...compose,
|
||||
value: EditorType.Compose,
|
||||
disabled: () => (allowKubeToSelectCompose ? false : hasKubeEndpoint),
|
||||
disabled: () =>
|
||||
allowKubeToSelectCompose
|
||||
? hasNomadEndpoint
|
||||
: hasNomadEndpoint || hasKubeEndpoint,
|
||||
tooltip: () =>
|
||||
hasKubeEndpoint
|
||||
? 'Cannot use this option with Edge Kubernetes environments'
|
||||
hasNomadEndpoint || hasKubeEndpoint
|
||||
? 'Cannot use this option with Edge Kubernetes or Edge Nomad environments'
|
||||
: '',
|
||||
},
|
||||
{
|
||||
...kubernetes,
|
||||
value: EditorType.Kubernetes,
|
||||
disabled: () => hasDockerEndpoint,
|
||||
disabled: () => hasDockerEndpoint || hasNomadEndpoint,
|
||||
tooltip: () =>
|
||||
hasDockerEndpoint
|
||||
? 'Cannot use this option with Edge Docker environments'
|
||||
hasDockerEndpoint || hasNomadEndpoint
|
||||
? 'Cannot use this option with Edge Docker or Edge Nomad environments'
|
||||
: '',
|
||||
iconType: 'logo',
|
||||
},
|
||||
];
|
||||
isBE && {
|
||||
id: 'deployment_nomad',
|
||||
icon: NomadIcon,
|
||||
label: 'Nomad',
|
||||
description: 'Nomad HCL format',
|
||||
value: EditorType.Nomad,
|
||||
disabled: () => hasDockerEndpoint || hasKubeEndpoint,
|
||||
tooltip: () =>
|
||||
hasDockerEndpoint || hasKubeEndpoint
|
||||
? 'Cannot use this option with Edge Docker or Edge Kubernetes environments'
|
||||
: '',
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Button } from '@@/buttons';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
interface Props {
|
||||
value?: number;
|
||||
registries: Registry[];
|
||||
onChange: () => void;
|
||||
formInvalid?: boolean;
|
||||
errorMessage?: string;
|
||||
onSelect: (value?: number) => void;
|
||||
isActive?: boolean;
|
||||
clearRegistries: () => void;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export function PrivateRegistryFieldset({
|
||||
value,
|
||||
registries,
|
||||
onChange,
|
||||
formInvalid,
|
||||
errorMessage,
|
||||
onSelect,
|
||||
isActive,
|
||||
clearRegistries,
|
||||
method,
|
||||
}: Props) {
|
||||
const [checked, setChecked] = useState(isActive || false);
|
||||
const [selected, setSelected] = useState(value);
|
||||
|
||||
const tooltipMessage =
|
||||
'Use this when using a private registry that requires credentials';
|
||||
|
||||
useEffect(() => {
|
||||
if (checked) {
|
||||
onChange();
|
||||
} else {
|
||||
clearRegistries();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [checked]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(value);
|
||||
}, [value]);
|
||||
|
||||
function reload() {
|
||||
onChange();
|
||||
setSelected(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Registry">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={checked}
|
||||
onChange={(value) => setChecked(value)}
|
||||
tooltip={tooltipMessage}
|
||||
label="Use Credentials"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
disabled={formInvalid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checked && (
|
||||
<>
|
||||
{method !== 'repository' && (
|
||||
<>
|
||||
<TextTip color="blue">
|
||||
If you make any changes to the image urls in your yaml, please
|
||||
reload or select registry manually
|
||||
</TextTip>
|
||||
|
||||
<Button onClick={reload}>Reload</Button>
|
||||
</>
|
||||
)}
|
||||
{!errorMessage ? (
|
||||
<FormControl label="Registry" inputId="users-selector">
|
||||
<Select
|
||||
value={registries.filter(
|
||||
(registry) => registry.Id === selected
|
||||
)}
|
||||
options={registries}
|
||||
getOptionLabel={(registry) => registry.Name}
|
||||
getOptionValue={(registry) => registry.Id.toString()}
|
||||
onChange={(value) => onSelect(value?.Id)}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<FormError>{errorMessage}</FormError>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
7
app/react/edge/edge-stacks/queries/buildUrl.ts
Normal file
7
app/react/edge/edge-stacks/queries/buildUrl.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { EdgeStack } from '../types';
|
||||
|
||||
export function buildUrl(id?: EdgeStack['Id'], action?: string) {
|
||||
const baseUrl = '/edge_stacks';
|
||||
const url = id ? `${baseUrl}/${id}` : baseUrl;
|
||||
return action ? `${url}/${action}` : url;
|
||||
}
|
10
app/react/edge/edge-stacks/queries/query-keys.ts
Normal file
10
app/react/edge/edge-stacks/queries/query-keys.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeStack } from '../types';
|
||||
|
||||
export const queryKeys = {
|
||||
base: () => ['edge-stacks'] as const,
|
||||
item: (id: EdgeStack['Id']) => [...queryKeys.base(), id] as const,
|
||||
logsStatus: (edgeStackId: EdgeStack['Id'], environmentId: EnvironmentId) =>
|
||||
[...queryKeys.item(edgeStackId), 'logs', environmentId] as const,
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { RegistryId } from '@/react/portainer/registries/types';
|
||||
|
||||
import { EdgeGroup } from '../../edge-groups/types';
|
||||
import { DeploymentType, EdgeStack } from '../types';
|
||||
|
||||
import { buildUrl } from './buildUrl';
|
||||
|
||||
export function useCreateStackFromFileContent() {
|
||||
return useMutation(createStackFromFileContent, {
|
||||
...withError('Failed creating Edge stack'),
|
||||
});
|
||||
}
|
||||
|
||||
interface FileContentPayload {
|
||||
name: string;
|
||||
stackFileContent: string;
|
||||
edgeGroups: EdgeGroup['Id'][];
|
||||
deploymentType: DeploymentType;
|
||||
registries?: RegistryId[];
|
||||
useManifestNamespaces?: boolean;
|
||||
prePullImage?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export async function createStackFromFileContent({
|
||||
dryRun,
|
||||
...payload
|
||||
}: FileContentPayload) {
|
||||
try {
|
||||
const { data } = await axios.post<EdgeStack>(buildUrl(), payload, {
|
||||
params: { method: 'string', dryrun: dryRun ? 'true' : 'false' },
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,62 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
AutoUpdateResponse,
|
||||
RepoConfigResponse,
|
||||
} from '@/react/portainer/gitops/types';
|
||||
import { RegistryId } from '@/react/portainer/registries/types';
|
||||
|
||||
import { EdgeGroup } from '../edge-groups/types';
|
||||
|
||||
interface EdgeStackStatusDetails {
|
||||
Pending: boolean;
|
||||
Ok: boolean;
|
||||
Error: boolean;
|
||||
Acknowledged: boolean;
|
||||
Remove: boolean;
|
||||
RemoteUpdateSuccess: boolean;
|
||||
ImagesPulled: boolean;
|
||||
}
|
||||
|
||||
interface EdgeStackStatus {
|
||||
Details: EdgeStackStatusDetails;
|
||||
Error: string;
|
||||
EndpointID: EnvironmentId;
|
||||
}
|
||||
|
||||
export enum DeploymentType {
|
||||
/** represent an edge stack deployed using a compose file */
|
||||
Compose,
|
||||
/** represent an edge stack deployed using a kubernetes manifest file */
|
||||
Kubernetes,
|
||||
/** represent an edge stack deployed using a nomad hcl job file */
|
||||
Nomad,
|
||||
}
|
||||
|
||||
export type EdgeStack = {
|
||||
Id: number;
|
||||
Name: string;
|
||||
Status: { [key: EnvironmentId]: EdgeStackStatus };
|
||||
CreationDate: number;
|
||||
EdgeGroups: Array<EdgeGroup['Id']>;
|
||||
Registries: RegistryId[];
|
||||
ProjectPath: string;
|
||||
EntryPoint: string;
|
||||
Version: number;
|
||||
NumDeployments: number;
|
||||
ManifestPath: string;
|
||||
DeploymentType: DeploymentType;
|
||||
EdgeUpdateID: number;
|
||||
ScheduledTime: string;
|
||||
UseManifestNamespaces: boolean;
|
||||
PrePullImage: boolean;
|
||||
RePullImage: boolean;
|
||||
AutoUpdate?: AutoUpdateResponse;
|
||||
GitConfig?: RepoConfigResponse;
|
||||
Prune: boolean;
|
||||
RetryDeploy: boolean;
|
||||
Webhook?: string;
|
||||
};
|
||||
|
||||
export enum EditorType {
|
||||
Compose,
|
||||
Kubernetes,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Registry } from '@/react/portainer/environments/environment.service/registries';
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
|
|
|
@ -13,7 +13,11 @@ export async function createGitCredential(
|
|||
gitCredential: CreateGitCredentialPayload
|
||||
) {
|
||||
try {
|
||||
await axios.post(buildGitUrl(gitCredential.userId), gitCredential);
|
||||
const { data } = await axios.post<{ gitCredential: GitCredential }>(
|
||||
buildGitUrl(gitCredential.userId),
|
||||
gitCredential
|
||||
);
|
||||
return data.gitCredential;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to create git credential');
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { RegistryId, Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { EnvironmentId } from '../types';
|
||||
|
||||
|
@ -14,12 +15,6 @@ interface AccessPolicy {
|
|||
type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
|
||||
type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
|
||||
|
||||
export type RegistryId = number;
|
||||
export interface Registry {
|
||||
Id: RegistryId;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
interface RegistryAccess {
|
||||
UserAccessPolicies: UserAccessPolicies;
|
||||
TeamAccessPolicies: TeamAccessPolicies;
|
||||
|
|
|
@ -22,3 +22,27 @@ export function parseAuthResponse(
|
|||
RepositoryUsername: auth.Username,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformGitAuthenticationViewModel(
|
||||
auth?: GitAuthModel
|
||||
): GitAuthenticationResponse | null {
|
||||
if (
|
||||
!auth ||
|
||||
!auth.RepositoryAuthentication ||
|
||||
typeof auth.RepositoryGitCredentialID === 'undefined' ||
|
||||
(auth.RepositoryGitCredentialID === 0 && auth.RepositoryPassword === '')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (auth.RepositoryGitCredentialID !== 0) {
|
||||
return {
|
||||
GitCredentialID: auth.RepositoryGitCredentialID,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Username: auth.RepositoryUsername,
|
||||
Password: auth.RepositoryPassword,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,9 +15,9 @@ export interface AutoUpdateResponse {
|
|||
}
|
||||
|
||||
export interface GitAuthenticationResponse {
|
||||
Username: string;
|
||||
Password: string;
|
||||
GitCredentialID: number;
|
||||
Username?: string;
|
||||
Password?: string;
|
||||
GitCredentialID?: number;
|
||||
}
|
||||
|
||||
export interface RepoConfigResponse {
|
||||
|
|
3
app/react/portainer/registries/queries/queryKeys.ts
Normal file
3
app/react/portainer/registries/queries/queryKeys.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const queryKeys = {
|
||||
registries: () => ['registries'] as const,
|
||||
};
|
20
app/react/portainer/registries/queries/useRegistries.ts
Normal file
20
app/react/portainer/registries/queries/useRegistries.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { Registry } from '../types';
|
||||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
export function useRegistries() {
|
||||
return useQuery(queryKeys.registries(), getRegistries);
|
||||
}
|
||||
|
||||
async function getRegistries() {
|
||||
try {
|
||||
const response = await axios.get<Array<Registry>>('/registries');
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Unable to retrieve registries');
|
||||
}
|
||||
}
|
5
app/react/portainer/registries/types.ts
Normal file
5
app/react/portainer/registries/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export type RegistryId = number;
|
||||
export interface Registry {
|
||||
Id: RegistryId;
|
||||
Name: string;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue