Commit 9d02907d authored by Jose Ivan Vargas's avatar Jose Ivan Vargas Committed by Fatih Acet

Add custom form field to validate custom metrics

parent 73e9c17f
<script>
import { GlFormInput, GlButton, GlLink, GlFormGroup } from '@gitlab/ui';
import _ from 'underscore';
import { __, s__ } from '~/locale';
import csrf from '~/lib/utils/csrf';
import Icon from '~/vue_shared/components/icon.vue';
import axios from '~/lib/utils/axios_utils';
import DeleteCustomMetricModal from './delete_custom_metric_modal.vue';
import QueryTypes from '../constants';
export default {
components: {
DeleteCustomMetricModal,
GlFormInput,
GlButton,
GlLink,
GlFormGroup,
Icon,
},
props: {
customMetricsPath: {
type: String,
required: false,
default: '',
},
metricPersisted: {
type: Boolean,
required: true,
},
editProjectServicePath: {
type: String,
required: true,
},
validateQueryPath: {
type: String,
required: true,
},
formData: {
type: Object,
required: true,
validator: val => {
const fieldNames = Object.keys(val);
const requiredFields = ['title', 'query', 'yLabel', 'unit', 'group', 'legend'];
return requiredFields.every(name => fieldNames.includes(name));
},
},
},
data() {
return {
validCustomQuery: null,
errorMessage: '',
};
},
computed: {
disabledForm() {
return this.validCustomQuery;
},
saveButtonText() {
return this.metricPersisted ? __('Save Changes') : s__('Metrics|Create metric');
},
titleText() {
return this.metricPersisted ? s__('Metrics|Edit metric') : s__('Metrics|New metric');
},
validQueryMsg() {
return this.validCustomQuery ? s__('Metrics|PromQL query is valid') : '';
},
invalidQueryMsg() {
return !this.validCustomQuery ? this.errorMessage : '';
},
},
created() {
this.csrf = csrf.token != null ? csrf.token : '';
this.formOperation = this.metricPersisted ? 'patch' : 'post';
this.formData.group = this.formData.group.length ? this.formData.group : QueryTypes.business;
if (this.metricPersisted) {
this.validate();
}
},
methods: {
submit() {
this.$refs.form.submit();
},
validate() {
this.requestValidation()
.then(res => {
const response = res.data;
const { valid, error } = response.query;
if (response.success) {
this.errorMessage = valid ? '' : error;
this.validCustomQuery = valid;
} else {
throw new Error('There was an error trying to validate your query');
}
})
.catch(() => {
this.errorMessage = s__('Metrics|There was an error trying to validate your query');
this.validCustomQuery = false;
});
},
validateQuery: _.debounce(function debounceValidateQuery() {
this.validate();
}, 500),
requestValidation() {
return axios.post(this.validateQueryPath, {
query: this.formData.query,
});
},
},
QueryTypes,
};
</script>
<template>
<div class="row my-3">
<h4 class="text-center prepend-top-0">{{ titleText }}</h4>
<form ref="form" class="col-lg-8 offset-lg-2" :action="customMetricsPath" method="post">
<input ref="method" type="hidden" name="_method" :value="formOperation" />
<input :value="csrf" type="hidden" name="authenticity_token" />
<gl-form-group
:label="__('Name')"
label-for="prometheus_metric_title"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_title"
v-model="formData.title"
:value="formData.title"
name="prometheus_metric[title]"
class="form-control"
:placeholder="s__('Metrics|e.g. Throughput')"
required
/>
<span class="form-text text-muted">{{ s__('Metrics|Used as a title for the chart') }}</span>
</gl-form-group>
<gl-form-group
:label="__('Type')"
label-for="prometheus_metric_group"
label-class="label-bold"
>
<input
id="group-business"
v-model="formData.group"
type="radio"
name="prometheus_metric[group]"
:value="$options.QueryTypes.business"
/>
<label class="label-bold append-right-10" for="group-business">{{ __('Business') }}</label>
<input
id="group-response"
v-model="formData.group"
type="radio"
name="prometheus_metric[group]"
:value="$options.QueryTypes.response"
/>
<label class="label-bold append-right-10" for="group-response">{{ __('Response') }}</label>
<input
id="group-system"
v-model="formData.group"
type="radio"
name="prometheus_metric[group]"
:value="$options.QueryTypes.system"
/>
<label class="label-bold" for="group-system">{{ s__('Metrics|System') }}</label>
<span class="form-text text-muted">{{ s__('Metrics|For grouping similar metrics') }}</span>
</gl-form-group>
<gl-form-group
:label="__('Query')"
label-for="prometheus_metric_query"
label-class="label-bold"
:state="validCustomQuery"
>
<gl-form-input
id="prometheus_metric_query"
v-model="formData.query"
:value="formData.query"
name="prometheus_metric[query]"
class="form-control"
placeholder="e.g. rate(http_requests_total[5m])"
required
:state="validCustomQuery"
@input="validateQuery"
/>
<slot name="valid-feedback">
<span class="form-text cgreen">
{{ validQueryMsg }}
</span>
</slot>
<slot name="invalid-feedback">
<span class="form-text cred">
{{ invalidQueryMsg }}
</span>
</slot>
<span v-show="formData.query.length === 0" class="form-text text-muted">
{{ s__('Metrics|Must be a valid PromQL query.') }}
<gl-link
href="https://prometheus.io/docs/prometheus/latest/querying/basics/"
tabindex="-1"
>
{{ s__('Metrics|Prometheus Query Documentation') }}
<icon name="external-link" :size="12" />
</gl-link>
</span>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Y-axis label')"
label-for="prometheus_metric_y_label"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_y_label"
v-model="formData.yLabel"
:value="formData.yLabel"
name="prometheus_metric[y_label]"
class="form-control"
placeholder="e.g. Requests/second"
required
/>
<span class="form-text text-muted">
{{
s__(
'Metrics|Label of the y-axis (usually the unit). The x-axis always represents time.',
)
}}
</span>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Unit label')"
label-for="prometheus_metric_unit"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_unit"
v-model="formData.unit"
:value="formData.unit"
name="prometheus_metric[unit]"
class="form-control"
placeholder="e.g. req/sec"
required
/>
</gl-form-group>
<gl-form-group
:label="s__('Metrics|Legend label (optional)')"
label-for="prometheus_metric_legend"
label-class="label-bold"
>
<gl-form-input
id="prometheus_metric_legend"
v-model="formData.legend"
:value="formData.legend"
name="prometheus_metric[legend]"
class="form-control"
placeholder="e.g. HTTP requests"
required
/>
<span class="form-text text-muted">
{{
s__(
'Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response.',
)
}}
</span>
</gl-form-group>
<div class="form-actions">
<gl-button variant="success" :disabled="!disabledForm" @click="submit">
{{ saveButtonText }}
</gl-button>
<gl-button variant="secondary" class="float-right" :href="editProjectServicePath">{{
__('Cancel')
}}</gl-button>
<delete-custom-metric-modal
v-if="metricPersisted"
:delete-metric-url="customMetricsPath"
:csrf-token="csrf"
/>
</div>
</form>
</div>
</template>
export default {
business: 'business',
response: 'response',
system: 'system',
};
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import CustomMetricsForm from './components/custom_metrics_form.vue';
export default () => {
// eslint-disable-next-line no-new
new Vue({
el: '#js-custom-metrics',
components: {
CustomMetricsForm,
},
render(createElement) {
const domEl = document.querySelector(this.$options.el);
const {
customMetricsPath,
editProjectServicePath,
validateQueryPath,
title,
query,
yLabel,
unit,
group,
legend,
} = domEl.dataset;
let { metricPersisted } = domEl.dataset;
metricPersisted = parseBoolean(metricPersisted);
return createElement('custom-metrics-form', {
props: {
customMetricsPath,
metricPersisted,
editProjectServicePath,
validateQueryPath,
formData: {
title,
query,
yLabel,
unit,
group,
legend,
},
},
});
},
});
};
import Vue from 'vue'; import customMetrics from 'ee/custom_metrics';
import csrf from '~/lib/utils/csrf';
import DeleteCustomMetricModal from './delete_custom_metric_modal.vue';
const initDeleteCustomMetricLogic = () => { document.addEventListener('DOMContentLoaded', customMetrics);
const deleteCustomMetricModalEl = document.getElementById('delete-custom-metric-modal-wrapper');
if (deleteCustomMetricModalEl) {
const { deleteMetricUrl } = deleteCustomMetricModalEl.dataset;
// eslint-disable-next-line no-new
new Vue({
el: deleteCustomMetricModalEl,
components: {
DeleteCustomMetricModal,
},
methods: {},
render(createElement) {
return createElement('delete-custom-metric-modal', {
props: { deleteMetricUrl, csrfToken: csrf.token },
});
},
});
}
};
document.addEventListener('DOMContentLoaded', initDeleteCustomMetricLogic);
import customMetrics from 'ee/custom_metrics';
document.addEventListener('DOMContentLoaded', customMetrics);
...@@ -9,10 +9,10 @@ module EE ...@@ -9,10 +9,10 @@ module EE
format.json do format.json do
result = prometheus_adapter.query(:validate, params[:query]) result = prometheus_adapter.query(:validate, params[:query])
if result.any? if result
render json: result render json: result
else else
head :no_content head :accepted
end end
end end
end end
......
# frozen_string_literal: true
module EE
module CustomMetricsHelper
def custom_metrics_data(project, metric)
custom_metrics_path = project.namespace.becomes(::Namespace)
{
'custom-metrics-path' => url_for([custom_metrics_path, project, metric]),
'metric-persisted' => metric.persisted?.to_s,
'edit-project-service-path' => edit_project_service_path(project, PrometheusService),
'validate-query-path' => validate_query_project_prometheus_metrics_path(project),
'title' => metric.title.to_s,
'query' => metric.query.to_s,
'y-label' => metric.y_label.to_s,
'unit' => metric.unit.to_s,
'group' => metric.group.to_s,
'legend' => metric.legend.to_s
}
end
end
end
- project = local_assigns.fetch(:project) - project = local_assigns.fetch(:project)
- metric = local_assigns.fetch(:metric) - metric = local_assigns.fetch(:metric)
- save_button_text = metric.persisted? ? _('Save changes') : s_('Metrics|Create metric')
.row.prepend-top-default.append-bottom-default #js-custom-metrics{ data: custom_metrics_data(project, metric) }
%h3.page-title.text-center
- if metric.persisted?
= s_('Metrics|Edit metric')
- else
= s_('Metrics|New metric')
= form_for [project.namespace.becomes(Namespace), project, metric], html: { class: 'col-lg-8 offset-lg-2' } do |f|
= form_errors(metric)
.form-group
= f.label :title, s_('Metrics|Name'), class: 'label-bold'
= f.text_field :title, required: true, class: 'form-control', placeholder: s_('Metrics|e.g. Throughput'), autofocus: true
%span.form-text.text-muted
= s_('Metrics|Used as a title for the chart')
.form-group
= label_tag :group, s_("Metrics|Type"), class: 'append-bottom-10'
.form-group.append-bottom-0
= f.radio_button :group, :business, checked: true
= f.label :group_business, s_("Metrics|Business"), class: 'label-bold append-right-10'
= f.radio_button :group, :response
= f.label :group_response, s_("Metrics|Response"), class: 'label-bold append-right-10'
= f.radio_button :group, :system
= f.label :group_system, s_("Metrics|System"), class: 'label-bold'
%p.text-tertiary
= s_('Metrics|For grouping similar metrics')
.form-group
= f.label :query, s_('Metrics|Query'), class: 'label-bold'
= f.text_field :query, required: true, class: 'form-control', placeholder: s_('Metrics|e.g. rate(http_requests_total[5m])')
%span.form-text.text-muted
= s_('Metrics|Must be a valid PromQL query.')
= link_to "https://prometheus.io/docs/prometheus/latest/querying/basics/", target: "_blank", rel: "noopener noreferrer" do
= sprite_icon("external-link", size: 12)
= s_('Metrics|Prometheus Query Documentation')
.form-group
= f.label :y_label, s_('Metrics|Y-axis label'), class: 'label-bold'
= f.text_field :y_label, class: 'form-control', placeholder: s_('Metrics|e.g. Requests/second')
%span.form-text.text-muted
= s_("Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time.")
.form-group
= f.label :unit, s_('Metrics|Unit label'), class: 'label-bold'
= f.text_field :unit, class: 'form-control', placeholder: s_('Metrics|e.g. req/sec')
.form-group
= f.label :legend, s_('Metrics|Legend label (optional)'), class: 'label-bold'
= f.text_field :legend, class: 'form-control', placeholder: s_('Metrics|e.g. HTTP requests')
%span.form-text.text-muted
= s_('Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response.')
.form-actions
= f.submit save_button_text, class: 'btn btn-success'
= link_to _('Cancel'), edit_project_service_path(project, PrometheusService), class: 'btn btn-default float-right'
- if metric.persisted?
#delete-custom-metric-modal-wrapper{ data: { delete_metric_url: project_prometheus_metric_path(project, metric) } }
---
title: Validate custom metrics
merge_request: 9178
author:
type: changed
...@@ -32,12 +32,12 @@ describe Projects::Prometheus::MetricsController do ...@@ -32,12 +32,12 @@ describe Projects::Prometheus::MetricsController do
end end
context 'validation information is not ready' do context 'validation information is not ready' do
let(:validation_result) { {} } let(:validation_result) { nil }
it 'validation data is returned' do it 'validation data is returned' do
post :validate_query, params: project_params(format: :json, query: query) post :validate_query, params: project_params(format: :json, query: query)
expect(response).to have_gitlab_http_status(204) expect(response).to have_gitlab_http_status(202)
end end
end end
end end
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import CustomMetricsForm from 'ee/custom_metrics/components/custom_metrics_form.vue';
const localVue = createLocalVue();
describe('CustomMetricsForm', () => {
let wrapper;
function mountComponent({
metricPersisted = false,
formData = {
title: '',
query: '',
yLabel: '',
unit: '',
group: '',
legend: '',
},
}) {
wrapper = shallowMount(CustomMetricsForm, {
localVue,
sync: false,
propsData: {
customMetricsPath: '',
editProjectServicePath: '',
metricPersisted,
validateQueryPath: '',
formData,
},
});
}
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
});
describe('Computed', () => {
it('Form button and title text indicate the custom metric is being edited', () => {
mountComponent({ metricPersisted: true });
expect(wrapper.vm.saveButtonText).toEqual('Save Changes');
expect(wrapper.vm.titleText).toEqual('Edit metric');
});
it('Form button and title text indicate the custom metric is being created', () => {
mountComponent({ metricPersisted: false });
expect(wrapper.vm.saveButtonText).toEqual('Create metric');
expect(wrapper.vm.titleText).toEqual('New metric');
});
it('Shows a correct validation message for a valid custom query', () => {
mountComponent({ metricPersisted: false });
wrapper.vm.validCustomQuery = true;
expect(wrapper.vm.validQueryMsg).toEqual('PromQL query is valid');
});
it('Shows an incorrect validation message for an invalid custom query', () => {
mountComponent({ metricPersisted: false });
wrapper.vm.validCustomQuery = false;
wrapper.vm.errorMessage = 'parse error at char...';
expect(wrapper.vm.invalidQueryMsg).toEqual(wrapper.vm.errorMessage);
});
});
});
...@@ -1531,6 +1531,9 @@ msgstr "" ...@@ -1531,6 +1531,9 @@ msgstr ""
msgid "Built-in" msgid "Built-in"
msgstr "" msgstr ""
msgid "Business"
msgstr ""
msgid "Business metrics (Custom)" msgid "Business metrics (Custom)"
msgstr "" msgstr ""
...@@ -5952,9 +5955,6 @@ msgstr "" ...@@ -5952,9 +5955,6 @@ msgstr ""
msgid "Metrics for environment" msgid "Metrics for environment"
msgstr "" msgstr ""
msgid "Metrics|Business"
msgstr ""
msgid "Metrics|Check out the CI/CD documentation on deploying to an environment" msgid "Metrics|Check out the CI/CD documentation on deploying to an environment"
msgstr "" msgstr ""
...@@ -5976,7 +5976,7 @@ msgstr "" ...@@ -5976,7 +5976,7 @@ msgstr ""
msgid "Metrics|For grouping similar metrics" msgid "Metrics|For grouping similar metrics"
msgstr "" msgstr ""
msgid "Metrics|Label of the chart's vertical axis. Usually the type of the unit being charted. The horizontal axis (X-axis) always represents time." msgid "Metrics|Label of the y-axis (usually the unit). The x-axis always represents time."
msgstr "" msgstr ""
msgid "Metrics|Learn about environments" msgid "Metrics|Learn about environments"
...@@ -5988,22 +5988,16 @@ msgstr "" ...@@ -5988,22 +5988,16 @@ msgstr ""
msgid "Metrics|Must be a valid PromQL query." msgid "Metrics|Must be a valid PromQL query."
msgstr "" msgstr ""
msgid "Metrics|Name"
msgstr ""
msgid "Metrics|New metric" msgid "Metrics|New metric"
msgstr "" msgstr ""
msgid "Metrics|No deployed environments" msgid "Metrics|No deployed environments"
msgstr "" msgstr ""
msgid "Metrics|Prometheus Query Documentation" msgid "Metrics|PromQL query is valid"
msgstr ""
msgid "Metrics|Query"
msgstr "" msgstr ""
msgid "Metrics|Response" msgid "Metrics|Prometheus Query Documentation"
msgstr "" msgstr ""
msgid "Metrics|System" msgid "Metrics|System"
...@@ -6018,10 +6012,10 @@ msgstr "" ...@@ -6018,10 +6012,10 @@ msgstr ""
msgid "Metrics|There was an error getting environments information." msgid "Metrics|There was an error getting environments information."
msgstr "" msgstr ""
msgid "Metrics|There was an error while retrieving metrics" msgid "Metrics|There was an error trying to validate your query"
msgstr "" msgstr ""
msgid "Metrics|Type" msgid "Metrics|There was an error while retrieving metrics"
msgstr "" msgstr ""
msgid "Metrics|Unexpected deployment data response from prometheus endpoint" msgid "Metrics|Unexpected deployment data response from prometheus endpoint"
...@@ -6045,21 +6039,9 @@ msgstr "" ...@@ -6045,21 +6039,9 @@ msgstr ""
msgid "Metrics|You're about to permanently delete this metric. This cannot be undone." msgid "Metrics|You're about to permanently delete this metric. This cannot be undone."
msgstr "" msgstr ""
msgid "Metrics|e.g. HTTP requests"
msgstr ""
msgid "Metrics|e.g. Requests/second"
msgstr ""
msgid "Metrics|e.g. Throughput" msgid "Metrics|e.g. Throughput"
msgstr "" msgstr ""
msgid "Metrics|e.g. rate(http_requests_total[5m])"
msgstr ""
msgid "Metrics|e.g. req/sec"
msgstr ""
msgid "Milestone" msgid "Milestone"
msgstr "" msgstr ""
...@@ -7717,6 +7699,9 @@ msgstr "" ...@@ -7717,6 +7699,9 @@ msgstr ""
msgid "Quarters" msgid "Quarters"
msgstr "" msgstr ""
msgid "Query"
msgstr ""
msgid "Quick actions can be used in the issues description and comment boxes." msgid "Quick actions can be used in the issues description and comment boxes."
msgstr "" msgstr ""
...@@ -7986,6 +7971,9 @@ msgstr "" ...@@ -7986,6 +7971,9 @@ msgstr ""
msgid "Resolved" msgid "Resolved"
msgstr "" msgstr ""
msgid "Response"
msgstr ""
msgid "Response metrics (AWS ELB)" msgid "Response metrics (AWS ELB)"
msgstr "" msgstr ""
......
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