Commit f9875875 authored by Phil Hughes's avatar Phil Hughes

Merge branch '60777-uninstall-button' into 'master'

Implement UI for uninstalling Cluster’s managed apps

Closes #60777

See merge request gitlab-org/gitlab-ce!27559
parents d6aa8a05 bf229a6c
......@@ -132,6 +132,7 @@ export default class Clusters {
eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId));
eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data));
eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data));
eventHub.$on('uninstallApplication', data => this.uninstallApplication(data));
}
removeListeners() {
......@@ -141,6 +142,7 @@ export default class Clusters {
eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess);
eventHub.$off('saveKnativeDomain');
eventHub.$off('setKnativeHostname');
eventHub.$off('uninstallApplication');
}
initPolling() {
......@@ -249,14 +251,13 @@ export default class Clusters {
}
}
installApplication(data) {
const appId = data.id;
installApplication({ id: appId, params }) {
this.store.updateAppProperty(appId, 'requestReason', null);
this.store.updateAppProperty(appId, 'statusReason', null);
this.store.installApplication(appId);
return this.service.installApplication(appId, data.params).catch(() => {
return this.service.installApplication(appId, params).catch(() => {
this.store.notifyInstallFailure(appId);
this.store.updateAppProperty(
appId,
......@@ -266,6 +267,22 @@ export default class Clusters {
});
}
uninstallApplication({ id: appId }) {
this.store.updateAppProperty(appId, 'requestReason', null);
this.store.updateAppProperty(appId, 'statusReason', null);
this.store.uninstallApplication(appId);
return this.service.uninstallApplication(appId).catch(() => {
this.store.notifyUninstallFailure(appId);
this.store.updateAppProperty(
appId,
'requestReason',
s__('ClusterIntegration|Request to begin uninstalling failed'),
);
});
}
upgradeApplication(data) {
const appId = data.id;
......
<script>
/* eslint-disable vue/require-default-prop */
import { GlLink } from '@gitlab/ui';
import { GlLink, GlModalDirective } from '@gitlab/ui';
import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
import { s__, sprintf } from '../../locale';
import eventHub from '../event_hub';
import identicon from '../../vue_shared/components/identicon.vue';
import loadingButton from '../../vue_shared/components/loading_button.vue';
import UninstallApplicationButton from './uninstall_application_button.vue';
import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue';
import { APPLICATION_STATUS } from '../constants';
......@@ -17,6 +18,10 @@ export default {
TimeagoTooltip,
GlLink,
UninstallApplicationButton,
UninstallApplicationConfirmationModal,
},
directives: {
GlModalDirective,
},
props: {
id: {
......@@ -94,6 +99,16 @@ export default {
required: false,
default: false,
},
uninstallFailed: {
type: Boolean,
required: false,
default: false,
},
uninstallSuccessful: {
type: Boolean,
required: false,
default: false,
},
updateAcknowledged: {
type: Boolean,
required: false,
......@@ -170,10 +185,21 @@ export default {
manageButtonLabel() {
return s__('ClusterIntegration|Manage');
},
hasError() {
return this.installFailed || this.uninstallFailed;
},
generalErrorDescription() {
return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), {
title: this.title,
});
let errorDescription;
if (this.installFailed) {
errorDescription = s__('ClusterIntegration|Something went wrong while installing %{title}');
} else if (this.uninstallFailed) {
errorDescription = s__(
'ClusterIntegration|Something went wrong while uninstalling %{title}',
);
}
return sprintf(errorDescription, { title: this.title });
},
versionLabel() {
if (this.updateFailed) {
......@@ -214,13 +240,23 @@ export default {
// AND new upgrade is unavailable AND version information is present.
return (this.updateSuccessful || this.updateFailed) && !this.upgradeAvailable && this.version;
},
uninstallSuccessDescription() {
return sprintf(s__('ClusterIntegration|%{title} uninstalled successfully.'), {
title: this.title,
});
},
},
watch: {
updateSuccessful() {
if (this.updateSuccessful) {
updateSuccessful(updateSuccessful) {
if (updateSuccessful) {
this.$toast.show(this.upgradeSuccessDescription);
}
},
uninstallSuccessful(uninstallSuccessful) {
if (uninstallSuccessful) {
this.$toast.show(this.uninstallSuccessDescription);
}
},
},
methods: {
installClicked() {
......@@ -235,6 +271,11 @@ export default {
params: this.installApplicationRequestParams,
});
},
uninstallConfirmed() {
eventHub.$emit('uninstallApplication', {
id: this.id,
});
},
},
};
</script>
......@@ -271,10 +312,7 @@ export default {
<span v-else class="js-cluster-application-title">{{ title }}</span>
</strong>
<slot name="description"></slot>
<div
v-if="installFailed || isUnknownStatus"
class="cluster-application-error text-danger prepend-top-10"
>
<div v-if="hasError" class="cluster-application-error text-danger prepend-top-10">
<p class="js-cluster-application-general-error-message append-bottom-0">
{{ generalErrorDescription }}
</p>
......@@ -325,9 +363,9 @@ export default {
role="gridcell"
>
<div v-if="showManageButton" class="btn-group table-action-buttons">
<a :href="manageLink" :class="{ disabled: disabled }" class="btn">
{{ manageButtonLabel }}
</a>
<a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{
manageButtonLabel
}}</a>
</div>
<div class="btn-group table-action-buttons">
<loading-button
......@@ -340,8 +378,15 @@ export default {
/>
<uninstall-application-button
v-if="displayUninstallButton"
v-gl-modal-directive="'uninstall-' + id"
:status="status"
class="js-cluster-application-uninstall-button"
/>
<uninstall-application-confirmation-modal
:application="id"
:application-title="title"
@confirm="uninstallConfirmed()"
/>
</div>
</div>
</div>
......
......@@ -240,6 +240,9 @@ export default {
:request-reason="applications.helm.requestReason"
:installed="applications.helm.installed"
:install-failed="applications.helm.installFailed"
:uninstallable="applications.helm.uninstallable"
:uninstall-successful="applications.helm.uninstallSuccessful"
:uninstall-failed="applications.helm.uninstallFailed"
class="rounded-top"
title-link="https://docs.helm.sh/"
>
......@@ -269,6 +272,9 @@ export default {
:request-reason="applications.ingress.requestReason"
:installed="applications.ingress.installed"
:install-failed="applications.ingress.installFailed"
:uninstallable="applications.ingress.uninstallable"
:uninstall-successful="applications.ingress.uninstallSuccessful"
:uninstall-failed="applications.ingress.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
>
......@@ -312,9 +318,9 @@ export default {
generated endpoint in order to access
your application after it has been deployed.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
</a>
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
__('More information')
}}</a>
</p>
</div>
......@@ -324,9 +330,9 @@ export default {
the process of being assigned. Please check your Kubernetes
cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
</a>
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
__('More information')
}}</a>
</p>
</template>
<template v-if="!ingressInstalled">
......@@ -345,6 +351,9 @@ export default {
:installed="applications.cert_manager.installed"
:install-failed="applications.cert_manager.installFailed"
:install-application-request-params="{ email: applications.cert_manager.email }"
:uninstallable="applications.cert_manager.uninstallable"
:uninstall-successful="applications.cert_manager.uninstallSuccessful"
:uninstall-failed="applications.cert_manager.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://cert-manager.readthedocs.io/en/latest/#"
>
......@@ -352,9 +361,9 @@ export default {
<div slot="description">
<p v-html="certManagerDescription"></p>
<div class="form-group">
<label for="cert-manager-issuer-email">
{{ s__('ClusterIntegration|Issuer Email') }}
</label>
<label for="cert-manager-issuer-email">{{
s__('ClusterIntegration|Issuer Email')
}}</label>
<div class="input-group">
<input
v-model="applications.cert_manager.email"
......@@ -391,6 +400,9 @@ export default {
:request-reason="applications.prometheus.requestReason"
:installed="applications.prometheus.installed"
:install-failed="applications.prometheus.installFailed"
:uninstallable="applications.prometheus.uninstallable"
:uninstall-successful="applications.prometheus.uninstallSuccessful"
:uninstall-failed="applications.prometheus.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://prometheus.io/docs/introduction/overview/"
>
......@@ -411,6 +423,9 @@ export default {
:install-failed="applications.runner.installFailed"
:update-successful="applications.runner.updateSuccessful"
:update-failed="applications.runner.updateFailed"
:uninstallable="applications.runner.uninstallable"
:uninstall-successful="applications.runner.uninstallSuccessful"
:uninstall-failed="applications.runner.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://docs.gitlab.com/runner/"
>
......@@ -434,6 +449,9 @@ export default {
:request-reason="applications.jupyter.requestReason"
:installed="applications.jupyter.installed"
:install-failed="applications.jupyter.installFailed"
:uninstallable="applications.jupyter.uninstallable"
:uninstall-successful="applications.jupyter.uninstallSuccessful"
:uninstall-failed="applications.jupyter.uninstallFailed"
:install-application-request-params="{ hostname: applications.jupyter.hostname }"
:disabled="!helmInstalled"
title-link="https://jupyterhub.readthedocs.io/en/stable/"
......@@ -474,9 +492,9 @@ export default {
s__(`ClusterIntegration|Replace this with your own hostname if you want.
If you do so, point hostname to Ingress IP Address from above.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
</a>
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
__('More information')
}}</a>
</p>
</div>
</template>
......@@ -494,6 +512,9 @@ export default {
:installed="applications.knative.installed"
:install-failed="applications.knative.installFailed"
:install-application-request-params="{ hostname: applications.knative.hostname }"
:uninstallable="applications.knative.uninstallable"
:uninstall-successful="applications.knative.uninstallSuccessful"
:uninstall-failed="applications.knative.uninstallFailed"
:disabled="!helmInstalled"
v-bind="applications.knative"
title-link="https://github.com/knative/docs"
......@@ -505,9 +526,9 @@ export default {
s__(`ClusterIntegration|You must have an RBAC-enabled cluster
to install Knative.`)
}}
<a :href="helpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
</a>
<a :href="helpPath" target="_blank" rel="noopener noreferrer">{{
__('More information')
}}</a>
</p>
<br />
</span>
......@@ -572,9 +593,9 @@ export default {
`ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`,
)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
</a>
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
__('More information')
}}</a>
</p>
<p
......
<script>
// TODO: Implement loading button component
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { APPLICATION_STATUS } from '~/clusters/constants';
const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
export default {
components: {
LoadingButton,
},
props: {
status: {
type: String,
required: true,
},
},
computed: {
disabled() {
return [UNINSTALLING, UPDATING].includes(this.status);
},
loading() {
return this.status === UNINSTALLING;
},
label() {
return this.loading ? this.__('Uninstalling') : this.__('Uninstall');
},
},
};
</script>
<template>
<loading-button @click="$emit('click')" />
<loading-button :label="label" :disabled="disabled" :loading="loading" />
</template>
<script>
import { GlModal } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import { INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants';
const CUSTOM_APP_WARNING_TEXT = {
[INGRESS]: s__(
'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.',
),
[CERT_MANAGER]: s__(
'ClusterIntegration|The associated certifcate will be deleted and cannot be restored.',
),
[PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'),
[RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'),
[KNATIVE]: s__('ClusterIntegration|The associated IP will be deleted and cannot be restored.'),
[JUPYTER]: '',
};
export default {
components: {
GlModal,
},
props: {
application: {
type: String,
required: true,
},
applicationTitle: {
type: String,
required: true,
},
},
computed: {
title() {
return sprintf(s__('ClusterIntegration|Uninstall %{appTitle}'), {
appTitle: this.applicationTitle,
});
},
warningText() {
return sprintf(
s__('ClusterIntegration|You are about to uninstall %{appTitle} from your cluster.'),
{
appTitle: this.applicationTitle,
},
);
},
customAppWarningText() {
return CUSTOM_APP_WARNING_TEXT[this.application];
},
modalId() {
return `uninstall-${this.application}`;
},
},
};
</script>
<template>
<gl-modal
ok-variant="danger"
cancel-variant="light"
:ok-title="title"
:modal-id="modalId"
:title="title"
@ok="$emit('confirm')"
>{{ warningText }} {{ customAppWarningText }}</gl-modal
>
</template>
......@@ -28,16 +28,23 @@ export const APPLICATION_STATUS = {
export const APPLICATION_INSTALLED_STATUSES = [
APPLICATION_STATUS.INSTALLED,
APPLICATION_STATUS.UPDATING,
APPLICATION_STATUS.UNINSTALLING,
];
// These are only used client-side
export const UPDATE_EVENT = 'update';
export const INSTALL_EVENT = 'install';
export const UNINSTALL_EVENT = 'uninstall';
export const HELM = 'helm';
export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
export const KNATIVE = 'knative';
export const RUNNER = 'runner';
export const CERT_MANAGER = 'cert_manager';
export const PROMETHEUS = 'prometheus';
export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS];
export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '../constants';
import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT, UNINSTALL_EVENT } from '../constants';
const {
NO_STATUS,
......@@ -11,6 +11,8 @@ const {
UPDATING,
UPDATED,
UPDATE_ERRORED,
UNINSTALLING,
UNINSTALL_ERRORED,
} = APPLICATION_STATUS;
const applicationStateMachine = {
......@@ -52,6 +54,15 @@ const applicationStateMachine = {
updateFailed: true,
},
},
[UNINSTALLING]: {
target: UNINSTALLING,
},
[UNINSTALL_ERRORED]: {
target: INSTALLED,
effects: {
uninstallFailed: true,
},
},
},
},
[NOT_INSTALLABLE]: {
......@@ -97,6 +108,13 @@ const applicationStateMachine = {
updateSuccessful: false,
},
},
[UNINSTALL_EVENT]: {
target: UNINSTALLING,
effects: {
uninstallFailed: false,
uninstallSuccessful: false,
},
},
},
},
[UPDATING]: {
......@@ -116,6 +134,22 @@ const applicationStateMachine = {
},
},
},
[UNINSTALLING]: {
on: {
[INSTALLABLE]: {
target: INSTALLABLE,
effects: {
uninstallSuccessful: true,
},
},
[UNINSTALL_ERRORED]: {
target: INSTALLED,
effects: {
uninstallFailed: true,
},
},
},
},
};
/**
......
......@@ -29,6 +29,10 @@ export default class ClusterService {
return axios.patch(this.appUpdateEndpointMap[appId], params);
}
uninstallApplication(appId, params) {
return axios.delete(this.appInstallEndpointMap[appId], params);
}
static updateCluster(endpoint, data) {
return axios.put(endpoint, data);
}
......
......@@ -10,6 +10,7 @@ import {
APPLICATION_STATUS,
INSTALL_EVENT,
UPDATE_EVENT,
UNINSTALL_EVENT,
} from '../constants';
import transitionApplicationState from '../services/application_state_machine';
......@@ -21,6 +22,9 @@ const applicationInitialState = {
requestReason: null,
installed: false,
installFailed: false,
uninstallable: false,
uninstallFailed: false,
uninstallSuccessful: false,
};
export default class ClusterStore {
......@@ -116,6 +120,14 @@ export default class ClusterStore {
this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED);
}
uninstallApplication(appId) {
this.handleApplicationEvent(appId, UNINSTALL_EVENT);
}
notifyUninstallFailure(appId) {
this.handleApplicationEvent(appId, APPLICATION_STATUS.UNINSTALL_ERRORED);
}
handleApplicationEvent(appId, event) {
const currentAppState = this.state.applications[appId];
......@@ -141,6 +153,7 @@ export default class ClusterStore {
status_reason: statusReason,
version,
update_available: upgradeAvailable,
can_uninstall: uninstallable,
} = serverAppEntry;
const currentApplicationState = this.state.applications[appId] || {};
const nextApplicationState = transitionApplicationState(currentApplicationState, status);
......@@ -150,8 +163,7 @@ export default class ClusterStore {
...nextApplicationState,
statusReason,
installed: isApplicationInstalled(nextApplicationState.status),
// Make sure uninstallable is always false until this feature is unflagged
uninstallable: false,
uninstallable,
};
if (appId === INGRESS) {
......
......@@ -34,10 +34,10 @@
.modal-body {
background-color: $modal-body-bg;
line-height: $line-height-base;
min-height: $modal-body-height;
position: relative;
padding: #{3 * $grid-size} #{2 * $grid-size};
text-align: left;
white-space: normal;
.form-actions {
margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size};
......
---
title: Implement UI for uninstalling Cluster’s managed apps
merge_request: 27559
author:
type: added
......@@ -1984,6 +1984,9 @@ msgstr ""
msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|%{title} uninstalled successfully."
msgstr ""
msgid "ClusterIntegration|%{title} upgraded successfully."
msgstr ""
......@@ -2011,6 +2014,9 @@ msgstr ""
msgid "ClusterIntegration|Advanced options on this Kubernetes cluster's integration"
msgstr ""
msgid "ClusterIntegration|All data will be deleted and cannot be restored."
msgstr ""
msgid "ClusterIntegration|Alternatively"
msgstr ""
......@@ -2026,6 +2032,9 @@ msgstr ""
msgid "ClusterIntegration|An error occurred while trying to fetch zone machine types: %{error}"
msgstr ""
msgid "ClusterIntegration|Any running pipelines will be canceled."
msgstr ""
msgid "ClusterIntegration|Applications"
msgstr ""
......@@ -2317,6 +2326,9 @@ msgstr ""
msgid "ClusterIntegration|Request to begin installing failed"
msgstr ""
msgid "ClusterIntegration|Request to begin uninstalling failed"
msgstr ""
msgid "ClusterIntegration|Retry update"
msgstr ""
......@@ -2371,6 +2383,9 @@ msgstr ""
msgid "ClusterIntegration|Something went wrong while installing %{title}"
msgstr ""
msgid "ClusterIntegration|Something went wrong while uninstalling %{title}"
msgstr ""
msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain."
msgstr ""
......@@ -2380,6 +2395,15 @@ msgstr ""
msgid "ClusterIntegration|The URL used to access the Kubernetes API."
msgstr ""
msgid "ClusterIntegration|The associated IP will be deleted and cannot be restored."
msgstr ""
msgid "ClusterIntegration|The associated certifcate will be deleted and cannot be restored."
msgstr ""
msgid "ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored."
msgstr ""
msgid "ClusterIntegration|The endpoint is in the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time."
msgstr ""
......@@ -2395,6 +2419,9 @@ msgstr ""
msgid "ClusterIntegration|Toggle Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|Uninstall %{appTitle}"
msgstr ""
msgid "ClusterIntegration|Update failed. Please check the logs and try again."
msgstr ""
......@@ -2422,6 +2449,9 @@ msgstr ""
msgid "ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
msgid "ClusterIntegration|You are about to uninstall %{appTitle} from your cluster."
msgstr ""
msgid "ClusterIntegration|You must first install Helm Tiller before installing the applications below"
msgstr ""
......
import Clusters from '~/clusters/clusters_bundle';
import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX } from '~/clusters/constants';
import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX, APPLICATIONS } from '~/clusters/constants';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { loadHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout';
import $ from 'jquery';
const { INSTALLING, INSTALLABLE, INSTALLED } = APPLICATION_STATUS;
const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS;
describe('Clusters', () => {
setTestTimeout(1000);
......@@ -212,73 +212,61 @@ describe('Clusters', () => {
});
describe('installApplication', () => {
it('tries to install helm', () => {
it.each(APPLICATIONS)('tries to install %s', applicationId => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
cluster.store.state.applications.helm.status = INSTALLABLE;
cluster.store.state.applications[applicationId].status = INSTALLABLE;
cluster.installApplication({ id: 'helm' });
cluster.installApplication({ id: applicationId });
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined);
expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING);
expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined);
});
it('tries to install ingress', () => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
cluster.store.state.applications.ingress.status = INSTALLABLE;
cluster.installApplication({ id: 'ingress' });
expect(cluster.store.state.applications.ingress.status).toEqual(INSTALLING);
expect(cluster.store.state.applications.ingress.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined);
});
it('sets error request status when the request fails', () => {
jest
.spyOn(cluster.service, 'installApplication')
.mockRejectedValueOnce(new Error('STUBBED ERROR'));
it('tries to install runner', () => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
cluster.store.state.applications.helm.status = INSTALLABLE;
cluster.store.state.applications.runner.status = INSTALLABLE;
const promise = cluster.installApplication({ id: 'helm' });
cluster.installApplication({ id: 'runner' });
return promise.then(() => {
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE);
expect(cluster.store.state.applications.helm.installFailed).toBe(true);
expect(cluster.store.state.applications.runner.status).toEqual(INSTALLING);
expect(cluster.store.state.applications.runner.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined);
expect(cluster.store.state.applications.helm.requestReason).toBeDefined();
});
});
});
it('tries to install jupyter', () => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
describe('uninstallApplication', () => {
it.each(APPLICATIONS)('tries to uninstall %s', applicationId => {
jest.spyOn(cluster.service, 'uninstallApplication').mockResolvedValueOnce();
cluster.installApplication({
id: 'jupyter',
params: { hostname: cluster.store.state.applications.jupyter.hostname },
});
cluster.store.state.applications[applicationId].status = INSTALLED;
cluster.store.state.applications.jupyter.status = INSTALLABLE;
expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', {
hostname: cluster.store.state.applications.jupyter.hostname,
});
cluster.uninstallApplication({ id: applicationId });
expect(cluster.store.state.applications[applicationId].status).toEqual(UNINSTALLING);
expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null);
expect(cluster.service.uninstallApplication).toHaveBeenCalledWith(applicationId);
});
it('sets error request status when the request fails', () => {
it('sets error request status when the uninstall request fails', () => {
jest
.spyOn(cluster.service, 'installApplication')
.spyOn(cluster.service, 'uninstallApplication')
.mockRejectedValueOnce(new Error('STUBBED ERROR'));
cluster.store.state.applications.helm.status = INSTALLABLE;
cluster.store.state.applications.helm.status = INSTALLED;
const promise = cluster.installApplication({ id: 'helm' });
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalled();
const promise = cluster.uninstallApplication({ id: 'helm' });
return promise.then(() => {
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE);
expect(cluster.store.state.applications.helm.installFailed).toBe(true);
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLED);
expect(cluster.store.state.applications.helm.uninstallFailed).toBe(true);
expect(cluster.store.state.applications.helm.requestReason).toBeDefined();
});
......
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import eventHub from '~/clusters/event_hub';
import { APPLICATION_STATUS } from '~/clusters/constants';
import applicationRow from '~/clusters/components/application_row.vue';
import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { DEFAULT_APPLICATION_STATE } from '../services/mock_data';
......@@ -194,11 +197,52 @@ describe('Application Row', () => {
...DEFAULT_APPLICATION_STATE,
installed: true,
uninstallable: true,
status: APPLICATION_STATUS.NOT_INSTALLABLE,
});
const uninstallButton = vm.$el.querySelector('.js-cluster-application-uninstall-button');
expect(uninstallButton).toBeTruthy();
});
it('displays a success toast message if application uninstall was successful', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
title: 'GitLab Runner',
uninstallSuccessful: false,
});
vm.$toast = { show: jest.fn() };
vm.uninstallSuccessful = true;
return vm.$nextTick(() => {
expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner uninstalled successfully.');
});
});
});
describe('when confirmation modal triggers confirm event', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(ApplicationRow, {
propsData: {
...DEFAULT_APPLICATION_STATE,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('triggers uninstallApplication event', () => {
jest.spyOn(eventHub, '$emit');
wrapper.find(UninstallApplicationConfirmationModal).vm.$emit('confirm');
expect(eventHub.$emit).toHaveBeenCalledWith('uninstallApplication', {
id: DEFAULT_APPLICATION_STATE.id,
});
});
});
describe('Upgrade button', () => {
......@@ -304,7 +348,7 @@ describe('Application Row', () => {
vm.$toast = { show: jest.fn() };
vm.updateSuccessful = true;
vm.$nextTick(() => {
return vm.$nextTick(() => {
expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner upgraded successfully.');
});
});
......@@ -360,60 +404,88 @@ describe('Application Row', () => {
});
describe('Error block', () => {
it('does not show error block when there is no error', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: null,
});
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
describe('when nothing fails', () => {
it('does not show error block', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
});
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
expect(generalErrorMessage).toBeNull();
expect(generalErrorMessage).toBeNull();
});
});
it('shows status reason when install fails', () => {
describe('when install or uninstall fails', () => {
const statusReason = 'We broke it 0.0';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.ERROR,
statusReason,
installFailed: true,
const requestReason = 'We broke the request 0.0';
beforeEach(() => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.ERROR,
statusReason,
requestReason,
installFailed: true,
});
});
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
const statusErrorMessage = vm.$el.querySelector(
'.js-cluster-application-status-error-message',
);
expect(generalErrorMessage.textContent.trim()).toEqual(
`Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`,
);
it('shows status reason if it is available', () => {
const statusErrorMessage = vm.$el.querySelector(
'.js-cluster-application-status-error-message',
);
expect(statusErrorMessage.textContent.trim()).toEqual(statusReason);
});
it('shows request reason if it is available', () => {
const requestErrorMessage = vm.$el.querySelector(
'.js-cluster-application-request-error-message',
);
expect(requestErrorMessage.textContent.trim()).toEqual(requestReason);
});
});
describe('when install fails', () => {
beforeEach(() => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.ERROR,
installFailed: true,
});
});
expect(statusErrorMessage.textContent.trim()).toEqual(statusReason);
it('shows a general message indicating the installation failed', () => {
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
expect(generalErrorMessage.textContent.trim()).toEqual(
`Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`,
);
});
});
it('shows request reason when REQUEST_FAILURE', () => {
const requestReason = 'We broke thre request 0.0';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
installFailed: true,
requestReason,
describe('when uninstall fails', () => {
beforeEach(() => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.ERROR,
uninstallFailed: true,
});
});
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
const requestErrorMessage = vm.$el.querySelector(
'.js-cluster-application-request-error-message',
);
expect(generalErrorMessage.textContent.trim()).toEqual(
`Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`,
);
it('shows a general message indicating the uninstalling failed', () => {
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
expect(requestErrorMessage.textContent.trim()).toEqual(requestReason);
expect(generalErrorMessage.textContent.trim()).toEqual(
`Something went wrong while uninstalling ${DEFAULT_APPLICATION_STATE.title}`,
);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import UninstallApplicationButton from '~/clusters/components/uninstall_application_button.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { APPLICATION_STATUS } from '~/clusters/constants';
const { INSTALLED, UPDATING, UNINSTALLING } = APPLICATION_STATUS;
describe('UninstallApplicationButton', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(UninstallApplicationButton, {
propsData: { ...props },
});
};
afterEach(() => {
wrapper.destroy();
});
describe.each`
status | loading | disabled | label
${INSTALLED} | ${false} | ${false} | ${'Uninstall'}
${UPDATING} | ${false} | ${true} | ${'Uninstall'}
${UNINSTALLING} | ${true} | ${true} | ${'Uninstalling'}
`('when app status is $status', ({ loading, disabled, status, label }) => {
it(`renders a loading=${loading}, disabled=${disabled} button with label="${label}"`, () => {
createComponent({ status });
expect(wrapper.find(LoadingButton).props()).toMatchObject({ loading, disabled, label });
});
});
});
import { shallowMount } from '@vue/test-utils';
import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue';
import { GlModal } from '@gitlab/ui';
import { INGRESS } from '~/clusters/constants';
describe('UninstallApplicationConfirmationModal', () => {
let wrapper;
const appTitle = 'Ingress';
const createComponent = (props = {}) => {
wrapper = shallowMount(UninstallApplicationConfirmationModal, {
propsData: { ...props },
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
createComponent({ application: INGRESS, applicationTitle: appTitle });
});
it(`renders a modal with a title "Uninstall ${appTitle}"`, () => {
expect(wrapper.find(GlModal).attributes('title')).toEqual(`Uninstall ${appTitle}`);
});
it(`renders a modal with an ok button labeled "Uninstall ${appTitle}"`, () => {
expect(wrapper.find(GlModal).attributes('ok-title')).toEqual(`Uninstall ${appTitle}`);
});
it('triggers confirm event when ok button is clicked', () => {
wrapper.find(GlModal).vm.$emit('ok');
expect(wrapper.emitted('confirm')).toBeTruthy();
});
it('displays a warning text indicating the app will be uninstalled', () => {
expect(wrapper.text()).toContain(`You are about to uninstall ${appTitle} from your cluster.`);
});
it('displays a custom warning text depending on the application', () => {
expect(wrapper.text()).toContain(
`The associated load balancer and IP will be deleted and cannot be restored.`,
);
});
});
import transitionApplicationState from '~/clusters/services/application_state_machine';
import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '~/clusters/constants';
import {
APPLICATION_STATUS,
UNINSTALL_EVENT,
UPDATE_EVENT,
INSTALL_EVENT,
} from '~/clusters/constants';
const {
NO_STATUS,
......@@ -12,6 +17,8 @@ const {
UPDATING,
UPDATED,
UPDATE_ERRORED,
UNINSTALLING,
UNINSTALL_ERRORED,
} = APPLICATION_STATUS;
const NO_EFFECTS = 'no effects';
......@@ -21,16 +28,18 @@ describe('applicationStateMachine', () => {
describe(`current state is ${NO_STATUS}`, () => {
it.each`
expectedState | event | effects
${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS}
${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS}
${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS}
${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS}
${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS}
${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }}
${UPDATING} | ${UPDATING} | ${NO_EFFECTS}
${INSTALLED} | ${UPDATED} | ${NO_EFFECTS}
${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }}
expectedState | event | effects
${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS}
${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS}
${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS}
${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS}
${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS}
${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }}
${UPDATING} | ${UPDATING} | ${NO_EFFECTS}
${INSTALLED} | ${UPDATED} | ${NO_EFFECTS}
${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }}
${UNINSTALLING} | ${UNINSTALLING} | ${NO_EFFECTS}
${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
......@@ -99,8 +108,9 @@ describe('applicationStateMachine', () => {
describe(`current state is ${INSTALLED}`, () => {
it.each`
expectedState | event | effects
${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }}
expectedState | event | effects
${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }}
${UNINSTALLING} | ${UNINSTALL_EVENT} | ${{ uninstallFailed: false, uninstallSuccessful: false }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
......@@ -131,4 +141,22 @@ describe('applicationStateMachine', () => {
});
});
});
describe(`current state is ${UNINSTALLING}`, () => {
it.each`
expectedState | event | effects
${INSTALLABLE} | ${INSTALLABLE} | ${{ uninstallSuccessful: true }}
${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
status: UNINSTALLING,
};
expect(transitionApplicationState(currentAppState, event)).toEqual({
status: expectedState,
...effects,
});
});
});
});
......@@ -11,6 +11,7 @@ const CLUSTERS_MOCK_DATA = {
name: 'helm',
status: APPLICATION_STATUS.INSTALLABLE,
status_reason: null,
can_uninstall: false,
},
{
name: 'ingress',
......@@ -18,32 +19,38 @@ const CLUSTERS_MOCK_DATA = {
status_reason: 'Cannot connect',
external_ip: null,
external_hostname: null,
can_uninstall: false,
},
{
name: 'runner',
status: APPLICATION_STATUS.INSTALLING,
status_reason: null,
can_uninstall: false,
},
{
name: 'prometheus',
status: APPLICATION_STATUS.ERROR,
status_reason: 'Cannot connect',
can_uninstall: false,
},
{
name: 'jupyter',
status: APPLICATION_STATUS.INSTALLING,
status_reason: 'Cannot connect',
can_uninstall: false,
},
{
name: 'knative',
status: APPLICATION_STATUS.INSTALLING,
status_reason: 'Cannot connect',
can_uninstall: false,
},
{
name: 'cert_manager',
status: APPLICATION_STATUS.ERROR,
status_reason: 'Cannot connect',
email: 'test@example.com',
can_uninstall: false,
},
],
},
......
......@@ -63,6 +63,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: false,
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
},
ingress: {
title: 'Ingress',
......@@ -74,6 +76,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: true,
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
},
runner: {
title: 'GitLab Runner',
......@@ -89,6 +93,8 @@ describe('Clusters Store', () => {
updateFailed: false,
updateSuccessful: false,
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
},
prometheus: {
title: 'Prometheus',
......@@ -98,6 +104,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: true,
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
},
jupyter: {
title: 'JupyterHub',
......@@ -108,6 +116,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: false,
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
},
knative: {
title: 'Knative',
......@@ -121,6 +131,8 @@ describe('Clusters Store', () => {
installed: false,
installFailed: false,
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
},
cert_manager: {
title: 'Cert-Manager',
......@@ -131,6 +143,8 @@ describe('Clusters Store', () => {
email: mockResponseData.applications[6].email,
installed: false,
uninstallable: false,
uninstallSuccessful: false,
uninstallFailed: false,
},
},
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment