Commit 92cdedd4 authored by David O'Regan's avatar David O'Regan Committed by Olena Horal-Koretska

Add support for HTTP Create

Add support for alert HTTP
create supported via GraphQL
parent 425982b7
<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 class="gl-text-gray-500">
<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>
...@@ -81,7 +81,6 @@ export default { ...@@ -81,7 +81,6 @@ export default {
<div class="incident-management-list"> <div class="incident-management-list">
<h5 class="gl-font-lg">{{ $options.i18n.title }}</h5> <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5>
<gl-table <gl-table
:empty-text="$options.i18n.emptyState"
:items="integrations" :items="integrations"
:fields="$options.fields" :fields="$options.fields"
:busy="loading" :busy="loading"
...@@ -115,6 +114,14 @@ export default { ...@@ -115,6 +114,14 @@ export default {
<template #table-busy> <template #table-busy>
<gl-loading-icon size="lg" color="dark" class="mt-3" /> <gl-loading-icon size="lg" color="dark" class="mt-3" />
</template> </template>
<template #empty>
<div
class="gl-border-t-solid gl-border-b-solid gl-border-1 gl-border gl-border-gray-100 mt-n3"
>
<p class="gl-text-gray-400 gl-py-3 gl-my-3">{{ $options.i18n.emptyState }}</p>
</div>
</template>
</gl-table> </gl-table>
</div> </div>
</template> </template>
...@@ -56,7 +56,7 @@ export default { ...@@ -56,7 +56,7 @@ export default {
data() { data() {
return { return {
loading: false, loading: false,
selectedIntegration: integrationTypes[1].value, selectedIntegration: integrationTypes[0].value,
options: integrationTypes, options: integrationTypes,
active: false, active: false,
authKey: '', authKey: '',
...@@ -88,34 +88,34 @@ export default { ...@@ -88,34 +88,34 @@ export default {
]; ];
}, },
isPrometheus() { isPrometheus() {
return this.selectedIntegration === 'prometheus'; return this.selectedIntegration === 'PROMETHEUS';
}, },
isOpsgenie() { isOpsgenie() {
return this.selectedIntegration === 'opsgenie'; return this.selectedIntegration === 'OPSGENIE';
}, },
selectedIntegrationType() { selectedIntegrationType() {
switch (this.selectedIntegration) { switch (this.selectedIntegration) {
case 'generic': { case 'HTTP': {
return { return {
url: this.generic.url, url: this.generic.url,
authKey: this.generic.authorizationKey, authKey: this.generic.authKey,
activated: this.generic.activated, active: this.generic.active,
resetKey: this.resetKey.bind(this), resetKey: this.resetKey.bind(this),
}; };
} }
case 'prometheus': { case 'PROMETHEUS': {
return { return {
url: this.prometheus.prometheusUrl, url: this.prometheus.url,
authKey: this.prometheus.authorizationKey, authKey: this.prometheus.authKey,
activated: this.prometheus.activated, active: this.prometheus.active,
resetKey: this.resetKey.bind(this, 'prometheus'), resetKey: this.resetKey.bind(this, 'PROMETHEUS'),
targetUrl: this.prometheus.prometheusApiUrl, targetUrl: this.prometheus.prometheusApiUrl,
}; };
} }
case 'opsgenie': { case 'OPSGENIE': {
return { return {
targetUrl: this.opsgenie.opsgenieMvcTargetUrl, targetUrl: this.opsgenie.opsgenieMvcTargetUrl,
activated: this.opsgenie.activated, active: this.opsgenie.active,
}; };
} }
default: { default: {
...@@ -161,16 +161,12 @@ export default { ...@@ -161,16 +161,12 @@ export default {
}, },
}, },
mounted() { mounted() {
if ( if (this.prometheus.active || this.generic.active || !this.opsgenie.opsgenieMvcIsAvailable) {
this.prometheus.activated ||
this.generic.activated ||
!this.opsgenie.opsgenieMvcIsAvailable
) {
this.removeOpsGenieOption(); this.removeOpsGenieOption();
} else if (this.opsgenie.activated) { } else if (this.opsgenie.active) {
this.setOpsgenieAsDefault(); this.setOpsgenieAsDefault();
} }
this.active = this.selectedIntegrationType.activated; this.active = this.selectedIntegrationType.active;
this.authKey = this.selectedIntegrationType.authKey ?? ''; this.authKey = this.selectedIntegrationType.authKey ?? '';
}, },
methods: { methods: {
...@@ -183,19 +179,19 @@ export default { ...@@ -183,19 +179,19 @@ export default {
}, },
setOpsgenieAsDefault() { setOpsgenieAsDefault() {
this.options = this.options.map(el => { this.options = this.options.map(el => {
if (el.value !== 'opsgenie') { if (el.value !== 'OPSGENIE') {
return { ...el, disabled: true }; return { ...el, disabled: true };
} }
return { ...el, disabled: false }; return { ...el, disabled: false };
}); });
this.selectedIntegration = this.options.find(({ value }) => value === 'opsgenie').value; this.selectedIntegration = this.options.find(({ value }) => value === 'OPSGENIE').value;
if (this.targetUrl === null) { if (this.targetUrl === null) {
this.targetUrl = this.selectedIntegrationType.targetUrl; this.targetUrl = this.selectedIntegrationType.targetUrl;
} }
}, },
removeOpsGenieOption() { removeOpsGenieOption() {
this.options = this.options.map(el => { this.options = this.options.map(el => {
if (el.value !== 'opsgenie') { if (el.value !== 'OPSGENIE') {
return { ...el, disabled: false }; return { ...el, disabled: false };
} }
return { ...el, disabled: true }; return { ...el, disabled: true };
...@@ -204,7 +200,7 @@ export default { ...@@ -204,7 +200,7 @@ export default {
resetFormValues() { resetFormValues() {
this.testAlert.json = null; this.testAlert.json = null;
this.targetUrl = this.selectedIntegrationType.targetUrl; this.targetUrl = this.selectedIntegrationType.targetUrl;
this.active = this.selectedIntegrationType.activated; this.active = this.selectedIntegrationType.active;
}, },
dismissFeedback() { dismissFeedback() {
this.serverError = null; this.serverError = null;
...@@ -212,7 +208,7 @@ export default { ...@@ -212,7 +208,7 @@ export default {
this.isFeedbackDismissed = false; this.isFeedbackDismissed = false;
}, },
resetKey(key) { resetKey(key) {
const fn = key === 'prometheus' ? this.resetPrometheusKey() : this.resetGenericKey(); const fn = key === 'PROMETHEUS' ? this.resetPrometheusKey() : this.resetGenericKey();
return fn return fn
.then(({ data: { token } }) => { .then(({ data: { token } }) => {
...@@ -242,9 +238,10 @@ export default { ...@@ -242,9 +238,10 @@ export default {
}, },
toggleActivated(value) { toggleActivated(value) {
this.loading = true; this.loading = true;
const path = this.isOpsgenie ? this.opsgenie.formPath : this.generic.formPath;
return service return service
.updateGenericActive({ .updateGenericActive({
endpoint: this[this.selectedIntegration].formPath, endpoint: path,
params: this.isOpsgenie params: this.isOpsgenie
? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } } ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } }
: { service: { active: value } }, : { service: { active: value } },
...@@ -345,7 +342,7 @@ export default { ...@@ -345,7 +342,7 @@ export default {
if (this.canSaveForm) { if (this.canSaveForm) {
this.canSaveForm = false; this.canSaveForm = false;
this.active = this.selectedIntegrationType.activated; this.active = this.selectedIntegrationType.active;
} }
}, },
}, },
...@@ -402,9 +399,9 @@ export default { ...@@ -402,9 +399,9 @@ export default {
</gl-sprintf> </gl-sprintf>
</span> </span>
</gl-form-group> </gl-form-group>
<gl-form-group :label="$options.i18n.activeLabel" label-for="activated"> <gl-form-group :label="$options.i18n.activeLabel" label-for="active">
<toggle-button <toggle-button
id="activated" id="active"
:disabled-input="loading" :disabled-input="loading"
:is-loading="loading" :is-loading="loading"
:value="active" :value="active"
......
<script> <script>
import produce from 'immer';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql'; import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql';
import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql';
import IntegrationsList from './alerts_integrations_list.vue'; import IntegrationsList from './alerts_integrations_list.vue';
import SettingsFormOld from './alerts_settings_form_old.vue'; import SettingsFormOld from './alerts_settings_form_old.vue';
import SettingsFormNew from './alerts_settings_form_new.vue'; import SettingsFormNew from './alerts_settings_form_new.vue';
import { typeSet } from '../constants';
export default { 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.',
),
},
components: { components: {
IntegrationsList, IntegrationsList,
SettingsFormOld, SettingsFormOld,
...@@ -49,6 +60,7 @@ export default { ...@@ -49,6 +60,7 @@ export default {
data() { data() {
return { return {
errored: false, errored: false,
isUpdating: false,
integrations: {}, integrations: {},
}; };
}, },
...@@ -61,16 +73,85 @@ export default { ...@@ -61,16 +73,85 @@ export default {
{ {
name: s__('AlertSettings|HTTP endpoint'), name: s__('AlertSettings|HTTP endpoint'),
type: s__('AlertsIntegrations|HTTP endpoint'), type: s__('AlertsIntegrations|HTTP endpoint'),
active: this.generic.activated, active: this.generic.active,
}, },
{ {
name: s__('AlertSettings|External Prometheus'), name: s__('AlertSettings|External Prometheus'),
type: s__('AlertsIntegrations|Prometheus'), type: s__('AlertsIntegrations|Prometheus'),
active: this.prometheus.activated, active: this.prometheus.active,
}, },
]; ];
}, },
}, },
methods: {
onCreateNewIntegration({ type, variables }) {
this.isUpdating = true;
this.$apollo
.mutate({
mutation:
type === this.$options.typeSet.http
? createHttpIntegrationMutation
: createPrometheusIntegrationMutation,
variables: {
...variables,
projectPath: this.projectPath,
},
update: this.updateIntegrations,
})
.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(err => {
this.errored = true;
createFlash({ message: err });
})
.finally(() => {
this.isUpdating = false;
});
},
updateIntegrations(
store,
{
data: { httpIntegrationCreate, prometheusIntegrationCreate },
},
) {
const integration =
httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration;
if (!integration) {
return;
}
const sourceData = store.readQuery({
query: getIntegrationsQuery,
variables: {
projectPath: this.projectPath,
},
});
const data = produce(sourceData, draftData => {
// eslint-disable-next-line no-param-reassign
draftData.project.alertManagementIntegrations.nodes = [
integration,
...draftData.project.alertManagementIntegrations.nodes,
];
});
store.writeQuery({
query: getIntegrationsQuery,
variables: {
projectPath: this.projectPath,
},
data,
});
},
},
}; };
</script> </script>
...@@ -80,7 +161,11 @@ export default { ...@@ -80,7 +161,11 @@ export default {
:integrations="glFeatures.httpIntegrationsList ? integrations.list : intergrationsOptionsOld" :integrations="glFeatures.httpIntegrationsList ? integrations.list : intergrationsOptionsOld"
:loading="loading" :loading="loading"
/> />
<settings-form-new v-if="glFeatures.httpIntegrationsList" /> <settings-form-new
v-if="glFeatures.httpIntegrationsList"
:loading="loading"
@on-create-new-integration="onCreateNewIntegration"
/>
<settings-form-old v-else /> <settings-form-old v-else />
</div> </div>
</template> </template>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
// TODO: Remove this as part of the form old removal
export const i18n = { export const i18n = {
usageSection: s__( 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.', '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.',
...@@ -39,13 +40,23 @@ export const i18n = { ...@@ -39,13 +40,23 @@ export const i18n = {
integration: s__('AlertSettings|Integration'), integration: s__('AlertSettings|Integration'),
}; };
// TODO: Delete as part of old form removal in 13.6
export const integrationTypes = [ 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') }, { value: '', text: s__('AlertSettings|Select integration type') },
{ value: 'generic', text: s__('AlertSettings|HTTP Endpoint') }, ...integrationTypes,
{ value: 'prometheus', text: s__('AlertSettings|External Prometheus') },
{ value: 'opsgenie', text: s__('AlertSettings|Opsgenie') },
]; ];
export const typeSet = {
http: 'HTTP',
prometheus: 'PROMETHEUS',
};
export const JSON_VALIDATE_DELAY = 250; export const JSON_VALIDATE_DELAY = 250;
export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/'; export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/';
......
#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
}
}
}
#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
}
}
}
...@@ -48,9 +48,9 @@ export default el => { ...@@ -48,9 +48,9 @@ export default el => {
el, el,
provide: { provide: {
prometheus: { prometheus: {
activated: parseBoolean(prometheusActivated), active: parseBoolean(prometheusActivated),
prometheusUrl, url: prometheusUrl,
authorizationKey: prometheusAuthorizationKey, authKey: prometheusAuthorizationKey,
prometheusFormPath, prometheusFormPath,
prometheusResetKeyPath, prometheusResetKeyPath,
prometheusApiUrl, prometheusApiUrl,
...@@ -58,14 +58,14 @@ export default el => { ...@@ -58,14 +58,14 @@ export default el => {
generic: { generic: {
alertsSetupUrl, alertsSetupUrl,
alertsUsageUrl, alertsUsageUrl,
activated: parseBoolean(activatedStr), active: parseBoolean(activatedStr),
formPath, formPath,
authorizationKey, authKey: authorizationKey,
url, url,
}, },
opsgenie: { opsgenie: {
formPath: opsgenieMvcFormPath, formPath: opsgenieMvcFormPath,
activated: parseBoolean(opsgenieMvcEnabled), active: parseBoolean(opsgenieMvcEnabled),
opsgenieMvcTargetUrl, opsgenieMvcTargetUrl,
opsgenieMvcIsAvailable: parseBoolean(opsgenieMvcAvailable), opsgenieMvcIsAvailable: parseBoolean(opsgenieMvcAvailable),
}, },
......
...@@ -30,7 +30,7 @@ module OperationsHelper ...@@ -30,7 +30,7 @@ module OperationsHelper
'alerts_setup_url' => help_page_path('operations/incident_management/alert_integrations.md', anchor: 'generic-http-endpoint'), 'alerts_setup_url' => help_page_path('operations/incident_management/alert_integrations.md', anchor: 'generic-http-endpoint'),
'alerts_usage_url' => project_alert_management_index_path(@project), 'alerts_usage_url' => project_alert_management_index_path(@project),
'disabled' => disabled.to_s, 'disabled' => disabled.to_s,
'project_path' => project_path(@project) 'project_path' => @project.full_path
} }
end end
......
...@@ -2494,6 +2494,12 @@ msgstr "" ...@@ -2494,6 +2494,12 @@ msgstr ""
msgid "AlertSettings|2. Name integration" msgid "AlertSettings|2. Name integration"
msgstr "" msgstr ""
msgid "AlertSettings|3. Set up webhook"
msgstr ""
msgid "AlertSettings|4. Test integration(optional)"
msgstr ""
msgid "AlertSettings|5. Map fields (optional)" msgid "AlertSettings|5. Map fields (optional)"
msgstr "" msgstr ""
...@@ -2548,6 +2554,15 @@ msgstr "" ...@@ -2548,6 +2554,15 @@ msgstr ""
msgid "AlertSettings|Opsgenie" msgid "AlertSettings|Opsgenie"
msgstr "" msgstr ""
msgid "AlertSettings|Prometheus API base URL"
msgstr ""
msgid "AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with to send a test alert to the %{linkStart}alerts page%{linkEnd}. This payload can be used to test the integration using the \"save and test payload\" button."
msgstr ""
msgid "AlertSettings|Reset Key"
msgstr ""
msgid "AlertSettings|Reset key" msgid "AlertSettings|Reset key"
msgstr "" msgstr ""
...@@ -2557,6 +2572,9 @@ msgstr "" ...@@ -2557,6 +2572,9 @@ msgstr ""
msgid "AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint." msgid "AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint."
msgstr "" msgstr ""
msgid "AlertSettings|Save and test payload"
msgstr ""
msgid "AlertSettings|Save integration" msgid "AlertSettings|Save integration"
msgstr "" msgstr ""
...@@ -2584,6 +2602,9 @@ msgstr "" ...@@ -2584,6 +2602,9 @@ msgstr ""
msgid "AlertSettings|URL cannot be blank and must start with http or https" msgid "AlertSettings|URL cannot be blank and must start with http or https"
msgstr "" msgstr ""
msgid "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send Alerts to GitLab. Review your chosen services documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint."
msgstr ""
msgid "AlertSettings|Webhook URL" msgid "AlertSettings|Webhook URL"
msgstr "" msgstr ""
...@@ -2623,6 +2644,9 @@ msgstr "" ...@@ -2623,6 +2644,9 @@ msgstr ""
msgid "AlertsIntegrations|Prometheus" msgid "AlertsIntegrations|Prometheus"
msgstr "" msgstr ""
msgid "AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list."
msgstr ""
msgid "Algorithm" msgid "Algorithm"
msgstr "" msgstr ""
...@@ -23249,9 +23273,6 @@ msgstr "" ...@@ -23249,9 +23273,6 @@ msgstr ""
msgid "Save Push Rules" msgid "Save Push Rules"
msgstr "" msgstr ""
msgid "Save and test payload"
msgstr ""
msgid "Save anyway" msgid "Save anyway"
msgstr "" msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AlertsSettingsFormNew with default values renders the initial template 1`] = ` exports[`AlertsSettingsFormNew with default values renders the initial template 1`] = `
"<gl-form-stub class=\\"gl-mt-6\\"> "<form class=\\"gl-mt-6\\">
<h5 class=\\"gl-font-lg gl-my-5\\">Add new integrations</h5> <h5 class=\\"gl-font-lg gl-my-5\\">Add new integrations</h5>
<gl-form-group-stub id=\\"integration-type\\" label=\\"1. Select integration type\\" label-for=\\"integration-type\\"> <div id=\\"integration-type\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"integration-type__BV_label_\\" for=\\"integration-type\\" class=\\"d-block col-form-label\\">1. Select integration type</label>
<gl-form-select-stub options=\\"[object Object],[object Object],[object Object],[object Object]\\" value=\\"\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our upcoming %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span> <div class=\\"bv-no-focus-ring\\"><select class=\\"gl-form-select custom-select\\" id=\\"__BVID__8\\">
</gl-form-group-stub> <option value=\\"\\">Select integration type</option>
<b-collapse-stub tag=\\"div\\" class=\\"gl-mt-3\\"> <option value=\\"HTTP\\">HTTP Endpoint</option>
<gl-form-group-stub id=\\"name-integration\\" label=\\"2. Name integration\\" label-for=\\"name-integration\\"> <option value=\\"PROMETHEUS\\">External Prometheus</option>
<b-form-input-stub value=\\"\\" placeholder=\\"Enter integration name\\" debounce=\\"0\\" type=\\"text\\" class=\\"gl-form-input\\"></b-form-input-stub> <option value=\\"OPSGENIE\\">Opsgenie</option>
</gl-form-group-stub> </select> <span class=\\"gl-text-gray-500\\">Learn more about our upcoming <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://gitlab.com/groups/gitlab-org/-/epics/4390\\" class=\\"gl-link gl-display-inline-block\\">integrations</a></span>
<!----> <!---->
<div class=\\"gl-display-flex gl-justify-content-end\\"> <!---->
<gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" type=\\"reset\\" class=\\"gl-mr-3 js-no-auto-disable\\">Cancel</gl-button-stub> <!---->
<gl-button-stub category=\\"secondary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" type=\\"submit\\" class=\\"gl-mr-1 js-no-auto-disable\\">Save and test payload</gl-button-stub> </div>
<gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" type=\\"submit\\" class=\\"js-no-auto-disable\\">Save integration</gl-button-stub> </div>
</div> <div class=\\"gl-mt-3 collapse\\" style=\\"display: none;\\" id=\\"__BVID__13\\">
</b-collapse-stub> <div id=\\"name-integration\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"name-integration__BV_label_\\" for=\\"name-integration\\" class=\\"d-block col-form-label\\">2. Name integration</label>
</gl-form-stub>" <div class=\\"bv-no-focus-ring\\"><input type=\\"text\\" placeholder=\\"Enter integration name\\" class=\\"gl-form-input form-control\\" id=\\"__BVID__18\\">
<!---->
<!---->
<!---->
</div>
</div>
<div id=\\"integration-webhook\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"integration-webhook__BV_label_\\" for=\\"integration-webhook\\" class=\\"d-block col-form-label\\">3. Set up webhook</label>
<div class=\\"bv-no-focus-ring\\"><span class=\\"gl-text-gray-500\\">Utilize the URL and authorization key below to authorize an external service to send Alerts to GitLab. Review your chosen services documentation to learn where to add these details, and the <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html\\" class=\\"gl-link gl-display-inline-block\\">GitLab documentation</a> to learn more about configuring your endpoint.</span> <label class=\\"gl-display-flex gl-flex-direction-column gl-mb-0 gl-w-max-content gl-my-4 gl-font-weight-normal\\">
<div class=\\"gl-toggle-wrapper\\"><span class=\\"gl-toggle-label\\">Active</span>
<!----> <button aria-label=\\"Active\\" type=\\"button\\" class=\\"gl-toggle\\"><span class=\\"toggle-icon\\"><svg data-testid=\\"close-icon\\" class=\\"gl-icon s16\\"><use href=\\"#close\\"></use></svg></span></button></div>
<!---->
</label>
<!---->
<div class=\\"gl-my-4\\"><span>
Webhook URL
</span>
<div id=\\"url\\" readonly=\\"readonly\\">
<div role=\\"group\\" class=\\"input-group\\">
<!---->
<!----> <input id=\\"url\\" type=\\"text\\" readonly=\\"readonly\\" class=\\"gl-form-input form-control\\">
<div class=\\"input-group-append\\"><button title=\\"Copy\\" data-clipboard-text=\\"\\" aria-label=\\"Copy this value\\" type=\\"button\\" class=\\"btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon\\">
<!----> <svg data-testid=\\"copy-to-clipboard-icon\\" class=\\"gl-button-icon gl-icon s16\\">
<use href=\\"#copy-to-clipboard\\"></use>
</svg>
<!----></button></div>
<!---->
</div>
</div>
</div>
<div class=\\"gl-my-4\\"><span>
Authorization key
</span>
<div id=\\"authorization-key\\" readonly=\\"readonly\\" class=\\"gl-mb-2\\">
<div role=\\"group\\" class=\\"input-group\\">
<!---->
<!----> <input id=\\"authorization-key\\" type=\\"text\\" readonly=\\"readonly\\" class=\\"gl-form-input form-control\\">
<div class=\\"input-group-append\\"><button title=\\"Copy\\" data-clipboard-text=\\"\\" aria-label=\\"Copy this value\\" type=\\"button\\" class=\\"btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon\\">
<!----> <svg data-testid=\\"copy-to-clipboard-icon\\" class=\\"gl-button-icon gl-icon s16\\">
<use href=\\"#copy-to-clipboard\\"></use>
</svg>
<!----></button></div>
<!---->
</div>
</div> <button type=\\"button\\" disabled=\\"disabled\\" class=\\"btn gl-mt-3 btn-default btn-md disabled gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\">Reset Key</span></button>
<!---->
</div>
<!---->
<!---->
<!---->
</div>
</div>
<div id=\\"test-integration\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"test-integration__BV_label_\\" for=\\"test-integration\\" class=\\"d-block col-form-label\\">4. Test integration(optional)</label>
<div class=\\"bv-no-focus-ring\\"><span class=\\"gl-text-gray-500\\">Provide an example payload from the monitoring tool you intend to integrate with to send a test alert to the <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"http://invalid\\" class=\\"gl-link gl-display-inline-block\\">alerts page</a>. This payload can be used to test the integration using the \\"save and test payload\\" button.</span> <textarea id=\\"test-integration\\" disabled=\\"disabled\\" placeholder=\\"Enter test alert JSON....\\" wrap=\\"soft\\" class=\\"gl-form-input gl-form-textarea gl-my-4 form-control is-valid\\" style=\\"resize: none; overflow-y: scroll;\\"></textarea>
<!---->
<!---->
<!---->
</div>
</div>
<!---->
<div class=\\"gl-display-flex gl-justify-content-end\\"><button type=\\"reset\\" class=\\"btn gl-mr-3 js-no-auto-disable btn-default btn-md gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\">Cancel</span></button> <button type=\\"button\\" class=\\"btn gl-mr-1 js-no-auto-disable btn-success btn-md gl-button btn-success-secondary\\">
<!---->
<!----> <span class=\\"gl-button-text\\">Save and test payload</span></button> <button data-testid=\\"integration-form-submit\\" type=\\"submit\\" class=\\"btn js-no-auto-disable btn-success btn-md gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\">Save integration</span></button></div>
</div>
</form>"
`; `;
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AlertsSettingsForm with default values renders the initial template 1`] = ` exports[`AlertsSettingsFormOld with default values renders the initial template 1`] = `
"<gl-form-stub> "<gl-form-stub>
<h5 class=\\"gl-font-lg gl-my-5\\"></h5> <h5 class=\\"gl-font-lg gl-my-5\\"></h5>
<!----> <!---->
...@@ -13,10 +13,10 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] ...@@ -13,10 +13,10 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
</p> </p>
</div> </div>
<gl-form-group-stub label-for=\\"integration-type\\" label=\\"Integration\\"> <gl-form-group-stub label-for=\\"integration-type\\" label=\\"Integration\\">
<gl-form-select-stub id=\\"integration-type\\" options=\\"[object Object],[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our our upcoming %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span> <gl-form-select-stub id=\\"integration-type\\" options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"HTTP\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our our upcoming %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span>
</gl-form-group-stub> </gl-form-group-stub>
<gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\"> <gl-form-group-stub label=\\"Active\\" label-for=\\"active\\">
<toggle-button-stub id=\\"activated\\"></toggle-button-stub> <toggle-button-stub id=\\"active\\"></toggle-button-stub>
</gl-form-group-stub> </gl-form-group-stub>
<!----> <!---->
<gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\"> <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\">
...@@ -25,7 +25,7 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] ...@@ -25,7 +25,7 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
</span> </span>
</gl-form-group-stub> </gl-form-group-stub>
<gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\"> <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\">
<gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub> <gl-form-input-group-stub value=\\"\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub>
<gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
<gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\"> <gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in. Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.
......
import { shallowMount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlForm, GlFormSelect, GlCollapse, GlFormInput } from '@gitlab/ui'; import { GlForm, GlFormSelect, GlCollapse, GlFormInput, GlToggle } from '@gitlab/ui';
import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form_new.vue'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form_new.vue';
import { defaultAlertSettingsConfig } from './util'; import { defaultAlertSettingsConfig } from './util';
import { typeSet } from '~/alerts_settings/constants';
describe('AlertsSettingsFormNew', () => { describe('AlertsSettingsFormNew', () => {
let wrapper; let wrapper;
const createComponent = ( const createComponent = ({
{ methods } = {}, data = {},
data, props = { loading: false },
multipleHttpIntegrationsCustomMapping = false, multipleHttpIntegrationsCustomMapping = false,
) => { } = {}) => {
wrapper = shallowMount(AlertsSettingsForm, { wrapper = mount(AlertsSettingsForm, {
data() { data() {
return { ...data }; return { ...data };
}, },
propsData: {
...props,
},
provide: { provide: {
glFeatures: { multipleHttpIntegrationsCustomMapping }, glFeatures: { multipleHttpIntegrationsCustomMapping },
...defaultAlertSettingsConfig, ...defaultAlertSettingsConfig,
}, },
methods,
stubs: { GlCollapse, GlFormInput },
}); });
}; };
const findForm = () => wrapper.find(GlForm); const findForm = () => wrapper.find(GlForm);
const findSelect = () => wrapper.find(GlFormSelect); const findSelect = () => wrapper.find(GlFormSelect);
const findFormSteps = () => wrapper.find(GlCollapse); const findFormSteps = () => wrapper.find(GlCollapse);
const findFormName = () => wrapper.find(GlFormInput); const findFormFields = () => wrapper.findAll(GlFormInput);
const findFormToggle = () => wrapper.find(GlToggle);
const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`);
const findSubmitButton = () => wrapper.find(`[type = "submit"]`);
afterEach(() => { afterEach(() => {
if (wrapper) { if (wrapper) {
...@@ -53,17 +57,83 @@ describe('AlertsSettingsFormNew', () => { ...@@ -53,17 +57,83 @@ describe('AlertsSettingsFormNew', () => {
}); });
it('shows the rest of the form when the dropdown is used', async () => { it('shows the rest of the form when the dropdown is used', async () => {
findSelect().vm.$emit('change', 'prometheus'); const options = findSelect().findAll('option');
await options.at(1).setSelected();
await wrapper.vm.$nextTick();
expect(
findFormFields()
.at(0)
.isVisible(),
).toBe(true);
});
});
describe('when form is invalid', () => {
// TODO, implement specs for when form is invalid
});
describe('when form is valid', () => {
beforeEach(() => {
createComponent({});
});
it('allows for on-create-new-integration with the correct form values for HTTP', async () => {
const options = findSelect().findAll('option');
await options.at(1).setSelected();
await findFormFields()
.at(0)
.setValue('Test integration');
await findFormToggle().trigger('click');
await wrapper.vm.$nextTick();
expect(findSubmitButton().exists()).toBe(true);
expect(findSubmitButton().text()).toBe('Save integration');
findForm().trigger('submit');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('on-create-new-integration')).toBeTruthy();
expect(wrapper.emitted('on-create-new-integration')[0]).toEqual([
{ type: typeSet.http, variables: { name: 'Test integration', active: true } },
]);
});
it('allows for on-create-new-integration with the correct form values for PROMETHEUS', async () => {
const options = findSelect().findAll('option');
await options.at(2).setSelected();
await findFormFields()
.at(0)
.setValue('Test integration');
await findFormFields()
.at(1)
.setValue('https://test.com');
await findFormToggle().trigger('click');
await wrapper.vm.$nextTick();
expect(findSubmitButton().exists()).toBe(true);
expect(findSubmitButton().text()).toBe('Save integration');
findForm().trigger('submit');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(findFormName().isVisible()).toBe(true); expect(wrapper.emitted('on-create-new-integration')).toBeTruthy();
expect(wrapper.emitted('on-create-new-integration')[0]).toEqual([
{ type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } },
]);
}); });
}); });
describe('Mapping builder section', () => { describe('Mapping builder section', () => {
beforeEach(() => { beforeEach(() => {
createComponent({}, {}); createComponent({});
}); });
it('should NOT render when feature flag disabled', () => { it('should NOT render when feature flag disabled', () => {
...@@ -71,7 +141,7 @@ describe('AlertsSettingsFormNew', () => { ...@@ -71,7 +141,7 @@ describe('AlertsSettingsFormNew', () => {
}); });
it('should render when feature flag is enabled', () => { it('should render when feature flag is enabled', () => {
createComponent({}, {}, true); createComponent({ multipleHttpIntegrationsCustomMapping: true });
expect(findMappingBuilderSection().exists()).toBe(true); expect(findMappingBuilderSection().exists()).toBe(true);
}); });
}); });
......
...@@ -8,7 +8,7 @@ import { defaultAlertSettingsConfig } from './util'; ...@@ -8,7 +8,7 @@ import { defaultAlertSettingsConfig } from './util';
jest.mock('~/alerts_settings/services'); jest.mock('~/alerts_settings/services');
describe('AlertsSettingsForm', () => { describe('AlertsSettingsFormOld', () => {
let wrapper; let wrapper;
const createComponent = ({ methods } = {}, data) => { const createComponent = ({ methods } = {}, data) => {
...@@ -113,7 +113,7 @@ describe('AlertsSettingsForm', () => { ...@@ -113,7 +113,7 @@ describe('AlertsSettingsForm', () => {
createComponent( createComponent(
{}, {},
{ {
selectedIntegration: 'prometheus', selectedIntegration: 'PROMETHEUS',
}, },
); );
}); });
...@@ -127,9 +127,7 @@ describe('AlertsSettingsForm', () => { ...@@ -127,9 +127,7 @@ describe('AlertsSettingsForm', () => {
}); });
it('shows the correct default API URL', () => { it('shows the correct default API URL', () => {
expect(findUrl().attributes('value')).toBe( expect(findUrl().attributes('value')).toBe(defaultAlertSettingsConfig.prometheus.url);
defaultAlertSettingsConfig.prometheus.prometheusUrl,
);
}); });
}); });
...@@ -138,7 +136,7 @@ describe('AlertsSettingsForm', () => { ...@@ -138,7 +136,7 @@ describe('AlertsSettingsForm', () => {
createComponent( createComponent(
{}, {},
{ {
selectedIntegration: 'opsgenie', selectedIntegration: 'OPSGENIE',
}, },
); );
}); });
......
...@@ -4,9 +4,16 @@ import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_ ...@@ -4,9 +4,16 @@ import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_
import AlertsSettingsFormOld from '~/alerts_settings/components/alerts_settings_form_old.vue'; import AlertsSettingsFormOld from '~/alerts_settings/components/alerts_settings_form_old.vue';
import AlertsSettingsFormNew from '~/alerts_settings/components/alerts_settings_form_new.vue'; import AlertsSettingsFormNew from '~/alerts_settings/components/alerts_settings_form_new.vue';
import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue';
import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql';
import createFlash from '~/flash';
import { defaultAlertSettingsConfig } from './util'; import { defaultAlertSettingsConfig } from './util';
import mockIntegrations from './mocks/integrations.json'; import mockIntegrations from './mocks/integrations.json';
jest.mock('~/flash');
const projectPath = '';
describe('AlertsSettingsWrapper', () => { describe('AlertsSettingsWrapper', () => {
let wrapper; let wrapper;
...@@ -25,6 +32,7 @@ describe('AlertsSettingsWrapper', () => { ...@@ -25,6 +32,7 @@ describe('AlertsSettingsWrapper', () => {
}, },
mocks: { mocks: {
$apollo: { $apollo: {
mutate: jest.fn(),
query: jest.fn(), query: jest.fn(),
queries: { queries: {
integrations: { integrations: {
...@@ -79,5 +87,85 @@ describe('AlertsSettingsWrapper', () => { ...@@ -79,5 +87,85 @@ describe('AlertsSettingsWrapper', () => {
expect(findLoader().exists()).toBe(false); expect(findLoader().exists()).toBe(false);
expect(findIntegrations()).toHaveLength(mockIntegrations.length); expect(findIntegrations()).toHaveLength(mockIntegrations.length);
}); });
it('shows an error message when a user cannot create a new integration', () => {
createComponent({
data: { integrations: { list: mockIntegrations } },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
expect(findLoader().exists()).toBe(false);
expect(findIntegrations()).toHaveLength(mockIntegrations.length);
});
it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations } },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { createHttpIntegrationMutation: { integration: { id: '1' } } },
});
wrapper.find(AlertsSettingsFormNew).vm.$emit('on-create-new-integration', {
type: 'HTTP',
variables: { name: 'Test 1', active: true },
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createHttpIntegrationMutation,
update: expect.anything(),
variables: {
name: 'Test 1',
active: true,
projectPath,
},
});
});
it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations } },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } },
});
wrapper.find(AlertsSettingsFormNew).vm.$emit('on-create-new-integration', {
type: 'PROMETHEUS',
variables: { apiUrl: 'https://test.com', active: true },
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createPrometheusIntegrationMutation,
update: expect.anything(),
variables: {
apiUrl: 'https://test.com',
active: true,
projectPath,
},
});
});
it('shows error alert when integration creation fails ', () => {
const errorMsg = 'Something went wrong';
createComponent({
data: { integrations: { list: mockIntegrations } },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
wrapper.find(AlertsSettingsFormNew).vm.$emit('on-create-new-integration', {});
setImmediate(() => {
expect(createFlash).toHaveBeenCalledWith({ message: errorMsg });
});
});
}); });
}); });
...@@ -2,7 +2,7 @@ const PROMETHEUS_URL = '/prometheus/alerts/notify.json'; ...@@ -2,7 +2,7 @@ const PROMETHEUS_URL = '/prometheus/alerts/notify.json';
const GENERIC_URL = '/alerts/notify.json'; const GENERIC_URL = '/alerts/notify.json';
const KEY = 'abcedfg123'; const KEY = 'abcedfg123';
const INVALID_URL = 'http://invalid'; const INVALID_URL = 'http://invalid';
const ACTIVATED = false; const ACTIVE = false;
export const defaultAlertSettingsConfig = { export const defaultAlertSettingsConfig = {
generic: { generic: {
...@@ -11,18 +11,18 @@ export const defaultAlertSettingsConfig = { ...@@ -11,18 +11,18 @@ export const defaultAlertSettingsConfig = {
url: GENERIC_URL, url: GENERIC_URL,
alertsSetupUrl: INVALID_URL, alertsSetupUrl: INVALID_URL,
alertsUsageUrl: INVALID_URL, alertsUsageUrl: INVALID_URL,
activated: ACTIVATED, active: ACTIVE,
}, },
prometheus: { prometheus: {
authorizationKey: KEY, authorizationKey: KEY,
prometheusFormPath: INVALID_URL, prometheusFormPath: INVALID_URL,
prometheusUrl: PROMETHEUS_URL, url: PROMETHEUS_URL,
activated: ACTIVATED, active: ACTIVE,
}, },
opsgenie: { opsgenie: {
opsgenieMvcIsAvailable: true, opsgenieMvcIsAvailable: true,
formPath: INVALID_URL, formPath: INVALID_URL,
activated: ACTIVATED, active: ACTIVE,
opsgenieMvcTargetUrl: GENERIC_URL, opsgenieMvcTargetUrl: GENERIC_URL,
}, },
}; };
...@@ -44,7 +44,7 @@ RSpec.describe OperationsHelper do ...@@ -44,7 +44,7 @@ RSpec.describe OperationsHelper do
'prometheus_activated' => 'false', 'prometheus_activated' => 'false',
'prometheus_url' => notify_project_prometheus_alerts_url(project, format: :json), 'prometheus_url' => notify_project_prometheus_alerts_url(project, format: :json),
'disabled' => 'false', 'disabled' => 'false',
'project_path' => project_path(project) 'project_path' => project.full_path
) )
end end
end end
......
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