mirror of
https://github.com/portainer/portainer.git
synced 2025-08-09 15:55:23 +02:00
refactor(app): redeploy git form [EE-6444]
This commit is contained in:
parent
9fc7187e24
commit
2cf7ef90bf
19 changed files with 670 additions and 329 deletions
|
@ -17,6 +17,7 @@ import {
|
|||
ApplicationDetailsWidget,
|
||||
ApplicationEventsDatatable,
|
||||
} from '@/react/kubernetes/applications/DetailsView';
|
||||
import { RedeployGitAppForm } from '@/react/kubernetes/applications/components/RedeployGitAppForm';
|
||||
import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable';
|
||||
import {
|
||||
PlacementFormSection,
|
||||
|
@ -187,6 +188,13 @@ export const ngModule = angular
|
|||
'showSystem',
|
||||
'setSystemResources',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'kubeRedeployGitAppForm',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(RedeployGitAppForm))), [
|
||||
'namespaceName',
|
||||
'stackId',
|
||||
])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
|
|
@ -150,7 +150,7 @@
|
|||
<div ng-if="!ctrl.isExternalApplication()">
|
||||
<git-form-info-panel
|
||||
ng-if="ctrl.state.appType == ctrl.KubernetesDeploymentTypes.GIT"
|
||||
class-name="'text-muted'"
|
||||
class-name="'text-muted text-xs'"
|
||||
url="ctrl.stack.GitConfig.URL"
|
||||
config-file-path="ctrl.stack.GitConfig.ConfigFilePath"
|
||||
additional-files="ctrl.stack.AdditionalFiles"
|
||||
|
@ -235,11 +235,11 @@
|
|||
<!-- #endregion -->
|
||||
|
||||
<!-- #region Git repository -->
|
||||
<kubernetes-redeploy-app-git-form
|
||||
<kube-redeploy-git-app-form
|
||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.GIT"
|
||||
stack="ctrl.stack"
|
||||
stack-id="ctrl.stack.Id"
|
||||
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||
></kubernetes-redeploy-app-git-form>
|
||||
></kube-redeploy-git-app-form>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region web editor -->
|
||||
|
|
|
@ -1,191 +0,0 @@
|
|||
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||
import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils';
|
||||
|
||||
class KubernetesRedeployAppGitFormController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, StackService, Notifications) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.StackService = StackService;
|
||||
this.Notifications = Notifications;
|
||||
|
||||
this.state = {
|
||||
saveGitSettingsInProgress: false,
|
||||
redeployInProgress: false,
|
||||
showConfig: false,
|
||||
isEdit: false,
|
||||
|
||||
// isAuthEdit is used to preserve the editing state of the AuthFieldset component.
|
||||
// Within the stack editing page, users have the option to turn the AuthFieldset on or off
|
||||
// and save the stack setting. If the user enables the AuthFieldset, it implies that they
|
||||
// must input new Git authentication, rather than edit existing authentication. Thus,
|
||||
// a dedicated state tracker is required to differentiate between the editing state of
|
||||
// AuthFieldset component and the whole stack
|
||||
// When isAuthEdit is true, PAT field needs to be validated.
|
||||
isAuthEdit: false,
|
||||
hasUnsavedChanges: false,
|
||||
baseWebhookUrl: baseStackWebhookUrl(),
|
||||
webhookId: createWebhookId(),
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
RefName: '',
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
// auto update
|
||||
AutoUpdate: parseAutoUpdateResponse(),
|
||||
};
|
||||
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onChangeRef = this.onChangeRef.bind(this);
|
||||
this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this);
|
||||
this.onChangeGitAuth = this.onChangeGitAuth.bind(this);
|
||||
this.onChangeTLSSkipVerify = this.onChangeTLSSkipVerify.bind(this);
|
||||
}
|
||||
|
||||
onChangeRef(value) {
|
||||
this.onChange({ RefName: value });
|
||||
}
|
||||
|
||||
async onChange(values) {
|
||||
return this.$async(async () => {
|
||||
this.formValues = {
|
||||
...this.formValues,
|
||||
...values,
|
||||
};
|
||||
|
||||
this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues);
|
||||
});
|
||||
}
|
||||
|
||||
onChangeGitAuth(values) {
|
||||
return this.$async(async () => {
|
||||
this.onChange(values);
|
||||
});
|
||||
}
|
||||
|
||||
onChangeTLSSkipVerify(value) {
|
||||
return this.$async(async () => {
|
||||
if (this.stack.GitConfig.TLSSkipVerify && !value) {
|
||||
const confirmed = await confirmEnableTLSVerify();
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.onChange({ TLSSkipVerify: value });
|
||||
});
|
||||
}
|
||||
|
||||
async onChangeAutoUpdate(values) {
|
||||
return this.$async(async () => {
|
||||
await this.onChange({
|
||||
AutoUpdate: {
|
||||
...this.formValues.AutoUpdate,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
buildAnalyticsProperties() {
|
||||
const metadata = {
|
||||
'automatic-updates': automaticUpdatesLabel(this.formValues.AutoUpdate.RepositoryAutomaticUpdates, this.formValues.AutoUpdate.RepositoryMechanism),
|
||||
};
|
||||
|
||||
return { metadata };
|
||||
|
||||
function automaticUpdatesLabel(repositoryAutomaticUpdates, repositoryMechanism) {
|
||||
switch (repositoryAutomaticUpdates && repositoryMechanism) {
|
||||
case RepositoryMechanismTypes.INTERVAL:
|
||||
return 'polling';
|
||||
case RepositoryMechanismTypes.WEBHOOK:
|
||||
return 'webhook';
|
||||
default:
|
||||
return 'off';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async pullAndRedeployApplication() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const confirmed = await confirm({
|
||||
title: 'Are you sure?',
|
||||
message: 'Any changes to this application will be overridden by the definition in git and may cause a service interruption. Do you wish to continue?',
|
||||
confirmButton: buildConfirmButton('Update', 'warning'),
|
||||
modalType: ModalType.Warn,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.redeployInProgress = true;
|
||||
await this.StackService.updateKubeGit(this.stack.Id, this.stack.EndpointId, this.namespace, this.formValues);
|
||||
this.Notifications.success('Success', 'Pulled and redeployed stack successfully');
|
||||
await this.$state.reload();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Failed redeploying application');
|
||||
} finally {
|
||||
this.state.redeployInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveGitSettings() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.state.saveGitSettingsInProgress = true;
|
||||
await this.StackService.updateKubeStack({ EndpointId: this.stack.EndpointId, Id: this.stack.Id }, { gitConfig: this.formValues, webhookId: this.state.webhookId });
|
||||
this.savedFormValues = angular.copy(this.formValues);
|
||||
this.state.hasUnsavedChanges = false;
|
||||
|
||||
if (!(this.stack.GitConfig && this.stack.GitConfig.Authentication)) {
|
||||
// update the AuthFieldset setting
|
||||
this.state.isAuthEdit = false;
|
||||
this.formValues.RepositoryUsername = '';
|
||||
this.formValues.RepositoryPassword = '';
|
||||
}
|
||||
this.Notifications.success('Success', 'Save stack settings successfully');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to save application settings');
|
||||
} finally {
|
||||
this.state.saveGitSettingsInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isSubmitButtonDisabled() {
|
||||
return this.state.saveGitSettingsInProgress || this.state.redeployInProgress;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.formValues.RefName = this.stack.GitConfig.ReferenceName;
|
||||
this.formValues.TLSSkipVerify = this.stack.GitConfig.TLSSkipVerify;
|
||||
|
||||
this.formValues.AutoUpdate = parseAutoUpdateResponse(this.stack.AutoUpdate);
|
||||
|
||||
if (this.stack.AutoUpdate && this.stack.AutoUpdate.Webhook) {
|
||||
this.state.webhookId = this.stack.AutoUpdate.Webhook;
|
||||
}
|
||||
|
||||
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
|
||||
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
|
||||
this.formValues.RepositoryPassword = this.stack.GitConfig.Authentication.Password;
|
||||
|
||||
this.formValues.RepositoryAuthentication = true;
|
||||
this.state.isEdit = true;
|
||||
this.state.isAuthEdit = true;
|
||||
}
|
||||
|
||||
this.savedFormValues = angular.copy(this.formValues);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesRedeployAppGitFormController;
|
|
@ -1,93 +0,0 @@
|
|||
<form name="$ctrl.redeployGitForm">
|
||||
<div class="col-sm-12 form-section-title"> Redeploy from git repository </div>
|
||||
<div class="form-group text-muted">
|
||||
<div class="col-sm-12">
|
||||
<p> Pull the latest manifest from git and redeploy the application. </p>
|
||||
</div>
|
||||
</div>
|
||||
<git-form-auto-update-fieldset
|
||||
value="$ctrl.formValues.AutoUpdate"
|
||||
on-change="($ctrl.onChangeAutoUpdate)"
|
||||
environment-type="KUBERNETES"
|
||||
is-force-pull-visible="false"
|
||||
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
||||
webhook-id="{{ $ctrl.state.webhookId }}"
|
||||
webhooks-docs="https://docs.portainer.io/user/kubernetes/applications/webhooks"
|
||||
></git-form-auto-update-fieldset>
|
||||
<time-window-display></time-window-display>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<p>
|
||||
<a class="small interactive" ng-click="$ctrl.state.showConfig = !$ctrl.state.showConfig">
|
||||
<pr-icon ng-if="$ctrl.state.showConfig" icon="'minus'" class-name="'mr-1'"></pr-icon>
|
||||
<pr-icon ng-if="!$ctrl.state.showConfig" icon="'plus'" class-name="'mr-1'"></pr-icon>
|
||||
{{ $ctrl.state.showConfig ? 'Hide' : 'Advanced' }} configuration
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<git-form-ref-field
|
||||
ng-if="$ctrl.state.showConfig"
|
||||
value="$ctrl.formValues.RefName"
|
||||
on-change="($ctrl.onChangeRef)"
|
||||
model="$ctrl.formValues"
|
||||
is-url-valid="true"
|
||||
></git-form-ref-field>
|
||||
|
||||
<git-form-auth-fieldset
|
||||
ng-if="$ctrl.state.showConfig"
|
||||
value="$ctrl.formValues"
|
||||
on-change="($ctrl.onChangeGitAuth)"
|
||||
is-auth-explanation-visible="true"
|
||||
is-auth-edit="$ctrl.state.isAuthEdit"
|
||||
></git-form-auth-fieldset>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.state.showConfig">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
name="TLSSkipVerify"
|
||||
checked="$ctrl.formValues.TLSSkipVerify"
|
||||
tooltip="'Enabling this will allow skipping TLS validation for any self-signed certificate.'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
label="'Skip TLS Verification'"
|
||||
on-change="($ctrl.onChangeTLSSkipVerify)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<!-- #Git buttons -->
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.pullAndRedeployApplication()"
|
||||
ng-disabled="$ctrl.isSubmitButtonDisabled() || $ctrl.state.hasUnsavedChanges|| !$ctrl.redeployGitForm.$valid"
|
||||
style="margin-top: 7px; margin-left: 0"
|
||||
button-spinner="$ctrl.state.redeployInProgress"
|
||||
analytics-on
|
||||
analytics-category="kubernetes"
|
||||
analytics-event="kubernetes-application-edit-git-pull"
|
||||
>
|
||||
<span ng-show="!$ctrl.state.redeployInProgress">
|
||||
<pr-icon icon="'refresh-cw'" class="!mr-1"></pr-icon>
|
||||
Pull and update application
|
||||
</span>
|
||||
<span ng-show="$ctrl.state.redeployInProgress">In progress...</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.saveGitSettings()"
|
||||
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.state.hasUnsavedChanges|| !$ctrl.redeployGitForm.$valid"
|
||||
style="margin-top: 7px; margin-left: 0"
|
||||
button-spinner="$ctrl.state.saveGitSettingsInProgress"
|
||||
analytics-on
|
||||
analytics-category="kubernetes"
|
||||
analytics-event="kubernetes-application-edit"
|
||||
analytics-properties="$ctrl.buildAnalyticsProperties()"
|
||||
>
|
||||
<span ng-show="!$ctrl.state.saveGitSettingsInProgress"> Save settings </span>
|
||||
<span ng-show="$ctrl.state.saveGitSettingsInProgress">In progress...</span>
|
||||
</button>
|
||||
</form>
|
|
@ -1,13 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import controller from './kubernetes-redeploy-app-git-form.controller';
|
||||
|
||||
const kubernetesRedeployAppGitForm = {
|
||||
templateUrl: './kubernetes-redeploy-app-git-form.html',
|
||||
controller,
|
||||
bindings: {
|
||||
stack: '<',
|
||||
namespace: '<',
|
||||
},
|
||||
};
|
||||
|
||||
angular.module('portainer.app').component('kubernetesRedeployAppGitForm', kubernetesRedeployAppGitForm);
|
22
app/react/common/stacks/queries/buildUrl.ts
Normal file
22
app/react/common/stacks/queries/buildUrl.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { StackId } from '@/react/common/stacks/types';
|
||||
|
||||
// actions in the url path in api/http/handler/stacks/handler.go
|
||||
type StackAction =
|
||||
| 'git'
|
||||
| 'git/redeploy'
|
||||
| 'file'
|
||||
| 'migrate'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'images_status';
|
||||
|
||||
export function buildStackUrl(stackId?: StackId, action?: StackAction) {
|
||||
let url = 'stacks';
|
||||
if (stackId) {
|
||||
url += `/${stackId}`;
|
||||
}
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
return url;
|
||||
}
|
6
app/react/common/stacks/queries/query-keys.ts
Normal file
6
app/react/common/stacks/queries/query-keys.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { StackId } from '../types';
|
||||
|
||||
export const stacksQueryKeys = {
|
||||
stackFile: (stackId: StackId) => ['stacks', stackId, 'file'],
|
||||
stacks: ['stacks'],
|
||||
};
|
47
app/react/common/stacks/queries/useStackQuery.ts
Normal file
47
app/react/common/stacks/queries/useStackQuery.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { Stack, StackFile, StackId } from '../types';
|
||||
|
||||
import { stacksQueryKeys } from './query-keys';
|
||||
import { buildStackUrl } from './buildUrl';
|
||||
|
||||
export function useStackQuery(stackId?: StackId) {
|
||||
return useQuery(
|
||||
stacksQueryKeys.stackFile(stackId || 0),
|
||||
() => getStack(stackId),
|
||||
{
|
||||
...withError('Unable to retrieve stack'),
|
||||
enabled: !!stackId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getStack(stackId?: StackId) {
|
||||
if (!stackId) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
const { data } = await axios.get<Stack>(buildStackUrl(stackId));
|
||||
return data;
|
||||
}
|
||||
|
||||
export function useStackFileQuery(stackId?: StackId) {
|
||||
return useQuery(
|
||||
stacksQueryKeys.stackFile(stackId || 0),
|
||||
() => getStackFile(stackId),
|
||||
{
|
||||
...withError('Unable to retrieve stack'),
|
||||
enabled: !!stackId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getStackFile(stackId?: StackId) {
|
||||
if (!stackId) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
const { data } = await axios.get<StackFile>(buildStackUrl(stackId, 'file'));
|
||||
return data;
|
||||
}
|
23
app/react/common/stacks/queries/useStacksQuery.ts
Normal file
23
app/react/common/stacks/queries/useStacksQuery.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { Stack } from '@/react/common/stacks/types';
|
||||
|
||||
import { buildStackUrl } from './buildUrl';
|
||||
import { stacksQueryKeys } from './query-keys';
|
||||
|
||||
export function useStacksQuery() {
|
||||
return useQuery(stacksQueryKeys.stacks, () => getStacks(), {
|
||||
...withError('Failed loading stack'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStacks() {
|
||||
try {
|
||||
const { data } = await axios.get<Stack[]>(buildStackUrl());
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import {
|
||||
withError,
|
||||
withInvalidate,
|
||||
queryClient,
|
||||
} from '@/react-tools/react-query';
|
||||
import { StackId } from '@/react/common/stacks/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
|
||||
|
||||
import { stacksQueryKeys } from './query-keys';
|
||||
import { buildStackUrl } from './buildUrl';
|
||||
|
||||
type UpdateKubeGitStackPayload = {
|
||||
AutoUpdate: AutoUpdateModel;
|
||||
RepositoryAuthentication: boolean;
|
||||
RepositoryGitCredentialID: number;
|
||||
RepositoryPassword: string;
|
||||
RepositoryReferenceName: string;
|
||||
RepositoryUsername: string;
|
||||
TLSSkipVerify: boolean;
|
||||
};
|
||||
|
||||
// update a stack from a git repository
|
||||
export function useUpdateKubeGitStackMutation(
|
||||
stackId: StackId,
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
return useMutation(
|
||||
(stack: UpdateKubeGitStackPayload) =>
|
||||
updateGitStack({ stack, stackId, environmentId }),
|
||||
{
|
||||
...withError('Unable to update stack'),
|
||||
...withInvalidate(queryClient, [stacksQueryKeys.stackFile(stackId)]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function updateGitStack({
|
||||
stackId,
|
||||
stack,
|
||||
environmentId,
|
||||
}: {
|
||||
stackId: StackId;
|
||||
stack: UpdateKubeGitStackPayload;
|
||||
environmentId: EnvironmentId;
|
||||
}) {
|
||||
return axios.put(buildStackUrl(stackId), stack, {
|
||||
params: { endpointId: environmentId },
|
||||
});
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { StackFile, StackId } from './types';
|
||||
|
||||
const queryKeys = {
|
||||
stackFile: (stackId?: StackId) => ['stacks', stackId, 'file'],
|
||||
};
|
||||
|
||||
export function useStackFile(stackId?: StackId) {
|
||||
return useQuery(queryKeys.stackFile(stackId), () => getStackFile(stackId), {
|
||||
...withError('Unable to retrieve stack'),
|
||||
enabled: !!stackId,
|
||||
});
|
||||
}
|
||||
|
||||
async function getStackFile(stackId?: StackId) {
|
||||
if (!stackId) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
const { data } = await axios.get<StackFile>(`/stacks/${stackId}/file`);
|
||||
return data;
|
||||
}
|
|
@ -3,7 +3,7 @@ import { useCurrentStateAndParams } from '@uirouter/react';
|
|||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { useStackFile } from '@/react/common/stacks/stack.service';
|
||||
import { useStackFileQuery } from '@/react/common/stacks/queries/useStackQuery';
|
||||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
import { Button } from '@@/buttons';
|
||||
|
@ -48,7 +48,7 @@ export function ApplicationDetailsWidget() {
|
|||
);
|
||||
const externalApp = app && isExternalApplication(app);
|
||||
const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]);
|
||||
const appStackFileQuery = useStackFile(appStackId);
|
||||
const appStackFileQuery = useStackFileQuery(appStackId);
|
||||
const { data: appServices } = useApplicationServices(
|
||||
environmentId,
|
||||
namespace,
|
||||
|
|
|
@ -0,0 +1,357 @@
|
|||
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { useRef, useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
import { Stack, StackId } from '@/react/common/stacks/types';
|
||||
import {
|
||||
parseAutoUpdateResponse,
|
||||
transformAutoUpdateViewModel,
|
||||
} from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
|
||||
import {
|
||||
AutoUpdateMechanism,
|
||||
AutoUpdateModel,
|
||||
} from '@/react/portainer/gitops/types';
|
||||
import {
|
||||
baseStackWebhookUrl,
|
||||
createWebhookId,
|
||||
} from '@/portainer/helpers/webhookHelper';
|
||||
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
|
||||
import { RefField } from '@/react/portainer/gitops/RefField';
|
||||
import { parseAuthResponse } from '@/react/portainer/gitops/AuthFieldset/utils';
|
||||
import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils';
|
||||
import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
|
||||
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||
import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy';
|
||||
import { useCreateGitCredentialMutation } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useStackQuery } from '@/react/common/stacks/queries/useStackQuery';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { useUpdateKubeGitStackMutation } from './queries/useUpdateKubeGitStackMutation';
|
||||
import {
|
||||
KubeAppGitFormValues,
|
||||
RedeployGitStackPayload,
|
||||
UpdateKubeGitStackPayload,
|
||||
} from './types';
|
||||
import { redeployGitAppFormValidationSchema } from './redeployGitAppFormValidationSchema';
|
||||
import { useRedeployKubeGitStackMutation } from './queries/useRedeployKubeGitStackMutation';
|
||||
|
||||
type Props = {
|
||||
stackId: StackId;
|
||||
namespaceName: string;
|
||||
};
|
||||
|
||||
export function RedeployGitAppForm({ stackId, namespaceName }: Props) {
|
||||
const { data: stack, ...stackQuery } = useStackQuery(stackId);
|
||||
const { trackEvent } = useAnalytics();
|
||||
const initialValues = getInitialValues(stack);
|
||||
const { user } = useCurrentUser();
|
||||
const { data: gitCredentials, ...gitCredentialsQuery } = useGitCredentials(
|
||||
user.Id
|
||||
);
|
||||
const environmentId = useEnvironmentId();
|
||||
const createGitCredentialMutation = useCreateGitCredentialMutation();
|
||||
const updateKubeGitStackMutation = useUpdateKubeGitStackMutation(
|
||||
stack?.Id || 0,
|
||||
environmentId
|
||||
);
|
||||
// keep a single generated webhook id, so that it doesn't change on every render
|
||||
const generatedWebhookId = useRef(createWebhookId());
|
||||
const webhookId = stack?.AutoUpdate?.Webhook || generatedWebhookId.current;
|
||||
|
||||
if (!initialValues) {
|
||||
return null;
|
||||
}
|
||||
const isAuthEdit = !!initialValues.authentication.RepositoryUsername;
|
||||
|
||||
if (stackQuery.isLoading || gitCredentialsQuery.isLoading) {
|
||||
return (
|
||||
<InlineLoader>Loading application git configuration...</InlineLoader>
|
||||
);
|
||||
}
|
||||
|
||||
if (stackQuery.isError) {
|
||||
return (
|
||||
<Alert color="error">Unable to load application git configuration.</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection titleSize="lg" title="Redeploy from git repository">
|
||||
<div className="form-group text-muted">
|
||||
<div className="col-sm-12">
|
||||
<p>Pull the latest manifest from git and redeploy the application.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Formik<KubeAppGitFormValues>
|
||||
initialValues={initialValues}
|
||||
validationSchema={() =>
|
||||
redeployGitAppFormValidationSchema(gitCredentials || [], isAuthEdit)
|
||||
}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RedeployGitAppInnerForm
|
||||
stack={stack}
|
||||
namespaceName={namespaceName}
|
||||
webhookId={webhookId}
|
||||
/>
|
||||
</Formik>
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
async function handleSubmit(
|
||||
values: KubeAppGitFormValues,
|
||||
{ resetForm }: FormikHelpers<KubeAppGitFormValues>
|
||||
) {
|
||||
trackSubmit(values);
|
||||
|
||||
// save the new git credentials if the user has selected to save them
|
||||
if (
|
||||
values.authentication.SaveCredential &&
|
||||
values.authentication.NewCredentialName &&
|
||||
values.authentication.RepositoryPassword &&
|
||||
values.authentication.RepositoryUsername
|
||||
) {
|
||||
createGitCredentialMutation.mutate({
|
||||
name: values.authentication.NewCredentialName,
|
||||
username: values.authentication.RepositoryUsername,
|
||||
password: values.authentication.RepositoryPassword,
|
||||
userId: user.Id,
|
||||
});
|
||||
}
|
||||
|
||||
// update the kubernetes git stack
|
||||
const { authentication } = values;
|
||||
const updateKubeGitStack: UpdateKubeGitStackPayload = {
|
||||
RepositoryAuthentication:
|
||||
!!values.authentication.RepositoryAuthentication,
|
||||
RepositoryReferenceName: values.repositoryReferenceName,
|
||||
AutoUpdate: transformAutoUpdateViewModel(values.autoUpdate, webhookId),
|
||||
TLSSkipVerify: values.tlsSkipVerify,
|
||||
RepositoryGitCredentialID: authentication.RepositoryGitCredentialID,
|
||||
RepositoryPassword: authentication.RepositoryPassword,
|
||||
RepositoryUsername: authentication.RepositoryUsername,
|
||||
};
|
||||
await updateKubeGitStackMutation.mutateAsync(updateKubeGitStack, {
|
||||
onSuccess: ({ data: newStack }) => {
|
||||
const newInitialValues = getInitialValues(newStack);
|
||||
notifySuccess('Success', 'Application saved successfully.');
|
||||
resetForm({ values: newInitialValues });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function trackSubmit(values: KubeAppGitFormValues) {
|
||||
trackEvent('kubernetes-application-edit', {
|
||||
category: 'kubernetes',
|
||||
metadata: {
|
||||
'automatic-updates': automaticUpdatesLabel(
|
||||
values.autoUpdate.RepositoryAutomaticUpdates,
|
||||
values.autoUpdate.RepositoryMechanism
|
||||
),
|
||||
},
|
||||
});
|
||||
function automaticUpdatesLabel(
|
||||
repositoryAutomaticUpdates: boolean,
|
||||
repositoryMechanism: AutoUpdateMechanism
|
||||
) {
|
||||
switch (repositoryAutomaticUpdates && repositoryMechanism) {
|
||||
case RepositoryMechanismTypes.INTERVAL:
|
||||
return 'polling';
|
||||
case RepositoryMechanismTypes.WEBHOOK:
|
||||
return 'webhook';
|
||||
default:
|
||||
return 'off';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function RedeployGitAppInnerForm({
|
||||
stack,
|
||||
namespaceName,
|
||||
webhookId,
|
||||
}: {
|
||||
stack: Stack;
|
||||
namespaceName: string;
|
||||
webhookId: string;
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const redeployKubeGitStackMutation = useRedeployKubeGitStackMutation(
|
||||
stack.Id,
|
||||
environmentId
|
||||
);
|
||||
const [isRedeployLoading, setIsRedeployLoading] = useState(false);
|
||||
const { trackEvent } = useAnalytics();
|
||||
const {
|
||||
values,
|
||||
errors,
|
||||
setFieldValue,
|
||||
handleSubmit,
|
||||
dirty,
|
||||
isValid,
|
||||
isSubmitting,
|
||||
setFieldTouched,
|
||||
} = useFormikContext<KubeAppGitFormValues>();
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit}>
|
||||
<AutoUpdateFieldset
|
||||
value={values.autoUpdate}
|
||||
onChange={(value: AutoUpdateModel) =>
|
||||
setFieldValue('autoUpdate', value)
|
||||
}
|
||||
baseWebhookUrl={baseStackWebhookUrl()}
|
||||
webhookId={webhookId}
|
||||
errors={errors.autoUpdate}
|
||||
environmentType="KUBERNETES"
|
||||
isForcePullVisible={false}
|
||||
webhooksDocs="https://docs.portainer.io/user/kubernetes/applications/webhooks"
|
||||
/>
|
||||
<TimeWindowDisplay />
|
||||
<FormSection title="Advanced configuration" isFoldable>
|
||||
<RefField
|
||||
value={values.repositoryReferenceName}
|
||||
onChange={(refName) =>
|
||||
setFieldValue('repositoryReferenceName', refName)
|
||||
}
|
||||
model={{
|
||||
...values.authentication,
|
||||
RepositoryURL: values.repositoryURL,
|
||||
}}
|
||||
error={errors.repositoryReferenceName}
|
||||
stackId={stack.Id}
|
||||
isUrlValid
|
||||
/>
|
||||
<AuthFieldset
|
||||
value={values.authentication}
|
||||
isAuthExplanationVisible
|
||||
onChange={(value) =>
|
||||
Object.entries(value).forEach(([key, value]) => {
|
||||
setFieldValue(key, value);
|
||||
// set touched after a delay to revalidate debounced username and access token fields
|
||||
setTimeout(() => setFieldTouched(key, true), 400);
|
||||
})
|
||||
}
|
||||
errors={errors.authentication}
|
||||
/>
|
||||
<SwitchField
|
||||
name="TLSSkipVerify"
|
||||
label="Skip TLS verification"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate."
|
||||
checked={values.tlsSkipVerify}
|
||||
onChange={onChangeTLSSkipVerify}
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection title="Actions">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
type="button"
|
||||
onClick={handleRedeploy}
|
||||
className="!ml-0"
|
||||
loadingText="In progress..."
|
||||
isLoading={isRedeployLoading}
|
||||
disabled={dirty}
|
||||
icon={RefreshCw}
|
||||
data-cy="application-redeploy-button"
|
||||
>
|
||||
Pull and update application
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
loadingText="In progress..."
|
||||
isLoading={isSubmitting}
|
||||
disabled={!dirty || !isValid}
|
||||
data-cy="application-redeploy-button"
|
||||
>
|
||||
Save settings
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</Form>
|
||||
);
|
||||
|
||||
async function handleRedeploy() {
|
||||
const confirmed = await confirm({
|
||||
title: 'Are you sure?',
|
||||
message:
|
||||
'Any changes to this application will be overridden by the definition in git and may cause a service interruption. Do you wish to continue?',
|
||||
confirmButton: buildConfirmButton('Update', 'warning'),
|
||||
modalType: ModalType.Warn,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setIsRedeployLoading(true);
|
||||
|
||||
// track the redeploy event
|
||||
trackEvent('kubernetes-application-edit-git-pull', {
|
||||
category: 'kubernetes',
|
||||
});
|
||||
|
||||
// redeploy the application
|
||||
const redeployPayload: RedeployGitStackPayload = {
|
||||
RepositoryReferenceName: values.repositoryReferenceName,
|
||||
RepositoryAuthentication:
|
||||
!!values.authentication.RepositoryAuthentication,
|
||||
RepositoryGitCredentialID:
|
||||
values.authentication.RepositoryGitCredentialID,
|
||||
RepositoryPassword: values.authentication.RepositoryPassword,
|
||||
RepositoryUsername: values.authentication.RepositoryUsername,
|
||||
Namespace: namespaceName,
|
||||
};
|
||||
await redeployKubeGitStackMutation.mutateAsync(redeployPayload, {
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Application redeployed successfully.');
|
||||
},
|
||||
});
|
||||
|
||||
setIsRedeployLoading(false);
|
||||
}
|
||||
|
||||
async function onChangeTLSSkipVerify(value: boolean) {
|
||||
// If the user is disabling TLS verification, ask for confirmation
|
||||
if (stack.GitConfig?.TLSSkipVerify && !value) {
|
||||
const confirmed = await confirmEnableTLSVerify();
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setFieldValue('tlsSkipVerify', value);
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialValues(stack?: Stack): KubeAppGitFormValues | undefined {
|
||||
if (!stack || !stack.GitConfig) {
|
||||
return undefined;
|
||||
}
|
||||
const autoUpdate = parseAutoUpdateResponse(stack.AutoUpdate);
|
||||
const authentication = parseAuthResponse(stack.GitConfig?.Authentication);
|
||||
return {
|
||||
authentication,
|
||||
repositoryReferenceName: stack.GitConfig.ReferenceName,
|
||||
repositoryURL: stack.GitConfig.URL,
|
||||
tlsSkipVerify: stack.GitConfig.TLSSkipVerify,
|
||||
autoUpdate,
|
||||
};
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { RedeployGitAppForm } from './RedeployGitAppForm';
|
|
@ -0,0 +1,54 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import { buildStackUrl } from '@/react/common/stacks/queries/buildUrl';
|
||||
import { stacksQueryKeys } from '@/react/common/stacks/queries/query-keys';
|
||||
import { StackId } from '@/react/common/stacks/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
withError,
|
||||
withInvalidate,
|
||||
queryClient,
|
||||
} from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { RedeployGitStackPayload } from '../types';
|
||||
|
||||
export function useRedeployKubeGitStackMutation(
|
||||
stackId: StackId,
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
return useMutation(
|
||||
(payload: RedeployGitStackPayload) =>
|
||||
redeployKubeGitStack({
|
||||
stackId,
|
||||
redeployPayload: payload,
|
||||
environmentId,
|
||||
}),
|
||||
{
|
||||
...withError('Unable to redeploy application.'),
|
||||
...withInvalidate(queryClient, [stacksQueryKeys.stackFile(stackId)]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function redeployKubeGitStack({
|
||||
stackId,
|
||||
redeployPayload,
|
||||
environmentId,
|
||||
}: {
|
||||
stackId: StackId;
|
||||
environmentId: EnvironmentId;
|
||||
redeployPayload: RedeployGitStackPayload;
|
||||
}) {
|
||||
try {
|
||||
return await axios.put(
|
||||
buildStackUrl(stackId, 'git/redeploy'),
|
||||
redeployPayload,
|
||||
{
|
||||
params: { endpointId: environmentId },
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import {
|
||||
withError,
|
||||
withInvalidate,
|
||||
queryClient,
|
||||
} from '@/react-tools/react-query';
|
||||
import { Stack, StackId } from '@/react/common/stacks/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { stacksQueryKeys } from '@/react/common/stacks/queries/query-keys';
|
||||
import { buildStackUrl } from '@/react/common/stacks/queries/buildUrl';
|
||||
|
||||
import { UpdateKubeGitStackPayload } from '../types';
|
||||
|
||||
// update a stack from a git repository
|
||||
export function useUpdateKubeGitStackMutation(
|
||||
stackId: StackId,
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
return useMutation(
|
||||
(stack: UpdateKubeGitStackPayload) =>
|
||||
updateGitStack({ stack, stackId, environmentId }),
|
||||
{
|
||||
...withError('Unable to update application.'),
|
||||
...withInvalidate(queryClient, [stacksQueryKeys.stackFile(stackId)]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function updateGitStack({
|
||||
stackId,
|
||||
stack,
|
||||
environmentId,
|
||||
}: {
|
||||
stackId: StackId;
|
||||
stack: UpdateKubeGitStackPayload;
|
||||
environmentId: EnvironmentId;
|
||||
}) {
|
||||
try {
|
||||
return await axios.post<Stack>(buildStackUrl(stackId, 'git'), stack, {
|
||||
params: { endpointId: environmentId },
|
||||
});
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { SchemaOf, boolean, object, string } from 'yup';
|
||||
|
||||
import { autoUpdateValidation } from '@/react/portainer/gitops/AutoUpdateFieldset/validation';
|
||||
import { gitAuthValidation } from '@/react/portainer/gitops/AuthFieldset';
|
||||
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
|
||||
|
||||
import { KubeAppGitFormValues } from './types';
|
||||
|
||||
export function redeployGitAppFormValidationSchema(
|
||||
gitCredentials: Array<GitCredential>,
|
||||
isAuthEdit: boolean
|
||||
): SchemaOf<KubeAppGitFormValues> {
|
||||
return object({
|
||||
authentication: gitAuthValidation(gitCredentials, isAuthEdit),
|
||||
repositoryURL: string().required('Repository URL is required'),
|
||||
tlsSkipVerify: boolean().required(),
|
||||
repositoryReferenceName: string().required('Branch is required'),
|
||||
autoUpdate: autoUpdateValidation(),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
AutoUpdateModel,
|
||||
AutoUpdateResponse,
|
||||
GitAuthModel,
|
||||
GitCredentialsModel,
|
||||
} from '@/react/portainer/gitops/types';
|
||||
|
||||
export interface KubeAppGitFormValues {
|
||||
authentication: GitAuthModel;
|
||||
repositoryReferenceName: string;
|
||||
repositoryURL: string;
|
||||
tlsSkipVerify: boolean;
|
||||
autoUpdate: AutoUpdateModel;
|
||||
}
|
||||
|
||||
export interface UpdateKubeGitStackPayload extends GitCredentialsModel {
|
||||
AutoUpdate: AutoUpdateResponse | null;
|
||||
RepositoryReferenceName: string;
|
||||
TLSSkipVerify: boolean;
|
||||
}
|
||||
|
||||
export interface RedeployGitStackPayload extends GitCredentialsModel {
|
||||
RepositoryReferenceName: string;
|
||||
Namespace: string;
|
||||
}
|
|
@ -58,7 +58,7 @@ export async function checkRepo(
|
|||
if (
|
||||
(!(creds.username && creds.password) || !creds.gitCredentialId) &&
|
||||
details ===
|
||||
'authentication failed, please ensure that the git credentials are correct'
|
||||
'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.';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue