diff options
Diffstat (limited to 'app/assets/javascripts/alerts_settings')
25 files changed, 2077 insertions, 235 deletions
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue new file mode 100644 index 00000000000..f6474efcc1f --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -0,0 +1,221 @@ +<script> +import Vue from 'vue'; +import { + GlIcon, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +// Mocks will be removed when integrating with BE is ready +// data format is defined and will be the same as mocked (maybe with some minor changes) +// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 +import gitlabFieldsMock from './mocks/gitlabFields.json'; + +export const i18n = { + columns: { + gitlabKeyTitle: s__('AlertMappingBuilder|GitLab alert key'), + payloadKeyTitle: s__('AlertMappingBuilder|Payload alert key'), + fallbackKeyTitle: s__('AlertMappingBuilder|Define fallback'), + }, + selectMappingKey: s__('AlertMappingBuilder|Select key'), + makeSelection: s__('AlertMappingBuilder|Make selection'), + fallbackTooltip: s__( + 'AlertMappingBuilder|Title is a required field for alerts in GitLab. Should the payload field you specified not be available, specifiy which field we should use instead. ', + ), + noResults: __('No matching results'), +}; + +export default { + i18n, + components: { + GlIcon, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + }, + directives: { + GlTooltip, + }, + props: { + payloadFields: { + type: Array, + required: false, + default: () => [], + }, + mapping: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + gitlabFields: this.gitlabAlertFields, + }; + }, + inject: { + gitlabAlertFields: { + default: gitlabFieldsMock, + }, + }, + computed: { + mappingData() { + return this.gitlabFields.map(gitlabField => { + const mappingFields = this.payloadFields.filter(({ type }) => + type.some(t => gitlabField.compatibleTypes.includes(t)), + ); + + const foundMapping = this.mapping.find( + ({ alertFieldName }) => alertFieldName === gitlabField.name, + ); + + const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; + + return { + mapping: payloadAlertPaths, + fallback: fallbackAlertPaths, + searchTerm: '', + fallbackSearchTerm: '', + mappingFields, + ...gitlabField, + }; + }); + }, + }, + methods: { + setMapping(gitlabKey, mappingKey, valueKey) { + const fieldIndex = this.gitlabFields.findIndex(field => field.name === gitlabKey); + const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } }; + Vue.set(this.gitlabFields, fieldIndex, updatedField); + }, + setSearchTerm(search = '', searchFieldKey, gitlabKey) { + const fieldIndex = this.gitlabFields.findIndex(field => field.name === gitlabKey); + const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [searchFieldKey]: search } }; + Vue.set(this.gitlabFields, fieldIndex, updatedField); + }, + filterFields(searchTerm = '', fields) { + const search = searchTerm.toLowerCase(); + + return fields.filter(field => field.label.toLowerCase().includes(search)); + }, + isSelected(fieldValue, mapping) { + return fieldValue === mapping; + }, + selectedValue(name) { + return ( + this.payloadFields.find(item => item.name === name)?.label || + this.$options.i18n.makeSelection + ); + }, + getFieldValue({ label, type }) { + return `${label} (${type.join(__(' or '))})`; + }, + noResults(searchTerm, fields) { + return !this.filterFields(searchTerm, fields).length; + }, + }, +}; +</script> + +<template> + <div class="gl-display-table gl-w-full gl-mt-5"> + <div class="gl-display-table-row"> + <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + {{ $options.i18n.columns.gitlabKeyTitle }} + </h5> + <h5 class="gl-display-table-cell gl-py-3 gl-pr-3"> </h5> + <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + {{ $options.i18n.columns.payloadKeyTitle }} + </h5> + <h5 id="fallbackFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + {{ $options.i18n.columns.fallbackKeyTitle }} + <gl-icon + v-gl-tooltip + name="question" + class="gl-text-gray-500" + :title="$options.i18n.fallbackTooltip" + /> + </h5> + </div> + + <div + v-for="(gitlabField, index) in mappingData" + :key="gitlabField.name" + class="gl-display-table-row" + > + <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle"> + <gl-form-input + aria-labelledby="gitlabFieldsHeader" + disabled + :value="getFieldValue(gitlabField)" + /> + </div> + + <div class="gl-display-table-cell gl-py-3 gl-pr-3"> + <div class="right-arrow" :class="{ 'gl-vertical-align-middle': index === 0 }"> + <i class="right-arrow-head"></i> + </div> + </div> + + <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle"> + <gl-dropdown + :disabled="!gitlabField.mappingFields.length" + aria-labelledby="parsedFieldsHeader" + :text="selectedValue(gitlabField.mapping)" + class="gl-w-full" + :header-text="$options.i18n.selectMappingKey" + > + <gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.name)" /> + <gl-dropdown-item + v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)" + :key="`${mappingField.name}__mapping`" + :is-checked="isSelected(gitlabField.mapping, mappingField.name)" + is-check-item + @click="setMapping(gitlabField.name, mappingField.name, 'mapping')" + > + {{ mappingField.label }} + </gl-dropdown-item> + <gl-dropdown-item v-if="noResults(gitlabField.searchTerm, gitlabField.mappingFields)"> + {{ $options.i18n.noResults }} + </gl-dropdown-item> + </gl-dropdown> + </div> + + <div class="gl-display-table-cell gl-py-3 w-30p"> + <gl-dropdown + v-if="Boolean(gitlabField.numberOfFallbacks)" + :disabled="!gitlabField.mappingFields.length" + aria-labelledby="fallbackFieldsHeader" + :text="selectedValue(gitlabField.fallback)" + class="gl-w-full" + :header-text="$options.i18n.selectMappingKey" + > + <gl-search-box-by-type + @input="setSearchTerm($event, 'fallbackSearchTerm', gitlabField.name)" + /> + <gl-dropdown-item + v-for="mappingField in filterFields( + gitlabField.fallbackSearchTerm, + gitlabField.mappingFields, + )" + :key="`${mappingField.name}__fallback`" + :is-checked="isSelected(gitlabField.fallback, mappingField.name)" + is-check-item + @click="setMapping(gitlabField.name, mappingField.name, 'fallback')" + > + {{ mappingField.label }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="noResults(gitlabField.fallbackSearchTerm, gitlabField.mappingFields)" + > + {{ $options.i18n.noResults }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue b/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue new file mode 100644 index 00000000000..35b7fe84c5f --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue @@ -0,0 +1,32 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + components: { + GlLink, + GlSprintf, + }, + props: { + message: { + type: String, + required: true, + }, + link: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message"> + <template #link="{ content }"> + <gl-link class="gl-display-inline-block" :href="link" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue index 217442e6131..12c0409629f 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -1,8 +1,24 @@ <script> -import { GlTable, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + GlButtonGroup, + GlButton, + GlIcon, + GlLoadingIcon, + GlModal, + GlModalDirective, + GlTable, + GlTooltipDirective, + GlSprintf, +} from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import Tracking from '~/tracking'; -import { trackAlertIntergrationsViewsOptions } from '../constants'; +import { + trackAlertIntegrationsViewsOptions, + integrationToDeleteDefault, + typeSet, +} from '../constants'; +import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; export const i18n = { title: s__('AlertsIntegrations|Current integrations'), @@ -24,23 +40,36 @@ const bodyTrClass = export default { i18n, + typeSet, components: { - GlTable, + GlButtonGroup, + GlButton, GlIcon, + GlLoadingIcon, + GlModal, + GlTable, + GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, }, + mixins: [glFeatureFlagsMixin()], props: { integrations: { type: Array, required: false, default: () => [], }, + loading: { + type: Boolean, + required: false, + default: false, + }, }, fields: [ { - key: 'activated', + key: 'active', label: __('Status'), }, { @@ -51,22 +80,56 @@ export default { key: 'type', label: __('Type'), }, + { + key: 'actions', + thClass: `gl-text-center`, + tdClass: `gl-text-center`, + label: __('Actions'), + }, ], - computed: { - tbodyTrClass() { - return { - [bodyTrClass]: this.integrations.length, - }; + apollo: { + currentIntegration: { + query: getCurrentIntegrationQuery, }, }, + data() { + return { + integrationToDelete: integrationToDeleteDefault, + currentIntegration: null, + }; + }, mounted() { - this.trackPageViews(); + const callback = entries => { + const isVisible = entries.some(entry => entry.isIntersecting); + + if (isVisible) { + this.trackPageViews(); + this.observer.disconnect(); + } + }; + + this.observer = new IntersectionObserver(callback); + this.observer.observe(this.$el); }, methods: { + tbodyTrClass(item) { + return { + [bodyTrClass]: this.integrations.length, + 'gl-bg-blue-50': (item !== null && item.id) === this.currentIntegration?.id, + }; + }, trackPageViews() { - const { category, action } = trackAlertIntergrationsViewsOptions; + const { category, action } = trackAlertIntegrationsViewsOptions; Tracking.event(category, action); }, + setIntegrationToDelete({ name, id }) { + this.integrationToDelete.id = id; + this.integrationToDelete.name = name; + }, + deleteIntegration() { + this.$emit('delete-integration', { id: this.integrationToDelete.id }); + this.integrationToDelete = { ...integrationToDeleteDefault }; + }, }, }; </script> @@ -75,15 +138,16 @@ export default { <div class="incident-management-list"> <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5> <gl-table - :empty-text="$options.i18n.emptyState" + class="integration-list" :items="integrations" :fields="$options.fields" + :busy="loading" stacked="md" :tbody-tr-class="tbodyTrClass" show-empty > - <template #cell(activated)="{ item }"> - <span v-if="item.activated" data-testid="integration-activated-status"> + <template #cell(active)="{ item }"> + <span v-if="item.active" data-testid="integration-activated-status"> <gl-icon v-gl-tooltip name="check-circle-filled" @@ -104,6 +168,47 @@ export default { {{ $options.i18n.status.disabled.name }} </span> </template> + + <template #cell(actions)="{ item }"> + <gl-button-group v-if="glFeatures.httpIntegrationsList" class="gl-ml-3"> + <gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" /> + <gl-button + v-gl-modal.deleteIntegration + :disabled="item.type === $options.typeSet.prometheus" + icon="remove" + @click="setIntegrationToDelete(item)" + /> + </gl-button-group> + </template> + + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + + <template #empty> + <div + class="gl-border-t-solid gl-border-b-solid gl-border-1 gl-border gl-border-gray-100 mt-n3 gl-px-5" + > + <p class="gl-text-gray-400 gl-py-3 gl-my-3">{{ $options.i18n.emptyState }}</p> + </div> + </template> </gl-table> + <gl-modal + modal-id="deleteIntegration" + :title="s__('AlertSettings|Delete integration')" + :ok-title="s__('AlertSettings|Delete integration')" + ok-variant="danger" + @ok="deleteIntegration" + > + <gl-sprintf + :message=" + s__( + 'AlertsIntegrations|You have opted to delete the %{integrationName} integration. Do you want to proceed? It means you will no longer receive alerts from this endpoint in your alert list, and this action cannot be undone.', + ) + " + > + <template #integrationName>{{ integrationToDelete.name }}</template> + </gl-sprintf> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue new file mode 100644 index 00000000000..3656fc4d7ec --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue @@ -0,0 +1,661 @@ +<script> +import { + GlButton, + GlCollapse, + GlForm, + GlFormGroup, + GlFormSelect, + GlFormInput, + GlFormInputGroup, + GlFormTextarea, + GlModal, + GlModalDirective, + GlToggle, +} from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import MappingBuilder from './alert_mapping_builder.vue'; +import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue'; +import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; +import service from '../services'; +import { + integrationTypesNew, + JSON_VALIDATE_DELAY, + targetPrometheusUrlPlaceholder, + targetOpsgenieUrlPlaceholder, + typeSet, + sectionHash, +} from '../constants'; +// Mocks will be removed when integrating with BE is ready +// data format is defined and will be the same as mocked (maybe with some minor changes) +// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 +import mockedCustomMapping from './mocks/parsedMapping.json'; + +export default { + placeholders: { + prometheus: targetPrometheusUrlPlaceholder, + opsgenie: targetOpsgenieUrlPlaceholder, + }, + JSON_VALIDATE_DELAY, + typeSet, + i18n: { + integrationFormSteps: { + step1: { + label: s__('AlertSettings|1. Select integration type'), + enterprise: s__( + 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.', + ), + }, + step2: { + label: s__('AlertSettings|2. Name integration'), + placeholder: s__('AlertSettings|Enter integration name'), + }, + step3: { + label: s__('AlertSettings|3. Set up webhook'), + help: s__( + "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + ), + prometheusHelp: s__( + 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.', + ), + info: s__('AlertSettings|Authorization key'), + reset: s__('AlertSettings|Reset Key'), + }, + step4: { + label: s__('AlertSettings|4. Sample alert payload (optional)'), + help: s__( + 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).', + ), + prometheusHelp: s__( + 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).', + ), + placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), + resetHeader: s__('AlertSettings|Reset the mapping'), + resetBody: s__( + "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.", + ), + resetOk: s__('AlertSettings|Proceed with editing'), + editPayload: s__('AlertSettings|Edit payload'), + submitPayload: s__('AlertSettings|Submit payload'), + payloadParsedSucessMsg: s__( + 'AlertSettings|Sample payload has been parsed. You can now map the fields.', + ), + }, + step5: { + label: s__('AlertSettings|5. Map fields (optional)'), + intro: s__( + "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.", + ), + }, + prometheusFormUrl: { + label: s__('AlertSettings|Prometheus API base URL'), + help: s__('AlertSettings|URL cannot be blank and must start with http or https'), + }, + restKeyInfo: { + label: s__( + 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', + ), + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + opsgenie: { + label: s__('AlertSettings|2. Add link to your Opsgenie alert list'), + info: s__( + 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.', + ), + }, + }, + }, + components: { + ClipboardButton, + GlButton, + GlCollapse, + GlForm, + GlFormGroup, + GlFormInput, + GlFormInputGroup, + GlFormTextarea, + GlFormSelect, + GlModal, + GlToggle, + AlertSettingsFormHelpBlock, + MappingBuilder, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: { + generic: { + default: {}, + }, + prometheus: { + default: {}, + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + opsgenie: { + default: {}, + }, + }, + mixins: [glFeatureFlagsMixin()], + props: { + loading: { + type: Boolean, + required: true, + }, + canAddIntegration: { + type: Boolean, + required: true, + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + canManageOpsgenie: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + currentIntegration: { + query: getCurrentIntegrationQuery, + }, + }, + data() { + return { + selectedIntegration: integrationTypesNew[0].value, + active: false, + formVisible: false, + integrationTestPayload: { + json: null, + error: null, + }, + resetSamplePayloadConfirmed: false, + customMapping: null, + parsingPayload: false, + currentIntegration: null, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + isManagingOpsgenie: false, + }; + }, + computed: { + isPrometheus() { + return this.selectedIntegration === this.$options.typeSet.prometheus; + }, + jsonIsValid() { + return this.integrationTestPayload.error === null; + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + disabledIntegrations() { + const options = []; + if (this.opsgenie.active) { + options.push(typeSet.http, typeSet.prometheus); + } else if (!this.canManageOpsgenie) { + options.push(typeSet.opsgenie); + } + + return options; + }, + options() { + return integrationTypesNew.map(el => ({ + ...el, + disabled: this.disabledIntegrations.includes(el.value), + })); + }, + selectedIntegrationType() { + switch (this.selectedIntegration) { + case typeSet.http: + return this.generic; + case typeSet.prometheus: + return this.prometheus; + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + case typeSet.opsgenie: + return this.opsgenie; + default: + return {}; + } + }, + integrationForm() { + return { + name: this.currentIntegration?.name || '', + active: this.currentIntegration?.active || false, + token: this.currentIntegration?.token || this.selectedIntegrationType.token, + url: this.currentIntegration?.url || this.selectedIntegrationType.url, + apiUrl: this.currentIntegration?.apiUrl || '', + }; + }, + testAlertPayload() { + return { + data: this.integrationTestPayload.json, + endpoint: this.integrationForm.url, + token: this.integrationForm.token, + }; + }, + showMappingBuilder() { + return ( + this.glFeatures.multipleHttpIntegrationsCustomMapping && + this.selectedIntegration === typeSet.http + ); + }, + mappingBuilderFields() { + return this.customMapping?.samplePayload?.payloadAlerFields?.nodes; + }, + mappingBuilderMapping() { + return this.customMapping?.storedMapping?.nodes; + }, + hasSamplePayload() { + return Boolean(this.customMapping?.samplePayload); + }, + canEditPayload() { + return this.hasSamplePayload && !this.resetSamplePayloadConfirmed; + }, + isPayloadEditDisabled() { + return !this.active || this.canEditPayload; + }, + }, + watch: { + currentIntegration(val) { + if (val === null) { + return this.reset(); + } + this.selectedIntegration = val.type; + this.active = val.active; + if (val.type === typeSet.http) this.getIntegrationMapping(val.id); + return this.integrationTypeSelect(); + }, + }, + methods: { + integrationTypeSelect() { + if (this.selectedIntegration === integrationTypesNew[0].value) { + this.formVisible = false; + } else { + this.formVisible = true; + } + + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + if (this.canManageOpsgenie && this.selectedIntegration === typeSet.opsgenie) { + this.isManagingOpsgenie = true; + this.active = this.opsgenie.active; + this.integrationForm.apiUrl = this.opsgenie.opsgenieMvcTargetUrl; + } else { + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + this.isManagingOpsgenie = false; + } + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + submitWithOpsgenie() { + return service + .updateGenericActive({ + endpoint: this.opsgenie.formPath, + params: { + service: { + opsgenie_mvc_target_url: this.integrationForm.apiUrl, + opsgenie_mvc_enabled: this.active, + }, + }, + }) + .then(() => { + window.location.hash = sectionHash; + window.location.reload(); + }); + }, + submitWithTestPayload() { + return service + .updateTestAlert(this.testAlertPayload) + .then(() => { + this.submit(); + }) + .catch(() => { + this.$emit('test-payload-failure'); + }); + }, + submit() { + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + if (this.isManagingOpsgenie) { + return this.submitWithOpsgenie(); + } + + const { name, apiUrl } = this.integrationForm; + const variables = + this.selectedIntegration === typeSet.http + ? { name, active: this.active } + : { apiUrl, active: this.active }; + const integrationPayload = { type: this.selectedIntegration, variables }; + + if (this.currentIntegration) { + return this.$emit('update-integration', integrationPayload); + } + + return this.$emit('create-new-integration', integrationPayload); + }, + reset() { + this.selectedIntegration = integrationTypesNew[0].value; + this.integrationTypeSelect(); + + if (this.currentIntegration) { + return this.$emit('clear-current-integration'); + } + + return this.resetFormValues(); + }, + resetFormValues() { + this.integrationForm.name = ''; + this.integrationForm.apiUrl = ''; + this.integrationTestPayload = { + json: null, + error: null, + }; + this.active = false; + + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + this.isManagingOpsgenie = false; + }, + resetAuthKey() { + if (!this.currentIntegration) { + return; + } + + this.$emit('reset-token', { + type: this.selectedIntegration, + variables: { id: this.currentIntegration.id }, + }); + }, + validateJson() { + this.integrationTestPayload.error = null; + if (this.integrationTestPayload.json === '') { + return; + } + + try { + JSON.parse(this.integrationTestPayload.json); + } catch (e) { + this.integrationTestPayload.error = JSON.stringify(e.message); + } + }, + parseMapping() { + // TODO: replace with real BE mutation when ready; + this.parsingPayload = true; + + return new Promise(resolve => { + setTimeout(() => resolve(mockedCustomMapping), 1000); + }) + .then(res => { + const mapping = { ...res }; + delete mapping.storedMapping; + this.customMapping = res; + this.integrationTestPayload.json = res?.samplePayload.body; + this.resetSamplePayloadConfirmed = false; + + this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg); + }) + .finally(() => { + this.parsingPayload = false; + }); + }, + getIntegrationMapping() { + // TODO: replace with real BE mutation when ready; + return Promise.resolve(mockedCustomMapping).then(res => { + this.customMapping = res; + this.integrationTestPayload.json = res?.samplePayload.body; + }); + }, + }, +}; +</script> + +<template> + <gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset"> + <h5 class="gl-font-lg gl-my-5">{{ s__('AlertSettings|Add new integrations') }}</h5> + <gl-form-group + id="integration-type" + :label="$options.i18n.integrationFormSteps.step1.label" + label-for="integration-type" + > + <gl-form-select + v-model="selectedIntegration" + :disabled="currentIntegration !== null || !canAddIntegration" + :options="options" + @change="integrationTypeSelect" + /> + + <div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported"> + <alert-settings-form-help-block + :message="$options.i18n.integrationFormSteps.step1.enterprise" + link="https://about.gitlab.com/pricing" + /> + </div> + </gl-form-group> + <gl-collapse v-model="formVisible" class="gl-mt-3"> + <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 --> + <div v-if="isManagingOpsgenie"> + <gl-form-group + id="integration-webhook" + :label="$options.i18n.integrationFormSteps.opsgenie.label" + label-for="integration-webhook" + > + <span class="gl-my-4"> + {{ $options.i18n.integrationFormSteps.opsgenie.info }} + </span> + + <gl-toggle + v-model="active" + :is-loading="loading" + :label="__('Active')" + class="gl-my-4 gl-font-weight-normal" + /> + + <gl-form-input + id="opsgenie-opsgenieMvcTargetUrl" + v-model="integrationForm.apiUrl" + type="text" + :placeholder="$options.placeholders.opsgenie" + /> + + <span class="gl-text-gray-400 gl-my-1"> + {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }} + </span> + </gl-form-group> + </div> + <div v-else> + <gl-form-group + id="name-integration" + :label="$options.i18n.integrationFormSteps.step2.label" + label-for="name-integration" + > + <gl-form-input + v-model="integrationForm.name" + type="text" + :placeholder="$options.i18n.integrationFormSteps.step2.placeholder" + /> + </gl-form-group> + <gl-form-group + id="integration-webhook" + :label="$options.i18n.integrationFormSteps.step3.label" + label-for="integration-webhook" + > + <alert-settings-form-help-block + :message=" + isPrometheus + ? $options.i18n.integrationFormSteps.step3.prometheusHelp + : $options.i18n.integrationFormSteps.step3.help + " + link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" + /> + + <gl-toggle + v-model="active" + :is-loading="loading" + :label="__('Active')" + class="gl-my-4 gl-font-weight-normal" + /> + + <div v-if="isPrometheus" class="gl-my-4"> + <span class="gl-font-weight-bold"> + {{ $options.i18n.integrationFormSteps.prometheusFormUrl.label }} + </span> + + <gl-form-input + id="integration-apiUrl" + v-model="integrationForm.apiUrl" + type="text" + :placeholder="$options.placeholders.prometheus" + /> + + <span class="gl-text-gray-400"> + {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }} + </span> + </div> + + <div class="gl-my-4"> + <span class="gl-font-weight-bold"> + {{ s__('AlertSettings|Webhook URL') }} + </span> + + <gl-form-input-group id="url" readonly :value="integrationForm.url"> + <template #append> + <clipboard-button + :text="integrationForm.url || ''" + :title="__('Copy')" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + </div> + + <div class="gl-my-4"> + <span class="gl-font-weight-bold"> + {{ $options.i18n.integrationFormSteps.step3.info }} + </span> + + <gl-form-input-group + id="authorization-key" + class="gl-mb-3" + readonly + :value="integrationForm.token" + > + <template #append> + <clipboard-button + :text="integrationForm.token || ''" + :title="__('Copy')" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + + <gl-button v-gl-modal.authKeyModal :disabled="!active"> + {{ $options.i18n.integrationFormSteps.step3.reset }} + </gl-button> + <gl-modal + modal-id="authKeyModal" + :title="$options.i18n.integrationFormSteps.step3.reset" + :ok-title="$options.i18n.integrationFormSteps.step3.reset" + ok-variant="danger" + @ok="resetAuthKey" + > + {{ $options.i18n.integrationFormSteps.restKeyInfo.label }} + </gl-modal> + </div> + </gl-form-group> + + <gl-form-group + id="test-integration" + :label="$options.i18n.integrationFormSteps.step4.label" + label-for="test-integration" + :class="{ 'gl-mb-0!': showMappingBuilder }" + :invalid-feedback="integrationTestPayload.error" + > + <alert-settings-form-help-block + :message=" + isPrometheus || !showMappingBuilder + ? $options.i18n.integrationFormSteps.step4.prometheusHelp + : $options.i18n.integrationFormSteps.step4.help + " + :link="generic.alertsUsageUrl" + /> + + <gl-form-textarea + id="test-payload" + v-model.trim="integrationTestPayload.json" + :disabled="isPayloadEditDisabled" + :state="jsonIsValid" + :placeholder="$options.i18n.integrationFormSteps.step4.placeholder" + class="gl-my-3" + :debounce="$options.JSON_VALIDATE_DELAY" + rows="6" + max-rows="10" + @input="validateJson" + /> + </gl-form-group> + + <template v-if="showMappingBuilder"> + <gl-button + v-if="canEditPayload" + v-gl-modal.resetPayloadModal + data-testid="payload-action-btn" + :disabled="!active" + class="gl-mt-3" + > + {{ $options.i18n.integrationFormSteps.step4.editPayload }} + </gl-button> + + <gl-button + v-else + data-testid="payload-action-btn" + :class="{ 'gl-mt-3': integrationTestPayload.error }" + :disabled="!active" + :loading="parsingPayload" + @click="parseMapping" + > + {{ $options.i18n.integrationFormSteps.step4.submitPayload }} + </gl-button> + <gl-modal + modal-id="resetPayloadModal" + :title="$options.i18n.integrationFormSteps.step4.resetHeader" + :ok-title="$options.i18n.integrationFormSteps.step4.resetOk" + ok-variant="danger" + @ok="resetSamplePayloadConfirmed = true" + > + {{ $options.i18n.integrationFormSteps.step4.resetBody }} + </gl-modal> + </template> + + <gl-form-group + v-if="showMappingBuilder" + id="mapping-builder" + class="gl-mt-5" + :label="$options.i18n.integrationFormSteps.step5.label" + label-for="mapping-builder" + > + <span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span> + <mapping-builder + :payload-fields="mappingBuilderFields" + :mapping="mappingBuilderMapping" + /> + </gl-form-group> + </div> + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button + type="submit" + variant="success" + class="js-no-auto-disable" + data-testid="integration-form-submit" + >{{ s__('AlertSettings|Save integration') }} + </gl-button> + <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 --> + <gl-button + v-if="!isManagingOpsgenie" + data-testid="integration-test-and-submit" + :disabled="Boolean(integrationTestPayload.error)" + category="secondary" + variant="success" + class="gl-mx-3 js-no-auto-disable" + @click="submitWithTestPayload" + >{{ s__('AlertSettings|Save and test payload') }}</gl-button + > + <gl-button + type="reset" + class="js-no-auto-disable" + :class="{ 'gl-ml-3': isManagingOpsgenie }" + >{{ __('Cancel') }}</gl-button + > + </div> + </gl-collapse> + </gl-form> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue index f885afae378..0246315bdc5 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue @@ -14,16 +14,14 @@ import { GlFormSelect, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { s__ } from '~/locale'; import { doesHashExistInUrl } from '~/lib/utils/url_utility'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -import IntegrationsList from './alerts_integrations_list.vue'; import csrf from '~/lib/utils/csrf'; import service from '../services'; import { i18n, - serviceOptions, + integrationTypes, JSON_VALIDATE_DELAY, targetPrometheusUrlPlaceholder, targetOpsgenieUrlPlaceholder, @@ -50,7 +48,6 @@ export default { GlSprintf, ClipboardButton, ToggleButton, - IntegrationsList, }, directives: { 'gl-modal': GlModalDirective, @@ -59,10 +56,10 @@ export default { data() { return { loading: false, - selectedEndpoint: serviceOptions[0].value, - options: serviceOptions, + selectedIntegration: integrationTypes[0].value, + options: integrationTypes, active: false, - authKey: '', + token: '', targetUrl: '', feedback: { variant: 'danger', @@ -91,34 +88,34 @@ export default { ]; }, isPrometheus() { - return this.selectedEndpoint === 'prometheus'; + return this.selectedIntegration === 'PROMETHEUS'; }, isOpsgenie() { - return this.selectedEndpoint === 'opsgenie'; + return this.selectedIntegration === 'OPSGENIE'; }, - selectedService() { - switch (this.selectedEndpoint) { - case 'generic': { + selectedIntegrationType() { + switch (this.selectedIntegration) { + case 'HTTP': { return { url: this.generic.url, - authKey: this.generic.authorizationKey, - activated: this.generic.activated, + token: this.generic.token, + active: this.generic.active, resetKey: this.resetKey.bind(this), }; } - case 'prometheus': { + case 'PROMETHEUS': { return { - url: this.prometheus.prometheusUrl, - authKey: this.prometheus.authorizationKey, - activated: this.prometheus.activated, - resetKey: this.resetKey.bind(this, 'prometheus'), + url: this.prometheus.url, + token: this.prometheus.token, + active: this.prometheus.active, + resetKey: this.resetKey.bind(this, 'PROMETHEUS'), targetUrl: this.prometheus.prometheusApiUrl, }; } - case 'opsgenie': { + case 'OPSGENIE': { return { targetUrl: this.opsgenie.opsgenieMvcTargetUrl, - activated: this.opsgenie.activated, + active: this.opsgenie.active, }; } default: { @@ -152,43 +149,25 @@ export default { ? this.$options.targetOpsgenieUrlPlaceholder : this.$options.targetPrometheusUrlPlaceholder; }, - integrations() { - return [ - { - name: s__('AlertSettings|HTTP endpoint'), - type: s__('AlertsIntegrations|HTTP endpoint'), - activated: this.generic.activated, - }, - { - name: s__('AlertSettings|External Prometheus'), - type: s__('AlertsIntegrations|Prometheus'), - activated: this.prometheus.activated, - }, - ]; - }, }, watch: { 'testAlert.json': debounce(function debouncedJsonValidate() { this.validateJson(); }, JSON_VALIDATE_DELAY), targetUrl(oldVal, newVal) { - if (newVal && oldVal !== this.selectedService.targetUrl) { + if (newVal && oldVal !== this.selectedIntegrationType.targetUrl) { this.canSaveForm = true; } }, }, mounted() { - if ( - this.prometheus.activated || - this.generic.activated || - !this.opsgenie.opsgenieMvcIsAvailable - ) { + if (this.prometheus.active || this.generic.active || !this.opsgenie.opsgenieMvcIsAvailable) { this.removeOpsGenieOption(); - } else if (this.opsgenie.activated) { + } else if (this.opsgenie.active) { this.setOpsgenieAsDefault(); } - this.active = this.selectedService.activated; - this.authKey = this.selectedService.authKey ?? ''; + this.active = this.selectedIntegrationType.active; + this.token = this.selectedIntegrationType.token ?? ''; }, methods: { createUserErrorMessage(errors = {}) { @@ -200,19 +179,19 @@ export default { }, setOpsgenieAsDefault() { this.options = this.options.map(el => { - if (el.value !== 'opsgenie') { + if (el.value !== 'OPSGENIE') { return { ...el, disabled: true }; } return { ...el, disabled: false }; }); - this.selectedEndpoint = this.options.find(({ value }) => value === 'opsgenie').value; + this.selectedIntegration = this.options.find(({ value }) => value === 'OPSGENIE').value; if (this.targetUrl === null) { - this.targetUrl = this.selectedService.targetUrl; + this.targetUrl = this.selectedIntegrationType.targetUrl; } }, removeOpsGenieOption() { this.options = this.options.map(el => { - if (el.value !== 'opsgenie') { + if (el.value !== 'OPSGENIE') { return { ...el, disabled: false }; } return { ...el, disabled: true }; @@ -220,8 +199,8 @@ export default { }, resetFormValues() { this.testAlert.json = null; - this.targetUrl = this.selectedService.targetUrl; - this.active = this.selectedService.activated; + this.targetUrl = this.selectedIntegrationType.targetUrl; + this.active = this.selectedIntegrationType.active; }, dismissFeedback() { this.serverError = null; @@ -229,12 +208,12 @@ export default { this.isFeedbackDismissed = false; }, resetKey(key) { - const fn = key === 'prometheus' ? this.resetPrometheusKey() : this.resetGenericKey(); + const fn = key === 'PROMETHEUS' ? this.resetPrometheusKey() : this.resetGenericKey(); return fn .then(({ data: { token } }) => { - this.authKey = token; - this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); + this.token = token; + this.setFeedback({ feedbackMessage: this.$options.i18n.tokenRest, variant: 'success' }); }) .catch(() => { this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); @@ -259,9 +238,10 @@ export default { }, toggleActivated(value) { this.loading = true; + const path = this.isOpsgenie ? this.opsgenie.formPath : this.generic.formPath; return service .updateGenericActive({ - endpoint: this[this.selectedEndpoint].formPath, + endpoint: path, params: this.isOpsgenie ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } } : { service: { active: value } }, @@ -331,9 +311,9 @@ export default { this.validateJson(); return service .updateTestAlert({ - endpoint: this.selectedService.url, + endpoint: this.selectedIntegrationType.url, data: this.testAlert.json, - authKey: this.selectedService.authKey, + token: this.selectedIntegrationType.token, }) .then(() => { this.setFeedback({ @@ -358,11 +338,11 @@ export default { onReset() { this.testAlert.json = null; this.dismissFeedback(); - this.targetUrl = this.selectedService.targetUrl; + this.targetUrl = this.selectedIntegrationType.targetUrl; if (this.canSaveForm) { this.canSaveForm = false; - this.active = this.selectedService.activated; + this.active = this.selectedIntegrationType.active; } }, }, @@ -370,153 +350,145 @@ export default { </script> <template> - <div> - <integrations-list :integrations="integrations" /> - - <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> - <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5> + <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> + <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5> - <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback"> - {{ feedback.feedbackMessage }} - <br /> - <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i> - <gl-button - v-if="showAlertSave" - variant="danger" - category="primary" - class="gl-display-block gl-mt-3" - @click="toggle(active)" - > - {{ __('Save anyway') }} - </gl-button> - </gl-alert> + <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback"> + {{ feedback.feedbackMessage }} + <br /> + <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i> + <gl-button + v-if="showAlertSave" + variant="danger" + category="primary" + class="gl-display-block gl-mt-3" + @click="toggle(active)" + > + {{ __('Save anyway') }} + </gl-button> + </gl-alert> - <div data-testid="alert-settings-description"> - <p v-for="section in sections" :key="section.text"> - <gl-sprintf :message="section.text"> - <template #link="{ content }"> - <gl-link :href="section.url" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - </div> + <div data-testid="alert-settings-description"> + <p v-for="section in sections" :key="section.text"> + <gl-sprintf :message="section.text"> + <template #link="{ content }"> + <gl-link :href="section.url" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> - <gl-form-group label-for="integration-type" :label="$options.i18n.integration"> - <gl-form-select - id="integration-type" - v-model="selectedEndpoint" - :options="options" - data-testid="alert-settings-select" - @change="resetFormValues" - /> + <gl-form-group label-for="integration-type" :label="$options.i18n.integration"> + <gl-form-select + id="integration-type" + v-model="selectedIntegration" + :options="options" + data-testid="alert-settings-select" + @change="resetFormValues" + /> + <span class="gl-text-gray-500"> + <gl-sprintf :message="$options.i18n.integrationsInfo"> + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + href="https://gitlab.com/groups/gitlab-org/-/epics/4390" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </span> + </gl-form-group> + <gl-form-group :label="$options.i18n.activeLabel" label-for="active"> + <toggle-button + id="active" + :disabled-input="loading" + :is-loading="loading" + :value="active" + @change="toggleService" + /> + </gl-form-group> + <gl-form-group + v-if="isOpsgenie || isPrometheus" + :label="$options.i18n.apiBaseUrlLabel" + label-for="api-url" + > + <gl-form-input + id="api-url" + v-model="targetUrl" + type="url" + :placeholder="baseUrlPlaceholder" + :disabled="!active" + /> + <span class="gl-text-gray-500"> + {{ $options.i18n.apiBaseUrlHelpText }} + </span> + </gl-form-group> + <template v-if="!isOpsgenie"> + <gl-form-group :label="$options.i18n.urlLabel" label-for="url"> + <gl-form-input-group id="url" readonly :value="selectedIntegrationType.url"> + <template #append> + <clipboard-button + :text="selectedIntegrationType.url" + :title="$options.i18n.copyToClipboard" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> <span class="gl-text-gray-500"> - <gl-sprintf :message="$options.i18n.integrationsInfo"> - <template #link="{ content }"> - <gl-link - class="gl-display-inline-block" - href="https://gitlab.com/groups/gitlab-org/-/epics/4390" - target="_blank" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> + {{ prometheusInfo }} </span> </gl-form-group> - <gl-form-group :label="$options.i18n.activeLabel" label-for="activated"> - <toggle-button - id="activated" - :disabled-input="loading" - :is-loading="loading" - :value="active" - @change="toggleService" - /> + <gl-form-group :label="$options.i18n.tokenLabel" label-for="authorization-key"> + <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="token"> + <template #append> + <clipboard-button + :text="token" + :title="$options.i18n.copyToClipboard" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + <gl-button v-gl-modal.tokenModal :disabled="!active" class="gl-mt-3">{{ + $options.i18n.resetKey + }}</gl-button> + <gl-modal + modal-id="tokenModal" + :title="$options.i18n.resetKey" + :ok-title="$options.i18n.resetKey" + ok-variant="danger" + @ok="selectedIntegrationType.resetKey" + > + {{ $options.i18n.restKeyInfo }} + </gl-modal> </gl-form-group> <gl-form-group - v-if="isOpsgenie || isPrometheus" - :label="$options.i18n.apiBaseUrlLabel" - label-for="api-url" + :label="$options.i18n.alertJson" + label-for="alert-json" + :invalid-feedback="testAlert.error" > - <gl-form-input - id="api-url" - v-model="targetUrl" - type="url" - :placeholder="baseUrlPlaceholder" + <gl-form-textarea + id="alert-json" + v-model.trim="testAlert.json" :disabled="!active" + :state="jsonIsValid" + :placeholder="$options.i18n.alertJsonPlaceholder" + rows="6" + max-rows="10" /> - <span class="gl-text-gray-500"> - {{ $options.i18n.apiBaseUrlHelpText }} - </span> </gl-form-group> - <template v-if="!isOpsgenie"> - <gl-form-group :label="$options.i18n.urlLabel" label-for="url"> - <gl-form-input-group id="url" readonly :value="selectedService.url"> - <template #append> - <clipboard-button - :text="selectedService.url" - :title="$options.i18n.copyToClipboard" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> - <span class="gl-text-gray-500"> - {{ prometheusInfo }} - </span> - </gl-form-group> - <gl-form-group :label="$options.i18n.authKeyLabel" label-for="authorization-key"> - <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="authKey"> - <template #append> - <clipboard-button - :text="authKey" - :title="$options.i18n.copyToClipboard" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> - <gl-button v-gl-modal.authKeyModal :disabled="!active" class="gl-mt-3">{{ - $options.i18n.resetKey - }}</gl-button> - <gl-modal - modal-id="authKeyModal" - :title="$options.i18n.resetKey" - :ok-title="$options.i18n.resetKey" - ok-variant="danger" - @ok="selectedService.resetKey" - > - {{ $options.i18n.restKeyInfo }} - </gl-modal> - </gl-form-group> - <gl-form-group - :label="$options.i18n.alertJson" - label-for="alert-json" - :invalid-feedback="testAlert.error" - > - <gl-form-textarea - id="alert-json" - v-model.trim="testAlert.json" - :disabled="!active" - :state="jsonIsValid" - :placeholder="$options.i18n.alertJsonPlaceholder" - rows="6" - max-rows="10" - /> - </gl-form-group> - <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ - $options.i18n.testAlertInfo - }}</gl-button> - </template> - <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"> - <gl-button - variant="success" - category="primary" - :disabled="!canSaveConfig" - @click="onSubmit" - > - {{ __('Save changes') }} - </gl-button> - <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset"> - {{ __('Cancel') }} - </gl-button> - </div> - </gl-form> - </div> + + <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ + $options.i18n.testAlertInfo + }}</gl-button> + </template> + <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"> + <gl-button variant="success" category="primary" :disabled="!canSaveConfig" @click="onSubmit"> + {{ __('Save changes') }} + </gl-button> + <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset"> + {{ __('Cancel') }} + </gl-button> + </div> + </gl-form> </template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue new file mode 100644 index 00000000000..1ffc2f80148 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -0,0 +1,331 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { fetchPolicies } from '~/lib/graphql'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql'; +import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; +import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql'; +import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql'; +import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql'; +import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql'; +import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql'; +import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql'; +import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql'; +import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql'; +import IntegrationsList from './alerts_integrations_list.vue'; +import SettingsFormOld from './alerts_settings_form_old.vue'; +import SettingsFormNew from './alerts_settings_form_new.vue'; +import { typeSet } from '../constants'; +import { + updateStoreAfterIntegrationDelete, + updateStoreAfterIntegrationAdd, +} from '../utils/cache_updates'; +import { + DELETE_INTEGRATION_ERROR, + ADD_INTEGRATION_ERROR, + RESET_INTEGRATION_TOKEN_ERROR, + UPDATE_INTEGRATION_ERROR, + INTEGRATION_PAYLOAD_TEST_ERROR, +} from '../utils/error_messages'; + +export default { + typeSet, + i18n: { + changesSaved: s__( + 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.', + ), + integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'), + }, + components: { + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + GlAlert, + GlLink, + GlSprintf, + IntegrationsList, + SettingsFormOld, + SettingsFormNew, + }, + mixins: [glFeatureFlagsMixin()], + inject: { + generic: { + default: {}, + }, + prometheus: { + default: {}, + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + opsgenie: { + default: {}, + }, + projectPath: { + default: '', + }, + multiIntegrations: { + default: false, + }, + }, + apollo: { + integrations: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: getIntegrationsQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + const { alertManagementIntegrations: { nodes: list = [] } = {} } = data.project || {}; + + return { + list, + }; + }, + error(err) { + createFlash({ message: err }); + }, + }, + currentIntegration: { + query: getCurrentIntegrationQuery, + }, + }, + data() { + return { + isUpdating: false, + integrations: {}, + currentIntegration: null, + }; + }, + computed: { + loading() { + return this.$apollo.queries.integrations.loading; + }, + integrationsOptionsOld() { + return [ + { + name: s__('AlertSettings|HTTP endpoint'), + type: s__('AlertsIntegrations|HTTP endpoint'), + active: this.generic.active, + }, + { + name: s__('AlertSettings|External Prometheus'), + type: s__('AlertsIntegrations|Prometheus'), + active: this.prometheus.active, + }, + ]; + }, + canAddIntegration() { + return this.multiIntegrations || this.integrations?.list?.length < 2; + }, + canManageOpsgenie() { + return ( + this.integrations?.list?.every(({ active }) => active === false) || + this.integrations?.list?.length === 0 + ); + }, + }, + methods: { + createNewIntegration({ type, variables }) { + const { projectPath } = this; + + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: + type === this.$options.typeSet.http + ? createHttpIntegrationMutation + : createPrometheusIntegrationMutation, + variables: { + ...variables, + projectPath, + }, + update(store, { data }) { + updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath }); + }, + }) + .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => { + const error = httpIntegrationCreate?.errors[0] || prometheusIntegrationCreate?.errors[0]; + if (error) { + return createFlash({ message: error }); + } + return createFlash({ + message: this.$options.i18n.changesSaved, + type: FLASH_TYPES.SUCCESS, + }); + }) + .catch(() => { + createFlash({ message: ADD_INTEGRATION_ERROR }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + updateIntegration({ type, variables }) { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: + type === this.$options.typeSet.http + ? updateHttpIntegrationMutation + : updatePrometheusIntegrationMutation, + variables: { + ...variables, + id: this.currentIntegration.id, + }, + }) + .then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => { + const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0]; + if (error) { + return createFlash({ message: error }); + } + return createFlash({ + message: this.$options.i18n.changesSaved, + type: FLASH_TYPES.SUCCESS, + }); + }) + .catch(() => { + createFlash({ message: UPDATE_INTEGRATION_ERROR }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + resetToken({ type, variables }) { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: + type === this.$options.typeSet.http + ? resetHttpTokenMutation + : resetPrometheusTokenMutation, + variables, + }) + .then( + ({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => { + const error = + httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0]; + if (error) { + return createFlash({ message: error }); + } + + const integration = + httpIntegrationResetToken?.integration || + prometheusIntegrationResetToken?.integration; + this.currentIntegration = integration; + + return createFlash({ + message: this.$options.i18n.changesSaved, + type: FLASH_TYPES.SUCCESS, + }); + }, + ) + .catch(() => { + createFlash({ message: RESET_INTEGRATION_TOKEN_ERROR }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + editIntegration({ id }) { + const currentIntegration = this.integrations.list.find(integration => integration.id === id); + this.$apollo.mutate({ + mutation: updateCurrentIntergrationMutation, + variables: { + id: currentIntegration.id, + name: currentIntegration.name, + active: currentIntegration.active, + token: currentIntegration.token, + type: currentIntegration.type, + url: currentIntegration.url, + apiUrl: currentIntegration.apiUrl, + }, + }); + }, + deleteIntegration({ id }) { + const { projectPath } = this; + + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: destroyHttpIntegrationMutation, + variables: { + id, + }, + update(store, { data }) { + updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath }); + }, + }) + .then(({ data: { httpIntegrationDestroy } = {} } = {}) => { + const error = httpIntegrationDestroy?.errors[0]; + if (error) { + return createFlash({ message: error }); + } + this.clearCurrentIntegration(); + return createFlash({ + message: this.$options.i18n.integrationRemoved, + type: FLASH_TYPES.SUCCESS, + }); + }) + .catch(() => { + createFlash({ message: DELETE_INTEGRATION_ERROR }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + clearCurrentIntegration() { + this.$apollo.mutate({ + mutation: updateCurrentIntergrationMutation, + variables: {}, + }); + }, + testPayloadFailure() { + createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + }, + }, +}; +</script> + +<template> + <div> + <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 --> + <gl-alert v-if="opsgenie.active" :dismissible="false" variant="tip"> + <gl-sprintf + :message=" + s__( + 'AlertSettings|We will soon be introducing the ability to create multiple unique HTTP endpoints. When this functionality is live, you will be able to configure an integration with Opsgenie to surface Opsgenie alerts in GitLab. This will replace the current Opsgenie integration which will be deprecated. %{linkStart}More Information%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + href="https://gitlab.com/gitlab-org/gitlab/-/issues/273657" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </gl-alert> + <integrations-list + v-else + :integrations="glFeatures.httpIntegrationsList ? integrations.list : integrationsOptionsOld" + :loading="loading" + @edit-integration="editIntegration" + @delete-integration="deleteIntegration" + /> + <settings-form-new + v-if="glFeatures.httpIntegrationsList" + :loading="isUpdating" + :can-add-integration="canAddIntegration" + :can-manage-opsgenie="canManageOpsgenie" + @create-new-integration="createNewIntegration" + @update-integration="updateIntegration" + @reset-token="resetToken" + @clear-current-integration="clearCurrentIntegration" + @test-payload-failure="testPayloadFailure" + /> + <settings-form-old v-else /> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json new file mode 100644 index 00000000000..ac559a30eda --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json @@ -0,0 +1,112 @@ +[ + { + "name": "title", + "label": "Title", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ], + "numberOfFallbacks": 1 + }, + { + "name": "description", + "label": "Description", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "startTime", + "label": "Start time", + "type": [ + "DateTime" + ], + "compatibleTypes": [ + "Number", + "DateTime" + ] + }, + { + "name": "service", + "label": "Service", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "monitoringTool", + "label": "Monitoring tool", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "hosts", + "label": "Hosts", + "type": [ + "String", + "Array" + ], + "compatibleTypes": [ + "String", + "Array", + "Number", + "DateTime" + ] + }, + { + "name": "severity", + "label": "Severity", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "fingerprint", + "label": "Fingerprint", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "environment", + "label": "Environment", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + } +] diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json new file mode 100644 index 00000000000..5326678155d --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json @@ -0,0 +1,121 @@ +{ + "samplePayload": { + "body": "{\n \"dashboardId\":1,\n \"evalMatches\":[\n {\n \"value\":1,\n \"metric\":\"Count\",\n \"tags\":{}\n }\n ],\n \"imageUrl\":\"https://grafana.com/static/assets/img/blog/mixed_styles.png\",\n \"message\":\"Notification Message\",\n \"orgId\":1,\n \"panelId\":2,\n \"ruleId\":1,\n \"ruleName\":\"Panel Title alert\",\n \"ruleUrl\":\"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\\u0026edit\\u0026tab=alert\\u0026panelId=2\\u0026orgId=1\",\n \"state\":\"alerting\",\n \"tags\":{\n \"tag name\":\"tag value\"\n },\n \"title\":\"[Alerting] Panel Title alert\"\n}\n", + "payloadAlerFields": { + "nodes": [ + { + "name": "dashboardId", + "label": "Dashboard Id", + "type": [ + "Number" + ] + }, + { + "name": "evalMatches", + "label": "Eval Matches", + "type": [ + "Array" + ] + }, + { + "name": "createdAt", + "label": "Created At", + "type": [ + "DateTime" + ] + }, + { + "name": "imageUrl", + "label": "Image Url", + "type": [ + "String" + ] + }, + { + "name": "message", + "label": "Message", + "type": [ + "String" + ] + }, + { + "name": "orgId", + "label": "Org Id", + "type": [ + "Number" + ] + }, + { + "name": "panelId", + "label": "Panel Id", + "type": [ + "String" + ] + }, + { + "name": "ruleId", + "label": "Rule Id", + "type": [ + "Number" + ] + }, + { + "name": "ruleName", + "label": "Rule Name", + "type": [ + "String" + ] + }, + { + "name": "ruleUrl", + "label": "Rule Url", + "type": [ + "String" + ] + }, + { + "name": "state", + "label": "State", + "type": [ + "String" + ] + }, + { + "name": "title", + "label": "Title", + "type": [ + "String" + ] + }, + { + "name": "tags", + "label": "Tags", + "type": [ + "Object" + ] + } + ] + } + }, + "storedMapping": { + "nodes": [ + { + "alertFieldName": "title", + "payloadAlertPaths": "title", + "fallbackAlertPaths": "ruleUrl" + }, + { + "alertFieldName": "description", + "payloadAlertPaths": "message" + }, + { + "alertFieldName": "hosts", + "payloadAlertPaths": "evalMatches" + }, + { + "alertFieldName": "startTime", + "payloadAlertPaths": "createdAt" + } + ] + } +} diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index 4220dbde0c7..e30dc2ad553 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -1,5 +1,6 @@ import { s__ } from '~/locale'; +// TODO: Remove this as part of the form old removal export const i18n = { usageSection: s__( 'AlertSettings|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.', @@ -17,11 +18,10 @@ export const i18n = { changesSaved: s__('AlertSettings|Your integration was successfully updated.'), prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'), integrationsInfo: s__( - 'AlertSettings|Learn more about our improvements for %{linkStart}integrations%{linkEnd}', + 'AlertSettings|Learn more about our our upcoming %{linkStart}integrations%{linkEnd}', ), resetKey: s__('AlertSettings|Reset key'), copyToClipboard: s__('AlertSettings|Copy'), - integrationsLabel: s__('AlertSettings|Add new integrations'), apiBaseUrlLabel: s__('AlertSettings|API URL'), authKeyLabel: s__('AlertSettings|Authorization key'), urlLabel: s__('AlertSettings|Webhook URL'), @@ -40,12 +40,26 @@ export const i18n = { integration: s__('AlertSettings|Integration'), }; -export const serviceOptions = [ - { value: 'generic', text: s__('AlertSettings|HTTP Endpoint') }, - { value: 'prometheus', text: s__('AlertSettings|External Prometheus') }, - { value: 'opsgenie', text: s__('AlertSettings|Opsgenie') }, +// TODO: Delete as part of old form removal in 13.6 +export const integrationTypes = [ + { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') }, + { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') }, + { value: 'OPSGENIE', text: s__('AlertSettings|Opsgenie') }, ]; +export const integrationTypesNew = [ + { value: '', text: s__('AlertSettings|Select integration type') }, + ...integrationTypes, +]; + +export const typeSet = { + http: 'HTTP', + prometheus: 'PROMETHEUS', + opsgenie: 'OPSGENIE', +}; + +export const integrationToDeleteDefault = { id: null, name: '' }; + export const JSON_VALIDATE_DELAY = 250; export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/'; @@ -56,9 +70,9 @@ export const sectionHash = 'js-alert-management-settings'; /* eslint-disable @gitlab/require-i18n-strings */ /** - * Tracks snowplow event when user views alerts intergration list + * Tracks snowplow event when user views alerts integration list */ -export const trackAlertIntergrationsViewsOptions = { - category: 'Alert Intergrations', +export const trackAlertIntegrationsViewsOptions = { + category: 'Alert Integrations', action: 'view_alert_integrations_list', }; diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js new file mode 100644 index 00000000000..02c2def87fa --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql.js @@ -0,0 +1,44 @@ +import Vue from 'vue'; +import produce from 'immer'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql'; + +Vue.use(VueApollo); + +const resolvers = { + Mutation: { + updateCurrentIntegration: ( + _, + { id = null, name, active, token, type, url, apiUrl }, + { cache }, + ) => { + const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery }); + const data = produce(sourceData, draftData => { + if (id === null) { + // eslint-disable-next-line no-param-reassign + draftData.currentIntegration = null; + } else { + // eslint-disable-next-line no-param-reassign + draftData.currentIntegration = { + id, + name, + active, + token, + type, + url, + apiUrl, + }; + } + }); + cache.writeQuery({ query: getCurrentIntegrationQuery, data }); + }, + }, +}; + +export default new VueApollo({ + defaultClient: createDefaultClient(resolvers, { + cacheConfig: {}, + assumeImmutableResults: true, + }), +}); diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql new file mode 100644 index 00000000000..6d9307959df --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql @@ -0,0 +1,9 @@ +fragment IntegrationItem on AlertManagementIntegration { + id + type + active + name + url + token + apiUrl +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql new file mode 100644 index 00000000000..d1dacbad40a --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { + httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql new file mode 100644 index 00000000000..bb22795ddd5 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql @@ -0,0 +1,12 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation createPrometheusIntegration($projectPath: ID!, $apiUrl: String!, $active: Boolean!) { + prometheusIntegrationCreate( + input: { projectPath: $projectPath, apiUrl: $apiUrl, active: $active } + ) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql new file mode 100644 index 00000000000..0a49c140e6a --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation destroyHttpIntegration($id: ID!) { + httpIntegrationDestroy(input: { id: $id }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql new file mode 100644 index 00000000000..178d1e13047 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation resetHttpIntegrationToken($id: ID!) { + httpIntegrationResetToken(input: { id: $id }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql new file mode 100644 index 00000000000..8f34521b9fd --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation resetPrometheusIntegrationToken($id: ID!) { + prometheusIntegrationResetToken(input: { id: $id }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql new file mode 100644 index 00000000000..3505241309e --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql @@ -0,0 +1,19 @@ +mutation updateCurrentIntegration( + $id: String + $name: String + $active: Boolean + $token: String + $type: String + $url: String + $apiUrl: String +) { + updateCurrentIntegration( + id: $id + name: $name + active: $active + token: $token + type: $type + url: $url + apiUrl: $apiUrl + ) @client +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql new file mode 100644 index 00000000000..bb5b334deeb --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) { + httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql new file mode 100644 index 00000000000..62761730bd2 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation updatePrometheusIntegration($id: ID!, $apiUrl: String!, $active: Boolean!) { + prometheusIntegrationUpdate(input: { id: $id, apiUrl: $apiUrl, active: $active }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql new file mode 100644 index 00000000000..4f22849a618 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql @@ -0,0 +1,3 @@ +query currentIntegration { + currentIntegration @client +} diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql new file mode 100644 index 00000000000..228dd5fb176 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql @@ -0,0 +1,11 @@ +#import "../fragments/integration_item.fragment.graphql" + +query getIntegrations($projectPath: ID!) { + project(fullPath: $projectPath) { + alertManagementIntegrations { + nodes { + ...IntegrationItem + } + } + } +} diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index 8d1d342d229..41b19a675c5 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -1,6 +1,15 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import { parseBoolean } from '~/lib/utils/common_utils'; -import AlertSettingsForm from './components/alerts_settings_form.vue'; +import AlertSettingsWrapper from './components/alerts_settings_wrapper.vue'; +import apolloProvider from './graphql'; + +apolloProvider.clients.defaultClient.cache.writeData({ + data: { + currentIntegration: null, + }, +}); +Vue.use(GlToast); export default el => { if (!el) { @@ -24,20 +33,17 @@ export default el => { opsgenieMvcFormPath, opsgenieMvcEnabled, opsgenieMvcTargetUrl, + projectPath, + multiIntegrations, } = el.dataset; - const genericActivated = parseBoolean(activatedStr); - const prometheusIsActivated = parseBoolean(prometheusActivated); - const opsgenieMvcActivated = parseBoolean(opsgenieMvcEnabled); - const opsgenieMvcIsAvailable = parseBoolean(opsgenieMvcAvailable); - return new Vue({ el, provide: { prometheus: { - activated: prometheusIsActivated, - prometheusUrl, - authorizationKey: prometheusAuthorizationKey, + active: parseBoolean(prometheusActivated), + url: prometheusUrl, + token: prometheusAuthorizationKey, prometheusFormPath, prometheusResetKeyPath, prometheusApiUrl, @@ -45,23 +51,26 @@ export default el => { generic: { alertsSetupUrl, alertsUsageUrl, - activated: genericActivated, + active: parseBoolean(activatedStr), formPath, - authorizationKey, + token: authorizationKey, url, }, opsgenie: { formPath: opsgenieMvcFormPath, - activated: opsgenieMvcActivated, + active: parseBoolean(opsgenieMvcEnabled), opsgenieMvcTargetUrl, - opsgenieMvcIsAvailable, + opsgenieMvcIsAvailable: parseBoolean(opsgenieMvcAvailable), }, + projectPath, + multiIntegrations: parseBoolean(multiIntegrations), }, + apolloProvider, components: { - AlertSettingsForm, + AlertSettingsWrapper, }, render(createElement) { - return createElement('alert-settings-form'); + return createElement('alert-settings-wrapper'); }, }); }; diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js index c49992d4f57..1835d6b46aa 100644 --- a/app/assets/javascripts/alerts_settings/services/index.js +++ b/app/assets/javascripts/alerts_settings/services/index.js @@ -2,6 +2,7 @@ import axios from '~/lib/utils/axios_utils'; export default { + // TODO: All this code save updateTestAlert will be deleted as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/255501 updateGenericKey({ endpoint, params }) { return axios.put(endpoint, params); }, @@ -25,11 +26,11 @@ export default { }, }); }, - updateTestAlert({ endpoint, data, authKey }) { + updateTestAlert({ endpoint, data, token }) { return axios.post(endpoint, data, { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${authKey}`, + Authorization: `Bearer ${token}`, }, }); }, diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js new file mode 100644 index 00000000000..18054b29fe9 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js @@ -0,0 +1,84 @@ +import produce from 'immer'; +import createFlash from '~/flash'; + +import { DELETE_INTEGRATION_ERROR, ADD_INTEGRATION_ERROR } from './error_messages'; + +const deleteIntegrationFromStore = (store, query, { httpIntegrationDestroy }, variables) => { + const integration = httpIntegrationDestroy?.integration; + if (!integration) { + return; + } + + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.project.alertManagementIntegrations.nodes = draftData.project.alertManagementIntegrations.nodes.filter( + ({ id }) => id !== integration.id, + ); + }); + + store.writeQuery({ + query, + variables, + data, + }); +}; + +const addIntegrationToStore = ( + store, + query, + { httpIntegrationCreate, prometheusIntegrationCreate }, + variables, +) => { + const integration = + httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration; + if (!integration) { + return; + } + + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.project.alertManagementIntegrations.nodes = [ + integration, + ...draftData.project.alertManagementIntegrations.nodes, + ]; + }); + + store.writeQuery({ + query, + variables, + data, + }); +}; + +const onError = (data, message) => { + createFlash({ message }); + throw new Error(data.errors); +}; + +export const hasErrors = ({ errors = [] }) => errors?.length; + +export const updateStoreAfterIntegrationDelete = (store, query, data, variables) => { + if (hasErrors(data)) { + onError(data, DELETE_INTEGRATION_ERROR); + } else { + deleteIntegrationFromStore(store, query, data, variables); + } +}; + +export const updateStoreAfterIntegrationAdd = (store, query, data, variables) => { + if (hasErrors(data)) { + onError(data, ADD_INTEGRATION_ERROR); + } else { + addIntegrationToStore(store, query, data, variables); + } +}; diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js new file mode 100644 index 00000000000..979d1ca3ccc --- /dev/null +++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js @@ -0,0 +1,21 @@ +import { s__ } from '~/locale'; + +export const DELETE_INTEGRATION_ERROR = s__( + 'AlertsIntegrations|The integration could not be deleted. Please try again.', +); + +export const ADD_INTEGRATION_ERROR = s__( + 'AlertsIntegrations|The integration could not be added. Please try again.', +); + +export const UPDATE_INTEGRATION_ERROR = s__( + 'AlertsIntegrations|The current integration could not be updated. Please try again.', +); + +export const RESET_INTEGRATION_TOKEN_ERROR = s__( + 'AlertsIntegrations|The integration token could not be reset. Please try again.', +); + +export const INTEGRATION_PAYLOAD_TEST_ERROR = s__( + 'AlertsIntegrations|Integration payload is invalid. You can still save your changes.', +); |