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 csrf from '~/lib/utils/csrf';
import DeleteCustomMetricModal from './delete_custom_metric_modal.vue';
import customMetrics from 'ee/custom_metrics';
const initDeleteCustomMetricLogic = () => {
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);
document.addEventListener('DOMContentLoaded', customMetrics);
import customMetrics from 'ee/custom_metrics';
document.addEventListener('DOMContentLoaded', customMetrics);
......@@ -9,10 +9,10 @@ module EE
format.json do
result = prometheus_adapter.query(:validate, params[:query])
if result.any?
if result
render json: result
else
head :no_content
head :accepted
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)
- metric = local_assigns.fetch(:metric)
- save_button_text = metric.persisted? ? _('Save changes') : s_('Metrics|Create metric')
.row.prepend-top-default.append-bottom-default
%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) } }
#js-custom-metrics{ data: custom_metrics_data(project, metric) }
---
title: Validate custom metrics
merge_request: 9178
author:
type: changed
......@@ -32,12 +32,12 @@ describe Projects::Prometheus::MetricsController do
end
context 'validation information is not ready' do
let(:validation_result) { {} }
let(:validation_result) { nil }
it 'validation data is returned' do
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
......
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 ""
msgid "Built-in"
msgstr ""
msgid "Business"
msgstr ""
msgid "Business metrics (Custom)"
msgstr ""
......@@ -5952,9 +5955,6 @@ msgstr ""
msgid "Metrics for environment"
msgstr ""
msgid "Metrics|Business"
msgstr ""
msgid "Metrics|Check out the CI/CD documentation on deploying to an environment"
msgstr ""
......@@ -5976,7 +5976,7 @@ msgstr ""
msgid "Metrics|For grouping similar metrics"
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 ""
msgid "Metrics|Learn about environments"
......@@ -5988,22 +5988,16 @@ msgstr ""
msgid "Metrics|Must be a valid PromQL query."
msgstr ""
msgid "Metrics|Name"
msgstr ""
msgid "Metrics|New metric"
msgstr ""
msgid "Metrics|No deployed environments"
msgstr ""
msgid "Metrics|Prometheus Query Documentation"
msgstr ""
msgid "Metrics|Query"
msgid "Metrics|PromQL query is valid"
msgstr ""
msgid "Metrics|Response"
msgid "Metrics|Prometheus Query Documentation"
msgstr ""
msgid "Metrics|System"
......@@ -6018,10 +6012,10 @@ msgstr ""
msgid "Metrics|There was an error getting environments information."
msgstr ""
msgid "Metrics|There was an error while retrieving metrics"
msgid "Metrics|There was an error trying to validate your query"
msgstr ""
msgid "Metrics|Type"
msgid "Metrics|There was an error while retrieving metrics"
msgstr ""
msgid "Metrics|Unexpected deployment data response from prometheus endpoint"
......@@ -6045,21 +6039,9 @@ msgstr ""
msgid "Metrics|You're about to permanently delete this metric. This cannot be undone."
msgstr ""
msgid "Metrics|e.g. HTTP requests"
msgstr ""
msgid "Metrics|e.g. Requests/second"
msgstr ""
msgid "Metrics|e.g. Throughput"
msgstr ""
msgid "Metrics|e.g. rate(http_requests_total[5m])"
msgstr ""
msgid "Metrics|e.g. req/sec"
msgstr ""
msgid "Milestone"
msgstr ""
......@@ -7717,6 +7699,9 @@ msgstr ""
msgid "Quarters"
msgstr ""
msgid "Query"
msgstr ""
msgid "Quick actions can be used in the issues description and comment boxes."
msgstr ""
......@@ -7986,6 +7971,9 @@ msgstr ""
msgid "Resolved"
msgstr ""
msgid "Response"
msgstr ""
msgid "Response metrics (AWS ELB)"
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