Commit 4d381eed authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Clement Ho

Support multiple queries per chart on metrics dash

Adding support for metrics alerts disabled multiple query support.
To avoid a data model refactor, this enables the visual of multiple
queries per chart on the front end, combining queries based on
metric group, title, and y-axis label.

This also adds support for adding and editing alerts based on the
query selected rather than the single metric associated with the chart.
parent 3dd940ae
...@@ -42,10 +42,10 @@ export default { ...@@ -42,10 +42,10 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
alertData: { thresholds: {
type: Object, type: Array,
required: false, required: false,
default: () => ({}), default: () => [],
}, },
}, },
data() { data() {
...@@ -64,6 +64,9 @@ export default { ...@@ -64,6 +64,9 @@ export default {
}, },
computed: { computed: {
chartData() { chartData() {
// Transforms & supplements query data to render appropriate labels & styles
// Input: [{ queryAttributes1 }, { queryAttributes2 }]
// Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
return this.graphData.queries.reduce((acc, query) => { return this.graphData.queries.reduce((acc, query) => {
const { appearance } = query; const { appearance } = query;
const lineType = const lineType =
...@@ -121,6 +124,9 @@ export default { ...@@ -121,6 +124,9 @@ export default {
}, },
earliestDatapoint() { earliestDatapoint() {
return this.chartData.reduce((acc, series) => { return this.chartData.reduce((acc, series) => {
if (!series.data.length) {
return acc;
}
const [[timestamp]] = series.data.sort(([a], [b]) => { const [[timestamp]] = series.data.sort(([a], [b]) => {
if (a < b) { if (a < b) {
return -1; return -1;
...@@ -235,7 +241,7 @@ export default { ...@@ -235,7 +241,7 @@ export default {
:data="chartData" :data="chartData"
:option="chartOptions" :option="chartOptions"
:format-tooltip-text="formatTooltipText" :format-tooltip-text="formatTooltipText"
:thresholds="alertData" :thresholds="thresholds"
:width="width" :width="width"
:height="height" :height="height"
@updated="onChartUpdated" @updated="onChartUpdated"
......
<script> <script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import _ from 'underscore';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import '~/vue_shared/mixins/is_ee'; import '~/vue_shared/mixins/is_ee';
...@@ -142,8 +143,13 @@ export default { ...@@ -142,8 +143,13 @@ export default {
} }
}, },
methods: { methods: {
getGraphAlerts(graphId) { getGraphAlerts(queries) {
return this.alertData ? this.alertData[graphId] || {} : {}; if (!this.allAlerts) return {};
const metricIdsForChart = queries.map(q => q.metricId);
return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId));
},
getGraphAlertValues(queries) {
return Object.values(this.getGraphAlerts(queries));
}, },
getGraphsData() { getGraphsData() {
this.state = 'loading'; this.state = 'loading';
...@@ -199,17 +205,15 @@ export default { ...@@ -199,17 +205,15 @@ export default {
:key="graphIndex" :key="graphIndex"
:graph-data="graphData" :graph-data="graphData"
:deployment-data="store.deploymentData" :deployment-data="store.deploymentData"
:alert-data="getGraphAlerts(graphData.id)" :thresholds="getGraphAlertValues(graphData.queries)"
:container-width="elWidth" :container-width="elWidth"
group-id="monitor-area-chart" group-id="monitor-area-chart"
> >
<alert-widget <alert-widget
v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData.id" v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData"
:alerts-endpoint="alertsEndpoint" :alerts-endpoint="alertsEndpoint"
:label="getGraphLabel(graphData)" :relevant-queries="graphData.queries"
:current-alerts="getQueryAlerts(graphData)" :alerts-to-manage="getGraphAlerts(graphData.queries)"
:custom-metric-id="graphData.id"
:alert-data="alertData[graphData.id]"
@setAlerts="setAlerts" @setAlerts="setAlerts"
/> />
</monitor-area-chart> </monitor-area-chart>
......
...@@ -27,10 +27,47 @@ function removeTimeSeriesNoData(queries) { ...@@ -27,10 +27,47 @@ function removeTimeSeriesNoData(queries) {
return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []); return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []);
} }
// Metrics and queries are currently stored 1:1, so `queries` is an array of length one.
// We want to group queries onto a single chart by title & y-axis label.
// This function will no longer be required when metrics:queries are 1:many,
// though there is no consequence if the function stays in use.
// @param metrics [Array<Object>]
// Ex) [
// { id: 1, title: 'title', y_label: 'MB', queries: [{ ...query1Attrs }] },
// { id: 2, title: 'title', y_label: 'MB', queries: [{ ...query2Attrs }] },
// { id: 3, title: 'new title', y_label: 'MB', queries: [{ ...query3Attrs }] }
// ]
// @return [Array<Object>]
// Ex) [
// { title: 'title', y_label: 'MB', queries: [{ metricId: 1, ...query1Attrs },
// { metricId: 2, ...query2Attrs }] },
// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]}
// ]
function groupQueriesByChartInfo(metrics) {
const metricsByChart = metrics.reduce((accumulator, metric) => {
const { id, queries, ...chart } = metric;
const chartKey = `${chart.title}|${chart.y_label}`;
accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] };
queries.forEach(queryAttrs =>
accumulator[chartKey].queries.push({ metricId: id.toString(), ...queryAttrs }),
);
return accumulator;
}, {});
return Object.values(metricsByChart);
}
function normalizeMetrics(metrics) { function normalizeMetrics(metrics) {
return metrics.map(metric => { const groupedMetrics = groupQueriesByChartInfo(metrics);
return groupedMetrics.map(metric => {
const queries = metric.queries.map(query => ({ const queries = metric.queries.map(query => ({
...query, ...query,
// custom metrics do not require a label, so we should ensure this attribute is defined
label: query.label || metric.y_label,
result: query.result.map(result => ({ result: query.result.map(result => ({
...result, ...result,
values: result.values.map(([timestamp, value]) => [ values: result.values.map(([timestamp, value]) => [
......
---
title: Support multiple queries per chart on metrics dash
merge_request: 25758
author:
type: added
...@@ -105,10 +105,12 @@ A few fields are required: ...@@ -105,10 +105,12 @@ A few fields are required:
- **Name**: Chart title - **Name**: Chart title
- **Type**: Type of metric. Metrics of the same type will be shown together. - **Type**: Type of metric. Metrics of the same type will be shown together.
- **Query**: Valid [PromQL query](https://prometheus.io/docs/prometheus/latest/querying/basics/). Note, no validation is performed at this time. If the query is not valid, the dashboard will display an error. - **Query**: Valid [PromQL query](https://prometheus.io/docs/prometheus/latest/querying/basics/).
- **Y-axis label**: Y axis title to display on the dashboard. - **Y-axis label**: Y axis title to display on the dashboard.
- **Unit label**: Query units, for example `req / sec`. Shown next to the value. - **Unit label**: Query units, for example `req / sec`. Shown next to the value.
Multiple metrics can be displayed on the same chart if the fields **Name**, **Type**, and **Y-axis label** match between metrics. For example, a metric with **Name** `Requests Rate`, **Type** `Business`, and **Y-axis label** `rec / sec` would display on the same chart as a second metric with the same values. A **Legend label** is suggested if this feature used.
#### Query Variables #### Query Variables
GitLab supports a limited set of [CI variables](../../../ci/variables/README.html) in the Prometheus query. This is particularly useful for identifying a specific environment, for example with `CI_ENVIRONMENT_SLUG`. The supported variables are: GitLab supports a limited set of [CI variables](../../../ci/variables/README.html) in the Prometheus query. This is particularly useful for identifying a specific environment, for example with `CI_ENVIRONMENT_SLUG`. The supported variables are:
......
...@@ -3,6 +3,7 @@ import { s__ } from '~/locale'; ...@@ -3,6 +3,7 @@ import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import AlertWidgetForm from './alert_widget_form.vue'; import AlertWidgetForm from './alert_widget_form.vue';
import AlertsService from '../services/alerts_service'; import AlertsService from '../services/alerts_service';
import { alertsValidator, queriesValidator } from '../validators';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
export default { export default {
...@@ -16,24 +17,20 @@ export default { ...@@ -16,24 +17,20 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
label: { // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls.
type: String, // Includes only the metrics/alerts to be managed by this widget.
required: true, alertsToManage: {
},
currentAlerts: {
type: Array,
require: false,
default: () => [],
},
customMetricId: {
type: Number,
require: false,
default: null,
},
alertData: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
validator: alertsValidator,
},
// [{ metric+query_attributes }]. Represents queries (and alerts) we know about
// on intial fetch. Essentially used for reference.
relevantQueries: {
type: Array,
required: true,
validator: queriesValidator,
}, },
}, },
data() { data() {
...@@ -42,14 +39,14 @@ export default { ...@@ -42,14 +39,14 @@ export default {
errorMessage: null, errorMessage: null,
isLoading: false, isLoading: false,
isOpen: false, isOpen: false,
alerts: this.currentAlerts, apiAction: 'create',
}; };
}, },
computed: { computed: {
alertSummary() { alertSummary() {
const data = this.firstAlertData; return Object.keys(this.alertsToManage)
if (!data) return null; .map(this.formatAlertSummary)
return `${this.label} ${data.operator} ${data.threshold}`; .join(', ');
}, },
alertIcon() { alertIcon() {
return this.hasAlerts ? 'notifications' : 'notifications-off'; return this.hasAlerts ? 'notifications' : 'notifications-off';
...@@ -60,18 +57,12 @@ export default { ...@@ -60,18 +57,12 @@ export default {
: s__('PrometheusAlerts|No alert set'); : s__('PrometheusAlerts|No alert set');
}, },
dropdownTitle() { dropdownTitle() {
return this.hasAlerts return this.apiAction === 'create'
? s__('PrometheusAlerts|Edit alert') ? s__('PrometheusAlerts|Add alert')
: s__('PrometheusAlerts|Add alert'); : s__('PrometheusAlerts|Edit alert');
}, },
hasAlerts() { hasAlerts() {
return Object.keys(this.alertData).length > 0; return !!Object.keys(this.alertsToManage).length;
},
firstAlert() {
return this.hasAlerts ? this.alerts[0] : undefined;
},
firstAlertData() {
return this.hasAlerts ? this.alertData[this.alerts[0]] : undefined;
}, },
formDisabled() { formDisabled() {
return !!(this.errorMessage || this.isLoading); return !!(this.errorMessage || this.isLoading);
...@@ -97,14 +88,14 @@ export default { ...@@ -97,14 +88,14 @@ export default {
methods: { methods: {
fetchAlertData() { fetchAlertData() {
this.isLoading = true; this.isLoading = true;
const queriesWithAlerts = this.relevantQueries.filter(query => query.alert_path);
return Promise.all( return Promise.all(
this.alerts.map(alertPath => queriesWithAlerts.map(query =>
this.service.readAlert(alertPath).then(alertData => { this.service
this.$emit('setAlerts', this.customMetricId, { .readAlert(query.alert_path)
...this.alertData, .then(alertAttributes => this.setAlert(alertAttributes, query.metricId)),
[alertPath]: alertData,
});
}),
), ),
) )
.then(() => { .then(() => {
...@@ -115,6 +106,18 @@ export default { ...@@ -115,6 +106,18 @@ export default {
this.isLoading = false; this.isLoading = false;
}); });
}, },
setAlert(alertAttributes, metricId) {
this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId });
},
removeAlert(alertPath) {
this.$emit('setAlerts', alertPath, null);
},
formatAlertSummary(alertPath) {
const alert = this.alertsToManage[alertPath];
const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId);
return `${alertQuery.label} ${alert.operator} ${alert.threshold}`;
},
handleDropdownToggle() { handleDropdownToggle() {
this.isOpen = !this.isOpen; this.isOpen = !this.isOpen;
}, },
...@@ -122,22 +125,23 @@ export default { ...@@ -122,22 +125,23 @@ export default {
this.isOpen = false; this.isOpen = false;
}, },
handleOutsideClick(event) { handleOutsideClick(event) {
if (!this.$refs.dropdownMenu.contains(event.target)) { if (
!this.$refs.dropdownMenu.contains(event.target) &&
!this.$refs.dropdownMenuToggle.contains(event.target)
) {
this.isOpen = false; this.isOpen = false;
} }
}, },
handleCreate({ operator, threshold }) { handleSetApiAction(apiAction) {
const newAlert = { operator, threshold, prometheus_metric_id: this.customMetricId }; this.apiAction = apiAction;
},
handleCreate({ operator, threshold, prometheus_metric_id }) {
const newAlert = { operator, threshold, prometheus_metric_id };
this.isLoading = true; this.isLoading = true;
this.service this.service
.createAlert(newAlert) .createAlert(newAlert)
.then(response => { .then(alertAttributes => {
const alertPath = response.alert_path; this.setAlert(alertAttributes, prometheus_metric_id);
this.alerts.unshift(alertPath);
this.$emit('setAlerts', this.customMetricId, {
...this.alertData,
[alertPath]: newAlert,
});
this.isLoading = false; this.isLoading = false;
this.handleDropdownClose(); this.handleDropdownClose();
}) })
...@@ -151,11 +155,8 @@ export default { ...@@ -151,11 +155,8 @@ export default {
this.isLoading = true; this.isLoading = true;
this.service this.service
.updateAlert(alert, updatedAlert) .updateAlert(alert, updatedAlert)
.then(() => { .then(alertAttributes => {
this.$emit('setAlerts', this.customMetricId, { this.setAlert(alertAttributes, this.alertsToManage[alert].metricId);
...this.alertData,
[alert]: updatedAlert,
});
this.isLoading = false; this.isLoading = false;
this.handleDropdownClose(); this.handleDropdownClose();
}) })
...@@ -169,9 +170,7 @@ export default { ...@@ -169,9 +170,7 @@ export default {
this.service this.service
.deleteAlert(alert) .deleteAlert(alert)
.then(() => { .then(() => {
const { [alert]: _, ...otherItems } = this.alertData; this.removeAlert(alert);
this.$emit('setAlerts', this.customMetricId, otherItems);
this.alerts = this.alerts.filter(alertPath => alert !== alertPath);
this.isLoading = false; this.isLoading = false;
this.handleDropdownClose(); this.handleDropdownClose();
}) })
...@@ -185,13 +184,14 @@ export default { ...@@ -185,13 +184,14 @@ export default {
</script> </script>
<template> <template>
<div :class="{ show: isOpen }" class="prometheus-alert-widget dropdown"> <div class="prometheus-alert-widget dropdown d-flex align-items-center">
<span v-if="errorMessage" class="alert-error-message"> {{ errorMessage }} </span> <span v-if="errorMessage" class="alert-error-message"> {{ errorMessage }} </span>
<span v-else class="alert-current-setting"> <span v-else class="alert-current-setting">
<gl-loading-icon v-show="isLoading" :inline="true" /> <gl-loading-icon v-show="isLoading" :inline="true" />
{{ alertSummary }} {{ alertSummary }}
</span> </span>
<button <button
ref="dropdownMenuToggle"
:aria-label="alertStatus" :aria-label="alertStatus"
class="btn btn-sm alert-dropdown-button" class="btn btn-sm alert-dropdown-button"
type="button" type="button"
...@@ -200,7 +200,7 @@ export default { ...@@ -200,7 +200,7 @@ export default {
<icon :name="alertIcon" :size="16" aria-hidden="true" /> <icon :name="alertIcon" :size="16" aria-hidden="true" />
<icon :size="16" name="arrow-down" aria-hidden="true" class="chevron" /> <icon :size="16" name="arrow-down" aria-hidden="true" class="chevron" />
</button> </button>
<div ref="dropdownMenu" class="dropdown-menu alert-dropdown-menu"> <div ref="dropdownMenu" :class="{ show: isOpen }" class="dropdown-menu alert-dropdown-menu">
<div class="dropdown-title"> <div class="dropdown-title">
<span>{{ dropdownTitle }}</span> <span>{{ dropdownTitle }}</span>
<button <button
...@@ -216,12 +216,13 @@ export default { ...@@ -216,12 +216,13 @@ export default {
<alert-widget-form <alert-widget-form
ref="widgetForm" ref="widgetForm"
:disabled="formDisabled" :disabled="formDisabled"
:alert="firstAlert" :alerts-to-manage="alertsToManage"
:alert-data="firstAlertData" :relevant-queries="relevantQueries"
@create="handleCreate" @create="handleCreate"
@update="handleUpdate" @update="handleUpdate"
@delete="handleDelete" @delete="handleDelete"
@cancel="handleDropdownClose" @cancel="handleDropdownClose"
@setAction="handleSetApiAction"
/> />
</div> </div>
</div> </div>
......
<script> <script>
import { __ } from '~/locale'; import { __, s__ } from '~/locale';
import _ from 'underscore';
import Vue from 'vue'; import Vue from 'vue';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import { alertsValidator, queriesValidator } from '../validators';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
Vue.use(Translate); Vue.use(Translate);
...@@ -24,39 +27,58 @@ const OPERATORS = { ...@@ -24,39 +27,58 @@ const OPERATORS = {
}; };
export default { export default {
components: {
GlDropdown,
GlDropdownItem,
},
props: { props: {
disabled: { disabled: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
alert: { alertsToManage: {
type: String,
required: false,
default: null,
},
alertData: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
validator: alertsValidator,
},
relevantQueries: {
type: Array,
required: true,
validator: queriesValidator,
}, },
}, },
data() { data() {
return { return {
operators: OPERATORS, operators: OPERATORS,
operator: this.alertData.operator, operator: null,
threshold: this.alertData.threshold, threshold: null,
prometheusMetricId: null,
selectedAlert: {},
}; };
}, },
computed: { computed: {
currentQuery() {
return this.relevantQueries.find(query => query.metricId === this.prometheusMetricId) || {};
},
formDisabled() {
// We need a prometheusMetricId to determine whether we're
// creating/updating/deleting
return this.disabled || !this.prometheusMetricId;
},
queryDropdownLabel() {
return this.currentQuery.label || s__('PrometheusAlerts|Select query');
},
haveValuesChanged() { haveValuesChanged() {
return ( return (
this.operator && this.operator &&
this.threshold === Number(this.threshold) && this.threshold === Number(this.threshold) &&
(this.operator !== this.alertData.operator || this.threshold !== this.alertData.threshold) (this.operator !== this.selectedAlert.operator ||
this.threshold !== this.selectedAlert.threshold)
); );
}, },
submitAction() { submitAction() {
if (!this.alert) return 'create'; if (_.isEmpty(this.selectedAlert)) return 'create';
if (this.haveValuesChanged) return 'update'; if (this.haveValuesChanged) return 'update';
return 'delete'; return 'delete';
}, },
...@@ -71,11 +93,30 @@ export default { ...@@ -71,11 +93,30 @@ export default {
}, },
}, },
watch: { watch: {
alertData() { alertsToManage() {
this.resetAlertData(); this.resetAlertData();
}, },
submitAction() {
this.$emit('setAction', this.submitAction);
},
}, },
methods: { methods: {
selectQuery(queryId) {
const existingAlertPath = _.findKey(this.alertsToManage, alert => alert.metricId === queryId);
const existingAlert = this.alertsToManage[existingAlertPath];
if (existingAlert) {
this.selectedAlert = existingAlert;
this.operator = existingAlert.operator;
this.threshold = existingAlert.threshold;
} else {
this.selectedAlert = {};
this.operator = null;
this.threshold = null;
}
this.prometheusMetricId = queryId;
},
handleCancel() { handleCancel() {
this.resetAlertData(); this.resetAlertData();
this.$emit('cancel'); this.$emit('cancel');
...@@ -83,14 +124,17 @@ export default { ...@@ -83,14 +124,17 @@ export default {
handleSubmit() { handleSubmit() {
this.$refs.submitButton.blur(); this.$refs.submitButton.blur();
this.$emit(this.submitAction, { this.$emit(this.submitAction, {
alert: this.alert, alert: this.selectedAlert.alert_path,
operator: this.operator, operator: this.operator,
threshold: this.threshold, threshold: this.threshold,
prometheus_metric_id: this.prometheusMetricId,
}); });
}, },
resetAlertData() { resetAlertData() {
this.operator = this.alertData.operator; this.operator = null;
this.threshold = this.alertData.threshold; this.threshold = null;
this.prometheusMetricId = null;
this.selectedAlert = {};
}, },
}, },
}; };
...@@ -98,10 +142,19 @@ export default { ...@@ -98,10 +142,19 @@ export default {
<template> <template>
<div class="alert-form"> <div class="alert-form">
<gl-dropdown :text="queryDropdownLabel" class="form-group" toggle-class="dropdown-menu-toggle">
<gl-dropdown-item
v-for="query in relevantQueries"
:key="query.metricId"
@click="selectQuery(query.metricId)"
>
{{ `${query.label} (${query.unit})` }}
</gl-dropdown-item>
</gl-dropdown>
<div :aria-label="s__('PrometheusAlerts|Operator')" class="form-group btn-group" role="group"> <div :aria-label="s__('PrometheusAlerts|Operator')" class="form-group btn-group" role="group">
<button <button
:class="{ active: operator === operators.greaterThan }" :class="{ active: operator === operators.greaterThan }"
:disabled="disabled" :disabled="formDisabled"
type="button" type="button"
class="btn btn-default" class="btn btn-default"
@click="operator = operators.greaterThan" @click="operator = operators.greaterThan"
...@@ -110,7 +163,7 @@ export default { ...@@ -110,7 +163,7 @@ export default {
</button> </button>
<button <button
:class="{ active: operator === operators.equalTo }" :class="{ active: operator === operators.equalTo }"
:disabled="disabled" :disabled="formDisabled"
type="button" type="button"
class="btn btn-default" class="btn btn-default"
@click="operator = operators.equalTo" @click="operator = operators.equalTo"
...@@ -119,7 +172,7 @@ export default { ...@@ -119,7 +172,7 @@ export default {
</button> </button>
<button <button
:class="{ active: operator === operators.lessThan }" :class="{ active: operator === operators.lessThan }"
:disabled="disabled" :disabled="formDisabled"
type="button" type="button"
class="btn btn-default" class="btn btn-default"
@click="operator = operators.lessThan" @click="operator = operators.lessThan"
...@@ -129,12 +182,17 @@ export default { ...@@ -129,12 +182,17 @@ export default {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>{{ s__('PrometheusAlerts|Threshold') }}</label> <label>{{ s__('PrometheusAlerts|Threshold') }}</label>
<input v-model.number="threshold" :disabled="disabled" type="number" class="form-control" /> <input
v-model.number="threshold"
:disabled="formDisabled"
type="number"
class="form-control"
/>
</div> </div>
<div class="action-group"> <div class="action-group">
<button <button
ref="cancelButton" ref="cancelButton"
:disabled="disabled" :disabled="formDisabled"
type="button" type="button"
class="btn btn-default" class="btn btn-default"
@click="handleCancel" @click="handleCancel"
......
...@@ -21,20 +21,21 @@ export default { ...@@ -21,20 +21,21 @@ export default {
}, },
data() { data() {
return { return {
alertData: {}, allAlerts: {},
}; };
}, },
methods: { computed: {
getGraphLabel(graphData) { alertsAvailable() {
if (!graphData.queries || !graphData.queries[0]) return undefined; return this.prometheusAlertsAvailable && this.alertsEndpoint;
return graphData.queries[0].label || graphData.y_label || 'Average';
}, },
getQueryAlerts(graphData) {
if (!graphData.queries) return [];
return graphData.queries.map(query => query.alert_path).filter(Boolean);
}, },
setAlerts(metricId, alertData) { methods: {
this.$set(this.alertData, metricId, alertData); setAlerts(alertPath, alertAttributes) {
if (alertAttributes) {
this.$set(this.allAlerts, alertPath, alertAttributes);
} else {
this.$delete(this.allAlerts, alertPath);
}
}, },
}, },
}; };
......
// Prop validator for alert information, expecting an object like the example below.
//
// {
// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': {
// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37",
// metricId: '1',
// operator: ">",
// query: "rate(http_requests_total[5m])[30m:1m]",
// threshold: 0.002,
// title: "Core Usage (Total)",
// }
// }
export function alertsValidator(value) {
return Object.keys(value).every(key => {
const alert = value[key];
return (
alert.alert_path &&
key === alert.alert_path &&
alert.metricId &&
typeof alert.metricId === 'string' &&
alert.operator &&
typeof alert.threshold === 'number'
);
});
}
// Prop validator for query information, expecting an array like the example below.
//
// [
// {
// metricId: '16',
// label: 'Total Cores'
// },
// {
// metricId: '17',
// label: 'Sub-total Cores'
// }
// ]
export function queriesValidator(value) {
return value.every(query => query.metricId && typeof query.metricId === 'string' && query.label);
}
...@@ -5,8 +5,22 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper'; ...@@ -5,8 +5,22 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('AlertWidgetForm', () => { describe('AlertWidgetForm', () => {
let AlertWidgetFormComponent; let AlertWidgetFormComponent;
let vm; let vm;
const metricId = '8';
const alertPath = 'alert';
const relevantQueries = [{ metricId, alert_path: alertPath, label: 'alert-label' }];
const props = { const props = {
disabled: false, disabled: false,
relevantQueries,
};
const propsWithAlertData = {
...props,
relevantQueries,
alertsToManage: {
alert: { alert_path: alertPath, operator: '<', threshold: 5, metricId },
},
}; };
beforeAll(() => { beforeAll(() => {
...@@ -20,6 +34,15 @@ describe('AlertWidgetForm', () => { ...@@ -20,6 +34,15 @@ describe('AlertWidgetForm', () => {
it('disables the input when disabled prop is set', () => { it('disables the input when disabled prop is set', () => {
vm = mountComponent(AlertWidgetFormComponent, { ...props, disabled: true }); vm = mountComponent(AlertWidgetFormComponent, { ...props, disabled: true });
vm.prometheusMetricId = 6;
expect(vm.$refs.cancelButton).toBeDisabled();
expect(vm.$refs.submitButton).toBeDisabled();
});
it('disables the input if no query is selected', () => {
vm = mountComponent(AlertWidgetFormComponent, props);
expect(vm.$refs.cancelButton).toBeDisabled(); expect(vm.$refs.cancelButton).toBeDisabled();
expect(vm.$refs.submitButton).toBeDisabled(); expect(vm.$refs.submitButton).toBeDisabled();
}); });
...@@ -30,15 +53,17 @@ describe('AlertWidgetForm', () => { ...@@ -30,15 +53,17 @@ describe('AlertWidgetForm', () => {
expect(vm.$refs.submitButton.innerText).toContain('Add'); expect(vm.$refs.submitButton.innerText).toContain('Add');
vm.$once('create', alert => { vm.$once('create', alert => {
expect(alert).toEqual({ expect(alert).toEqual({
alert: null, alert: undefined,
operator: '<', operator: '<',
threshold: 5, threshold: 5,
prometheus_metric_id: '8',
}); });
done(); done();
}); });
// the button should be disabled until an operator and threshold are selected // the button should be disabled until an operator and threshold are selected
expect(vm.$refs.submitButton).toBeDisabled(); expect(vm.$refs.submitButton).toBeDisabled();
vm.selectQuery('8');
vm.operator = '<'; vm.operator = '<';
vm.threshold = 5; vm.threshold = 5;
Vue.nextTick(() => { Vue.nextTick(() => {
...@@ -47,41 +72,39 @@ describe('AlertWidgetForm', () => { ...@@ -47,41 +72,39 @@ describe('AlertWidgetForm', () => {
}); });
it('emits a "delete" event when form submitted with existing alert and no changes are made', done => { it('emits a "delete" event when form submitted with existing alert and no changes are made', done => {
vm = mountComponent(AlertWidgetFormComponent, { vm = mountComponent(AlertWidgetFormComponent, propsWithAlertData);
...props, vm.selectQuery('8');
alert: 'alert',
alertData: { operator: '<', threshold: 5 },
});
vm.$once('delete', alert => { vm.$once('delete', alert => {
expect(alert).toEqual({ expect(alert).toEqual({
alert: 'alert', alert: 'alert',
operator: '<', operator: '<',
threshold: 5, threshold: 5,
prometheus_metric_id: '8',
}); });
done(); done();
}); });
Vue.nextTick(() => {
expect(vm.$refs.submitButton.innerText).toContain('Delete'); expect(vm.$refs.submitButton.innerText).toContain('Delete');
vm.$refs.submitButton.click(); vm.$refs.submitButton.click();
}); });
it('emits a "update" event when form submitted with existing alert', done => {
vm = mountComponent(AlertWidgetFormComponent, {
...props,
alert: 'alert',
alertData: { operator: '<', threshold: 5 },
}); });
expect(vm.$refs.submitButton.innerText).toContain('Delete'); it('emits a "update" event when form submitted with existing alert', done => {
vm = mountComponent(AlertWidgetFormComponent, propsWithAlertData);
vm.selectQuery('8');
vm.$once('update', alert => { vm.$once('update', alert => {
expect(alert).toEqual({ expect(alert).toEqual({
alert: 'alert', alert: 'alert',
operator: '=', operator: '=',
threshold: 5, threshold: 5,
prometheus_metric_id: '8',
}); });
done(); done();
}); });
Vue.nextTick(() => {
expect(vm.$refs.submitButton.innerText).toContain('Delete');
// change operator to allow update // change operator to allow update
vm.operator = '='; vm.operator = '=';
...@@ -90,4 +113,5 @@ describe('AlertWidgetForm', () => { ...@@ -90,4 +113,5 @@ describe('AlertWidgetForm', () => {
vm.$refs.submitButton.click(); vm.$refs.submitButton.click();
}); });
}); });
});
}); });
...@@ -6,16 +6,33 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper'; ...@@ -6,16 +6,33 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('AlertWidget', () => { describe('AlertWidget', () => {
let AlertWidgetComponent; let AlertWidgetComponent;
let vm; let vm;
const metricId = '5';
const alertPath = 'my/alert.json';
const relevantQueries = [{ metricId, label: 'alert-label', alert_path: alertPath }];
const props = { const props = {
alertsEndpoint: '', alertsEndpoint: '',
customMetricId: 5, relevantQueries,
label: 'alert-label', alertsToManage: {},
currentAlerts: ['my/alert.json'], };
const propsWithAlert = {
...props,
relevantQueries,
}; };
const mockSetAlerts = (_, data) => { const propsWithAlertData = {
/* eslint-disable-next-line no-underscore-dangle */ ...props,
Vue.set(vm._props, 'alertData', data); relevantQueries,
alertsToManage: {
[alertPath]: { operator: '>', threshold: 42, alert_path: alertPath, metricId },
},
};
const mockSetAlerts = (path, data) => {
const alerts = data ? { [path]: data } : {};
Vue.set(vm, 'alertsToManage', alerts);
}; };
beforeAll(() => { beforeAll(() => {
...@@ -38,7 +55,7 @@ describe('AlertWidget', () => { ...@@ -38,7 +55,7 @@ describe('AlertWidget', () => {
resolveReadAlert = cb; resolveReadAlert = cb;
}), }),
); );
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget'); vm = mountComponent(AlertWidgetComponent, propsWithAlert, '#alert-widget');
// expect loading spinner to exist during fetch // expect loading spinner to exist during fetch
expect(vm.isLoading).toBeTruthy(); expect(vm.isLoading).toBeTruthy();
...@@ -58,7 +75,7 @@ describe('AlertWidget', () => { ...@@ -58,7 +75,7 @@ describe('AlertWidget', () => {
it('displays an error message when fetch fails', done => { it('displays an error message when fetch fails', done => {
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.reject()); spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.reject());
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget'); vm = mountComponent(AlertWidgetComponent, propsWithAlert, '#alert-widget');
setTimeout(() => setTimeout(() =>
vm.$nextTick(() => { vm.$nextTick(() => {
...@@ -70,28 +87,48 @@ describe('AlertWidget', () => { ...@@ -70,28 +87,48 @@ describe('AlertWidget', () => {
); );
}); });
it('displays an alert summary when fetch succeeds', done => { it('displays an alert summary when there is a single alert', () => {
spyOn(AlertsService.prototype, 'readAlert').and.returnValue( spyOn(AlertsService.prototype, 'readAlert').and.returnValue(
Promise.resolve({ operator: '>', threshold: 42 }), Promise.resolve({ operator: '>', threshold: 42 }),
); );
const propsWithAlertData = {
...props,
alertData: { 'my/alert.json': { operator: '>', threshold: 42 } },
};
vm = mountComponent(AlertWidgetComponent, propsWithAlertData, '#alert-widget'); vm = mountComponent(AlertWidgetComponent, propsWithAlertData, '#alert-widget');
setTimeout(() =>
vm.$nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label > 42'); expect(vm.alertSummary).toBe('alert-label > 42');
expect(vm.$el.querySelector('.alert-current-setting')).toBeVisible(); expect(vm.$el.querySelector('.alert-current-setting')).toBeVisible();
done(); });
}),
it('displays a combined alert summary when there are multiple alerts', () => {
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(
Promise.resolve({ operator: '>', threshold: 42 }),
); );
const propsWithManyAlerts = {
...props,
relevantQueries: relevantQueries.concat([
{ metricId: '6', alert_path: 'my/alert2.json', label: 'alert-label2' },
]),
alertsToManage: {
'my/alert.json': {
operator: '>',
threshold: 42,
alert_path: alertPath,
metricId,
},
'my/alert2.json': {
operator: '=',
threshold: 900,
alert_path: 'my/alert2.json',
metricId: '6',
},
},
};
vm = mountComponent(AlertWidgetComponent, propsWithManyAlerts, '#alert-widget');
expect(vm.alertSummary).toBe('alert-label > 42, alert-label2 = 900');
expect(vm.$el.querySelector('.alert-current-setting')).toBeVisible();
}); });
it('opens and closes a dropdown menu by clicking close button', done => { it('opens and closes a dropdown menu by clicking close button', done => {
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] }); vm = mountComponent(AlertWidgetComponent, props);
expect(vm.isOpen).toEqual(false); expect(vm.isOpen).toEqual(false);
expect(vm.$el.querySelector('.alert-dropdown-menu')).toBeHidden(); expect(vm.$el.querySelector('.alert-dropdown-menu')).toBeHidden();
...@@ -100,20 +137,18 @@ describe('AlertWidget', () => { ...@@ -100,20 +137,18 @@ describe('AlertWidget', () => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.isOpen).toEqual(true); expect(vm.isOpen).toEqual(true);
expect(vm.$el).toHaveClass('show');
vm.$el.querySelector('.dropdown-menu-close').click(); vm.$el.querySelector('.dropdown-menu-close').click();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.isOpen).toEqual(false); expect(vm.isOpen).toEqual(false);
expect(vm.$el).not.toHaveClass('show');
done(); done();
}); });
}); });
}); });
it('opens and closes a dropdown menu by clicking outside the menu', done => { it('opens and closes a dropdown menu by clicking outside the menu', done => {
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] }); vm = mountComponent(AlertWidgetComponent, props);
expect(vm.isOpen).toEqual(false); expect(vm.isOpen).toEqual(false);
expect(vm.$el.querySelector('.alert-dropdown-menu')).toBeHidden(); expect(vm.$el.querySelector('.alert-dropdown-menu')).toBeHidden();
...@@ -122,13 +157,11 @@ describe('AlertWidget', () => { ...@@ -122,13 +157,11 @@ describe('AlertWidget', () => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.isOpen).toEqual(true); expect(vm.isOpen).toEqual(true);
expect(vm.$el).toHaveClass('show');
document.body.click(); document.body.click();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.isOpen).toEqual(false); expect(vm.isOpen).toEqual(false);
expect(vm.$el).not.toHaveClass('show');
done(); done();
}); });
}); });
...@@ -138,17 +171,14 @@ describe('AlertWidget', () => { ...@@ -138,17 +171,14 @@ describe('AlertWidget', () => {
const alertParams = { const alertParams = {
operator: '<', operator: '<',
threshold: 4, threshold: 4,
prometheus_metric_id: 5, prometheus_metric_id: '5',
}; };
spyOn(AlertsService.prototype, 'createAlert').and.returnValue( spyOn(AlertsService.prototype, 'createAlert').and.returnValue(
Promise.resolve({ Promise.resolve({ alert_path: 'foo/bar', ...alertParams }),
alert_path: 'foo/bar',
...alertParams,
}),
); );
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] }); vm = mountComponent(AlertWidgetComponent, props);
vm.$on('setAlerts', mockSetAlerts); vm.$on('setAlerts', mockSetAlerts);
vm.$refs.widgetForm.$emit('create', alertParams); vm.$refs.widgetForm.$emit('create', alertParams);
...@@ -156,54 +186,40 @@ describe('AlertWidget', () => { ...@@ -156,54 +186,40 @@ describe('AlertWidget', () => {
expect(AlertsService.prototype.createAlert).toHaveBeenCalledWith(alertParams); expect(AlertsService.prototype.createAlert).toHaveBeenCalledWith(alertParams);
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.isLoading).toEqual(false); expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label < 4');
done(); done();
}); });
}); });
it('updates an alert with an appropriate handler', done => { it('updates an alert with an appropriate handler', done => {
const alertPath = 'my/test/alert.json'; const alertParams = { operator: '<', threshold: 4, alert_path: alertPath };
const alertParams = { const newAlertParams = { operator: '=', threshold: 12 };
operator: '<',
threshold: 4,
};
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.resolve(alertParams)); spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.resolve(alertParams));
spyOn(AlertsService.prototype, 'updateAlert').and.returnValue(Promise.resolve()); spyOn(AlertsService.prototype, 'updateAlert').and.returnValue(Promise.resolve({}));
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [alertPath] }); vm = mountComponent(AlertWidgetComponent, propsWithAlertData);
vm.$on('setAlerts', mockSetAlerts); vm.$on('setAlerts', mockSetAlerts);
vm.$refs.widgetForm.$emit('update', { vm.$refs.widgetForm.$emit('update', {
...alertParams,
alert: alertPath, alert: alertPath,
operator: '=', ...newAlertParams,
threshold: 12, prometheus_metric_id: '5',
}); });
expect(AlertsService.prototype.updateAlert).toHaveBeenCalledWith(alertPath, { expect(AlertsService.prototype.updateAlert).toHaveBeenCalledWith(alertPath, newAlertParams);
...alertParams,
operator: '=',
threshold: 12,
});
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.isLoading).toEqual(false); expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label = 12');
done(); done();
}); });
}); });
it('deletes an alert with an appropriate handler', done => { it('deletes an alert with an appropriate handler', done => {
const alertPath = 'my/test/alert.json'; const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 };
const alertParams = {
operator: '<',
threshold: 4,
};
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.resolve(alertParams)); spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.resolve(alertParams));
spyOn(AlertsService.prototype, 'deleteAlert').and.returnValue(Promise.resolve()); spyOn(AlertsService.prototype, 'deleteAlert').and.returnValue(Promise.resolve({}));
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [alertPath] }); vm = mountComponent(AlertWidgetComponent, propsWithAlert);
vm.$on('setAlerts', mockSetAlerts); vm.$on('setAlerts', mockSetAlerts);
vm.$refs.widgetForm.$emit('delete', { alert: alertPath }); vm.$refs.widgetForm.$emit('delete', { alert: alertPath });
......
import { alertsValidator, queriesValidator } from 'ee/monitoring/validators';
describe('alertsValidator', () => {
const validAlert = {
alert_path: 'my/alert.json',
operator: '<',
threshold: 5,
metricId: '8',
};
it('requires all alerts to have an alert path', () => {
const { operator, threshold, metricId } = validAlert;
const input = { [validAlert.alert_path]: { operator, threshold, metricId } };
expect(alertsValidator(input)).toEqual(false);
});
it('requires that the object key matches the alert path', () => {
const input = { undefined: validAlert };
expect(alertsValidator(input)).toEqual(false);
});
it('requires all alerts to have a metric id', () => {
const input = { [validAlert.alert_path]: { ...validAlert, metricId: undefined } };
expect(alertsValidator(input)).toEqual(false);
});
it('requires the metricId to be a string', () => {
const input = { [validAlert.alert_path]: { ...validAlert, metricId: 8 } };
expect(alertsValidator(input)).toEqual(false);
});
it('requires all alerts to have an operator', () => {
const input = { [validAlert.alert_path]: { ...validAlert, operator: '' } };
expect(alertsValidator(input)).toEqual(false);
});
it('requires all alerts to have an numeric threshold', () => {
const input = { [validAlert.alert_path]: { ...validAlert, threshold: '60' } };
expect(alertsValidator(input)).toEqual(false);
});
it('correctly identifies a valid alerts object', () => {
const input = { [validAlert.alert_path]: validAlert };
expect(alertsValidator(input)).toEqual(true);
});
});
describe('queriesValidator', () => {
const validQuery = { metricId: '8', alert_path: 'alert', label: 'alert-label' };
it('requires all alerts to have a metric id', () => {
const input = [{ ...validQuery, metricId: undefined }];
expect(queriesValidator(input)).toEqual(false);
});
it('requires the metricId to be a string', () => {
const input = [{ ...validQuery, metricId: 8 }];
expect(queriesValidator(input)).toEqual(false);
});
it('requires all queries to have a label', () => {
const input = [{ ...validQuery, label: undefined }];
expect(queriesValidator(input)).toEqual(false);
});
it('correctly identifies a valid queries array', () => {
const input = [validQuery];
expect(queriesValidator(input)).toEqual(true);
});
});
...@@ -8460,6 +8460,9 @@ msgstr "" ...@@ -8460,6 +8460,9 @@ msgstr ""
msgid "PrometheusAlerts|Operator" msgid "PrometheusAlerts|Operator"
msgstr "" msgstr ""
msgid "PrometheusAlerts|Select query"
msgstr ""
msgid "PrometheusAlerts|Threshold" msgid "PrometheusAlerts|Threshold"
msgstr "" msgstr ""
......
...@@ -65,7 +65,7 @@ describe('Area component', () => { ...@@ -65,7 +65,7 @@ describe('Area component', () => {
expect(props.data).toBe(areaChart.vm.chartData); expect(props.data).toBe(areaChart.vm.chartData);
expect(props.option).toBe(areaChart.vm.chartOptions); expect(props.option).toBe(areaChart.vm.chartOptions);
expect(props.formatTooltipText).toBe(areaChart.vm.formatTooltipText); expect(props.formatTooltipText).toBe(areaChart.vm.formatTooltipText);
expect(props.thresholds).toBe(areaChart.props('alertData')); expect(props.thresholds).toBe(areaChart.vm.thresholds);
}); });
it('recieves a tooltip title', () => { it('recieves a tooltip title', () => {
...@@ -105,12 +105,13 @@ describe('Area component', () => { ...@@ -105,12 +105,13 @@ describe('Area component', () => {
seriesName: areaChart.vm.chartData[0].name, seriesName: areaChart.vm.chartData[0].name,
componentSubType: type, componentSubType: type,
value: [mockDate, 5.55555], value: [mockDate, 5.55555],
seriesIndex: 0,
}, },
], ],
value: mockDate, value: mockDate,
}); });
describe('series is of line type', () => { describe('when series is of line type', () => {
beforeEach(() => { beforeEach(() => {
areaChart.vm.formatTooltipText(generateSeriesData('line')); areaChart.vm.formatTooltipText(generateSeriesData('line'));
}); });
...@@ -131,7 +132,7 @@ describe('Area component', () => { ...@@ -131,7 +132,7 @@ describe('Area component', () => {
}); });
}); });
describe('series is of scatter type', () => { describe('when series is of scatter type', () => {
beforeEach(() => { beforeEach(() => {
areaChart.vm.formatTooltipText(generateSeriesData('scatter')); areaChart.vm.formatTooltipText(generateSeriesData('scatter'));
}); });
......
...@@ -663,10 +663,10 @@ ...@@ -663,10 +663,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.58.0.tgz#bb05263ff2eb7ca09a25cd14d0b1a932d2ea9c2f" resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.58.0.tgz#bb05263ff2eb7ca09a25cd14d0b1a932d2ea9c2f"
integrity sha512-RlWSjjBT4lMIFuNC1ziCO1nws9zqZtxCjhrqK2DxDDTgp2W0At9M/BFkHp8RHyMCrO3g1fHTrLPUgzr5oR3Epg== integrity sha512-RlWSjjBT4lMIFuNC1ziCO1nws9zqZtxCjhrqK2DxDDTgp2W0At9M/BFkHp8RHyMCrO3g1fHTrLPUgzr5oR3Epg==
"@gitlab/ui@^3.0.0": "@gitlab/ui@^3.0.1":
version "3.0.0" version "3.0.2"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.0.0.tgz#33ca2808dbd4395e69a366a219d1edc1f3dbccd5" resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.0.2.tgz#29a17699751261657487b939c651c0f93264df2a"
integrity sha512-pDEa2k6ln5GE/N2z0V7dNEeFtSTW0p9ipO2/N9q6QMxO7fhhOhpMC0QVbdIljKTbglspDWI5v6BcqUjzYri5Pg== integrity sha512-JZhcS5cDxtpxopTc55UWvUbZAwKvxygYHT9I01QmUtKgaKIJlnjBj8zkcg1xHazX7raSjjtjqfDEla39a+luuQ==
dependencies: dependencies:
"@babel/standalone" "^7.0.0" "@babel/standalone" "^7.0.0"
bootstrap-vue "^2.0.0-rc.11" bootstrap-vue "^2.0.0-rc.11"
......
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