Commit db6629da authored by Miguel Rincon's avatar Miguel Rincon Committed by Kushal Pandya

Display when a panel group is in an empty state in the dashboard

The metrics dashboard didn't let users know when data was missing from
a panel. This change is the first step to know why data is missing.

This change makes use of getter to calculate if the a panel group of
the dashboard should be displayed as empty.
parent d1bd7207
<script>
import _ from 'underscore';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
GlButton,
......@@ -99,6 +99,10 @@ export default {
type: String,
required: true,
},
emptyNoDataSmallSvgPath: {
type: String,
required: true,
},
emptyUnableToConnectSvgPath: {
type: String,
required: true,
......@@ -176,11 +180,11 @@ export default {
'showEmptyState',
'environments',
'deploymentData',
'metricsWithData',
'useDashboardEndpoint',
'allDashboards',
'additionalPanelTypesEnabled',
]),
...mapGetters('monitoringDashboard', ['metricsWithData']),
firstDashboard() {
return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0
? this.allDashboards[0]
......@@ -280,13 +284,8 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
chartsWithData(panels) {
return panels.filter(panel =>
panel.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
);
},
groupHasData(group) {
return this.chartsWithData(group.panels).length > 0;
return this.metricsWithData(group.key).length > 0;
},
onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
......@@ -447,42 +446,61 @@ export default {
:key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
:collapse-group="groupHasData(groupData)"
:collapse-group="!groupHasData(groupData)"
>
<vue-draggable
:value="groupData.panels"
group="metrics-dashboard"
:component-data="{ attrs: { class: 'row mx-0 w-100' } }"
:disabled="!isRearrangingPanels"
@input="updatePanels(groupData.key, $event)"
>
<div
v-for="(graphData, graphIndex) in groupData.panels"
:key="`panel-type-${graphIndex}`"
class="col-12 col-lg-6 px-2 mb-2 draggable"
:class="{ 'draggable-enabled': isRearrangingPanels }"
<div v-if="groupHasData(groupData)">
<vue-draggable
:value="groupData.panels"
group="metrics-dashboard"
:component-data="{ attrs: { class: 'row mx-0 w-100' } }"
:disabled="!isRearrangingPanels"
@input="updatePanels(groupData.key, $event)"
>
<div class="position-relative draggable-panel js-draggable-panel">
<div
v-if="isRearrangingPanels"
class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
@click="removePanel(groupData.key, groupData.panels, graphIndex)"
>
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
><icon name="close"
/></a>
</div>
<div
v-for="(graphData, graphIndex) in groupData.panels"
:key="`panel-type-${graphIndex}`"
class="col-12 col-lg-6 px-2 mb-2 draggable"
:class="{ 'draggable-enabled': isRearrangingPanels }"
>
<div class="position-relative draggable-panel js-draggable-panel">
<div
v-if="isRearrangingPanels"
class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
@click="removePanel(groupData.key, groupData.panels, graphIndex)"
>
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
><icon name="close"
/></a>
</div>
<panel-type
:clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
:graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
:prometheus-alerts-available="prometheusAlertsAvailable"
:index="`${index}-${graphIndex}`"
/>
<panel-type
:clipboard-text="
generateLink(groupData.group, graphData.title, graphData.y_label)
"
:graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
:prometheus-alerts-available="prometheusAlertsAvailable"
:index="`${index}-${graphIndex}`"
/>
</div>
</div>
</div>
</vue-draggable>
</vue-draggable>
</div>
<div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
<empty-state
ref="empty-group"
selected-state="noDataGroup"
:documentation-path="documentationPath"
:settings-path="settingsPath"
:clusters-path="clustersPath"
:empty-getting-started-svg-path="emptyGettingStartedSvgPath"
:empty-loading-svg-path="emptyLoadingSvgPath"
:empty-no-data-svg-path="emptyNoDataSvgPath"
:empty-no-data-small-svg-path="emptyNoDataSmallSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
:compact="true"
/>
</div>
</graph-group>
</div>
<empty-state
......@@ -494,6 +512,7 @@ export default {
:empty-getting-started-svg-path="emptyGettingStartedSvgPath"
:empty-loading-svg-path="emptyLoadingSvgPath"
:empty-no-data-svg-path="emptyNoDataSvgPath"
:empty-no-data-small-svg-path="emptyNoDataSmallSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
:compact="smallEmptyState"
/>
......
<script>
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import GraphGroup from './graph_group.vue';
......@@ -35,7 +35,8 @@ export default {
};
},
computed: {
...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']),
...mapState('monitoringDashboard', ['dashboard']),
...mapGetters('monitoringDashboard', ['metricsWithData']),
charts() {
if (!this.dashboard || !this.dashboard.panel_groups) {
return [];
......@@ -73,7 +74,7 @@ export default {
'setShowErrorBanner',
]),
chartHasData(chart) {
return chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id));
return chart.metrics.some(metric => this.metricsWithData().includes(metric.metric_id));
},
onSidebarMutation() {
setTimeout(() => {
......
......@@ -37,6 +37,10 @@ export default {
type: String,
required: true,
},
emptyNoDataSmallSvgPath: {
type: String,
required: true,
},
emptyUnableToConnectSvgPath: {
type: String,
required: true,
......@@ -80,6 +84,11 @@ export default {
secondaryButtonText: '',
secondaryButtonPath: '',
},
noDataGroup: {
svgUrl: this.emptyNoDataSmallSvgPath,
title: __('No data to display'),
description: __('The data source is connected, but there is no data to display.'),
},
unableToConnect: {
svgUrl: this.emptyUnableToConnectSvgPath,
title: __('Unable to connect to Prometheus server'),
......
......@@ -15,31 +15,44 @@ export default {
required: false,
default: true,
},
/**
* Initial value of collapse on mount.
*/
collapseGroup: {
type: Boolean,
required: true,
required: false,
default: false,
},
},
data() {
return {
showGroup: true,
isCollapsed: this.collapseGroup,
};
},
computed: {
caretIcon() {
return this.collapseGroup && this.showGroup ? 'angle-down' : 'angle-right';
return this.isCollapsed ? 'angle-right' : 'angle-down';
},
},
watch: {
collapseGroup(val) {
// Respond to changes in collapseGroup but do not
// collapse it once was opened by the user.
if (this.showPanels && !val) {
this.isCollapsed = false;
}
},
},
methods: {
collapse() {
this.showGroup = !this.showGroup;
this.isCollapsed = !this.isCollapsed;
},
},
};
</script>
<template>
<div v-if="showPanels" class="card prometheus-panel">
<div v-if="showPanels" ref="graph-group" class="card prometheus-panel">
<div class="card-header d-flex align-items-center">
<h4 class="flex-grow-1">{{ name }}</h4>
<a role="button" class="js-graph-group-toggle" @click="collapse">
......@@ -47,12 +60,12 @@ export default {
</a>
</div>
<div
v-if="collapseGroup"
v-show="collapseGroup && showGroup"
v-show="!isCollapsed"
ref="graph-group-content"
class="card-body prometheus-graph-group p-0"
>
<slot></slot>
</div>
</div>
<div v-else class="prometheus-graph-group"><slot></slot></div>
<div v-else ref="graph-group-content" class="prometheus-graph-group"><slot></slot></div>
</template>
......@@ -4,7 +4,7 @@ import createFlash from '~/flash';
import trackDashboardLoad from '../monitoring_tracking_helper';
import statusCodes from '../../lib/utils/http_status';
import { backOff } from '../../lib/utils/common_utils';
import { s__ } from '../../locale';
import { s__, sprintf } from '../../locale';
const TWO_MINUTES = 120000;
......@@ -74,17 +74,21 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data)
.then(response => dispatch('receiveMetricsDashboardSuccess', { response, params }))
.then(() => {
const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
return trackDashboardLoad({
label: `${dashboardType}_metrics_dashboard`,
value: state.metricsWithData.length,
});
})
.catch(error => {
dispatch('receiveMetricsDashboardFailure', error);
if (state.setShowErrorBanner) {
createFlash(s__('Metrics|There was an error while retrieving metrics'));
.catch(e => {
dispatch('receiveMetricsDashboardFailure', e);
if (state.showErrorBanner) {
if (e.response.data && e.response.data.message) {
const { message } = e.response.data;
createFlash(
sprintf(
s__('Metrics|There was an error while retrieving metrics. %{message}'),
{ message },
false,
),
);
} else {
createFlash(s__('Metrics|There was an error while retrieving metrics'));
}
}
});
};
......@@ -126,7 +130,7 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => {
});
};
export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => {
export const fetchPrometheusMetrics = ({ state, commit, dispatch, getters }, params) => {
commit(types.REQUEST_METRICS_DATA);
const promises = [];
......@@ -140,9 +144,11 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => {
return Promise.all(promises)
.then(() => {
if (state.metricsWithData.length === 0) {
commit(types.SET_NO_DATA_EMPTY_STATE);
}
const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
trackDashboardLoad({
label: `${dashboardType}_metrics_dashboard`,
value: getters.metricsWithData().length,
});
})
.catch(() => {
createFlash(s__(`Metrics|There was an error while retrieving metrics`), 'warning');
......
const metricsIdsInPanel = panel =>
panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
/**
* Getter to obtain the list of metric ids that have data
*
* Useful to understand which parts of the dashboard should
* be displayed. It is a Vuex Method-Style Access getter.
*
* @param {Object} state
* @returns {Function} A function that returns an array of
* metrics in the dashboard that contain results, optionally
* filtered by group key.
*/
export const metricsWithData = state => groupKey => {
let groups = state.dashboard.panel_groups;
if (groupKey) {
groups = groups.filter(group => group.key === groupKey);
}
const res = [];
groups.forEach(group => {
group.panels.forEach(panel => {
res.push(...metricsIdsInPanel(panel));
});
});
return res;
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
......@@ -12,6 +13,7 @@ export const createStore = () =>
monitoringDashboard: {
namespaced: true,
actions,
getters,
mutations,
state,
},
......
......@@ -67,7 +67,6 @@ export default {
group.panels.forEach(panel => {
panel.metrics.forEach(metric => {
if (metric.metric_id === metricId) {
state.metricsWithData.push(metricId);
// ensure dates/numbers are correctly formatted for charts
const normalizedResults = result.map(normalizeQueryResult);
Vue.set(metric, 'result', Object.freeze(normalizedResults));
......
......@@ -13,7 +13,6 @@ export default () => ({
},
deploymentData: [],
environments: [],
metricsWithData: [],
allDashboards: [],
currentDashboard: null,
projectPath: null,
......
......@@ -67,7 +67,6 @@
.prometheus-graph-group {
display: flex;
flex-wrap: wrap;
margin-top: $gl-padding-8;
}
.prometheus-graph {
......
......@@ -26,6 +26,7 @@ module EnvironmentsHelper
"empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'),
"empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'),
"empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'),
"empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'),
"metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json),
"dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json),
......
---
title: Add empty region when group metrics are missing
merge_request: 20900
author:
type: fixed
......@@ -77,14 +77,31 @@ describe('Dashboard', () => {
}
describe('add custom metrics', () => {
const defaultProps = {
customMetricsPath: '/endpoint',
hasMetrics: true,
documentationPath: '/path/to/docs',
settingsPath: '/path/to/settings',
clustersPath: '/path/to/clusters',
projectPath: '/path/to/project',
metricsEndpoint: mockApiEndpoint,
tagsPath: '/path/to/tags',
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
environmentsEndpoint: '/root/hello-prometheus/environments/35',
currentEnvironmentName: 'production',
prometheusAlertsAvailable: true,
alertsEndpoint: '/endpoint',
};
describe('when not available', () => {
beforeEach(() => {
createComponent({
customMetricsAvailable: false,
customMetricsPath: '/endpoint',
hasMetrics: true,
prometheusAlertsAvailable: true,
alertsEndpoint: '/endpoint',
...defaultProps,
});
});
......@@ -101,10 +118,7 @@ describe('Dashboard', () => {
createComponent({
customMetricsAvailable: true,
customMetricsPath: '/endpoint',
hasMetrics: true,
prometheusAlertsAvailable: true,
alertsEndpoint: '/endpoint',
...defaultProps,
});
setupComponentStore(wrapper);
......
......@@ -11157,6 +11157,9 @@ msgstr ""
msgid "Metrics|There was an error while retrieving metrics"
msgstr ""
msgid "Metrics|There was an error while retrieving metrics. %{message}"
msgstr ""
msgid "Metrics|Unexpected deployment data response from prometheus endpoint"
msgstr ""
......@@ -17478,6 +17481,9 @@ msgstr ""
msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository."
msgstr ""
msgid "The data source is connected, but there is no data to display."
msgstr ""
msgid "The default CI configuration path for new projects."
msgstr ""
......
......@@ -45,10 +45,11 @@ describe('Time series component', () => {
store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData);
// Mock data contains 2 panels, pick the first one
// Mock data contains 2 panel groups, with 1 and 2 panels respectively
store.commit(`monitoringDashboard/${types.SET_QUERY_RESULT}`, mockedQueryResultPayload);
[mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[0].panels;
// Pick the second panel group and the first panel in it
[mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[1].panels;
makeTimeSeriesChart = (graphData, type) =>
shallowMount(TimeSeries, {
......
......@@ -11,6 +11,7 @@ function createComponent(props) {
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
},
});
......
......@@ -12,6 +12,7 @@ describe('Embed', () => {
let wrapper;
let store;
let actions;
let metricsWithDataGetter;
function mountComponent() {
wrapper = shallowMount(Embed, {
......@@ -31,11 +32,16 @@ describe('Embed', () => {
fetchMetricsData: () => {},
};
metricsWithDataGetter = jest.fn();
store = new Vuex.Store({
modules: {
monitoringDashboard: {
namespaced: true,
actions,
getters: {
metricsWithData: () => metricsWithDataGetter,
},
state: initialState,
},
},
......@@ -43,6 +49,7 @@ describe('Embed', () => {
});
afterEach(() => {
metricsWithDataGetter.mockClear();
if (wrapper) {
wrapper.destroy();
}
......@@ -63,13 +70,13 @@ describe('Embed', () => {
beforeEach(() => {
store.state.monitoringDashboard.dashboard.panel_groups = groups;
store.state.monitoringDashboard.dashboard.panel_groups[0].panels = metricsData;
store.state.monitoringDashboard.metricsWithData = metricsWithData;
metricsWithDataGetter.mockReturnValue(metricsWithData);
mountComponent();
});
it('shows a chart when metrics are present', () => {
wrapper.setProps({});
expect(wrapper.find('.metrics-embed').exists()).toBe(true);
expect(wrapper.find(PanelType).exists()).toBe(true);
expect(wrapper.findAll(PanelType).length).toBe(2);
......
......@@ -75,11 +75,9 @@ export const metricsData = [
},
];
export const initialState = {
monitoringDashboard: {},
export const initialState = () => ({
dashboard: {
panel_groups: [],
},
metricsWithData: [],
useDashboardEndpoint: true,
};
});
......@@ -240,6 +240,11 @@ export const metricsNewGroupsAPIResponse = [
},
];
export const mockedEmptyResult = {
metricId: '1_response_metrics_nginx_ingress_throughput_status_code',
result: [],
};
export const mockedQueryResultPayload = {
metricId: '17_system_metrics_kubernetes_container_memory_average',
result: [
......@@ -327,6 +332,30 @@ export const mockedQueryResultPayloadCoresTotal = {
};
export const metricsGroupsAPIResponse = [
{
group: 'Response metrics (NGINX Ingress VTS)',
priority: 10,
panels: [
{
metrics: [
{
id: 'response_metrics_nginx_ingress_throughput_status_code',
label: 'Status Code',
metric_id: 1,
prometheus_endpoint_path:
'/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29',
query_range:
'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)',
unit: 'req / sec',
},
],
title: 'Throughput',
type: 'area-chart',
weight: 1,
y_label: 'Requests / Sec',
},
],
},
{
group: 'System metrics (Kubernetes)',
priority: 5,
......
......@@ -191,12 +191,11 @@ describe('Monitoring store actions', () => {
let state;
const response = metricsDashboardResponse;
beforeEach(() => {
jest.spyOn(Tracking, 'event');
dispatch = jest.fn();
state = storeState();
state.dashboardEndpoint = '/dashboard';
});
it('dispatches receive and success actions', done => {
it('on success, dispatches receive and success actions', done => {
const params = {};
document.body.dataset.page = 'projects:environments:metrics';
mock.onGet(state.dashboardEndpoint).reply(200, response);
......@@ -213,39 +212,65 @@ describe('Monitoring store actions', () => {
response,
params,
});
})
.then(() => {
expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
'dashboard_fetch',
{
label: 'custom_metrics_dashboard',
property: 'count',
value: 0,
},
);
done();
})
.catch(done.fail);
});
it('dispatches failure action', done => {
const params = {};
mock.onGet(state.dashboardEndpoint).reply(500);
fetchDashboard(
{
state,
dispatch,
},
params,
)
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
done();
})
.catch(done.fail);
describe('on failure', () => {
let result;
let errorResponse;
beforeEach(() => {
const params = {};
result = () => {
mock.onGet(state.dashboardEndpoint).replyOnce(500, errorResponse);
return fetchDashboard({ state, dispatch }, params);
};
});
it('dispatches a failure action', done => {
errorResponse = {};
result()
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
expect(createFlash).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('dispatches a failure action when a message is returned', done => {
const message = 'Something went wrong with Prometheus!';
errorResponse = { message };
result()
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
expect(createFlash).toHaveBeenCalledWith(expect.stringContaining(message));
done();
})
.catch(done.fail);
});
it('does not show a flash error when showErrorBanner is disabled', done => {
state.showErrorBanner = false;
result()
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
expect(createFlash).not.toHaveBeenCalled();
done();
})
.catch(done.fail);
});
});
});
describe('receiveMetricsDashboardSuccess', () => {
......@@ -317,18 +342,33 @@ describe('Monitoring store actions', () => {
});
});
describe('fetchPrometheusMetrics', () => {
const params = {};
let commit;
let dispatch;
let state;
beforeEach(() => {
jest.spyOn(Tracking, 'event');
commit = jest.fn();
dispatch = jest.fn();
state = storeState();
});
it('commits empty state when state.groups is empty', done => {
const state = storeState();
const params = {};
fetchPrometheusMetrics({ state, commit, dispatch }, params)
const getters = {
metricsWithData: () => [],
};
fetchPrometheusMetrics({ state, commit, dispatch, getters }, params)
.then(() => {
expect(commit).toHaveBeenCalledWith(types.SET_NO_DATA_EMPTY_STATE);
expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
'dashboard_fetch',
{
label: 'custom_metrics_dashboard',
property: 'count',
value: 0,
},
);
expect(dispatch).not.toHaveBeenCalled();
expect(createFlash).not.toHaveBeenCalled();
done();
......@@ -336,19 +376,28 @@ describe('Monitoring store actions', () => {
.catch(done.fail);
});
it('dispatches fetchPrometheusMetric for each panel query', done => {
const params = {};
const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
const metric = state.dashboard.panel_groups[0].panels[0].metrics[0];
fetchPrometheusMetrics({ state, commit, dispatch }, params)
const [metric] = state.dashboard.panel_groups[0].panels[0].metrics;
const getters = {
metricsWithData: () => [metric.id],
};
fetchPrometheusMetrics({ state, commit, dispatch, getters }, params)
.then(() => {
expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
metric,
params,
});
expect(createFlash).not.toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
'dashboard_fetch',
{
label: 'custom_metrics_dashboard',
property: 'count',
value: 1,
},
);
done();
})
......@@ -357,8 +406,6 @@ describe('Monitoring store actions', () => {
});
it('dispatches fetchPrometheusMetric for each panel query, handles an error', done => {
const params = {};
const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
const metric = state.dashboard.panel_groups[0].panels[0].metrics[0];
......
import * as getters from '~/monitoring/stores/getters';
import mutations from '~/monitoring/stores/mutations';
import * as types from '~/monitoring/stores/mutation_types';
import {
metricsGroupsAPIResponse,
mockedEmptyResult,
mockedQueryResultPayload,
mockedQueryResultPayloadCoresTotal,
} from '../mock_data';
describe('Monitoring store Getters', () => {
describe('metricsWithData', () => {
let metricsWithData;
let setupState;
let state;
beforeEach(() => {
setupState = (initState = {}) => {
state = initState;
metricsWithData = getters.metricsWithData(state);
};
});
afterEach(() => {
state = null;
});
it('has method-style access', () => {
setupState();
expect(metricsWithData).toEqual(expect.any(Function));
});
it('when dashboard has no panel groups, returns empty', () => {
setupState({
dashboard: {
panel_groups: [],
},
});
expect(metricsWithData()).toEqual([]);
});
describe('when the dashboard is set', () => {
beforeEach(() => {
setupState({
dashboard: { panel_groups: [] },
});
});
it('no loaded metric returns empty', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
expect(metricsWithData()).toEqual([]);
});
it('an empty metric, returns empty', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
mutations[types.SET_QUERY_RESULT](state, mockedEmptyResult);
expect(metricsWithData()).toEqual([]);
});
it('a metric with results, it returns a metric', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayload);
expect(metricsWithData()).toEqual([mockedQueryResultPayload.metricId]);
});
it('multiple metrics with results, it return multiple metrics', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayload);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayloadCoresTotal);
expect(metricsWithData()).toEqual([
mockedQueryResultPayload.metricId,
mockedQueryResultPayloadCoresTotal.metricId,
]);
});
it('multiple metrics with results, it returns metrics filtered by group', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayload);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayloadCoresTotal);
// First group has no metrics
expect(metricsWithData(state.dashboard.panel_groups[0].key)).toEqual([]);
// Second group has metrics
expect(metricsWithData(state.dashboard.panel_groups[1].key)).toEqual([
mockedQueryResultPayload.metricId,
mockedQueryResultPayloadCoresTotal.metricId,
]);
});
});
});
});
......@@ -7,41 +7,59 @@ import {
metricsDashboardResponse,
dashboardGitResponse,
} from '../mock_data';
import { uniqMetricsId } from '~/monitoring/stores/utils';
describe('Monitoring mutations', () => {
let stateCopy;
beforeEach(() => {
stateCopy = state();
});
describe('RECEIVE_METRICS_DATA_SUCCESS', () => {
let groups;
let payload;
const getGroups = () => stateCopy.dashboard.panel_groups;
beforeEach(() => {
stateCopy.dashboard.panel_groups = [];
groups = metricsGroupsAPIResponse;
payload = metricsGroupsAPIResponse;
});
it('adds a key to the group', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups[0].key).toBe('system-metrics-kubernetes--0');
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
const groups = getGroups();
expect(groups[0].key).toBe('response-metrics-nginx-ingress-vts--0');
expect(groups[1].key).toBe('system-metrics-kubernetes--1');
});
it('normalizes values', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
const expectedLabel = 'Pod average';
const { label, query_range } = stateCopy.dashboard.panel_groups[0].panels[0].metrics[0];
const { label, query_range } = getGroups()[1].panels[0].metrics[0];
expect(label).toEqual(expectedLabel);
expect(query_range.length).toBeGreaterThan(0);
});
it('contains one group, which it has two panels and one metrics property', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups).toBeDefined();
expect(stateCopy.dashboard.panel_groups.length).toEqual(1);
expect(stateCopy.dashboard.panel_groups[0].panels.length).toEqual(2);
expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics.length).toEqual(1);
expect(stateCopy.dashboard.panel_groups[0].panels[1].metrics.length).toEqual(1);
it('contains two groups, with panels with a metric each', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
const groups = getGroups();
expect(groups).toBeDefined();
expect(groups).toHaveLength(2);
expect(groups[0].panels).toHaveLength(1);
expect(groups[0].panels[0].metrics).toHaveLength(1);
expect(groups[1].panels).toHaveLength(2);
expect(groups[1].panels[0].metrics).toHaveLength(1);
expect(groups[1].panels[1].metrics).toHaveLength(1);
});
it('assigns metrics a metric id', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics[0].metricId).toEqual(
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
const groups = getGroups();
expect(groups[0].panels[0].metrics[0].metricId).toEqual(
'1_response_metrics_nginx_ingress_throughput_status_code',
);
expect(groups[1].panels[0].metrics[0].metricId).toEqual(
'17_system_metrics_kubernetes_container_memory_average',
);
});
......@@ -52,7 +70,7 @@ describe('Monitoring mutations', () => {
stateCopy.deploymentData = [];
mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData);
expect(stateCopy.deploymentData).toBeDefined();
expect(stateCopy.deploymentData.length).toEqual(3);
expect(stateCopy.deploymentData).toHaveLength(3);
expect(typeof stateCopy.deploymentData[0]).toEqual('object');
});
});
......@@ -73,41 +91,38 @@ describe('Monitoring mutations', () => {
});
});
describe('SET_QUERY_RESULT', () => {
const metricId = 12;
const id = 'system_metrics_kubernetes_container_memory_total';
const metricId = '12_system_metrics_kubernetes_container_memory_total';
const result = [
{
values: [[0, 1], [1, 1], [1, 3]],
},
];
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
const getMetrics = () => stateCopy.dashboard.panel_groups[0].panels[0].metrics;
beforeEach(() => {
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
});
it('clears empty state', () => {
expect(stateCopy.showEmptyState).toBe(true);
mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId,
result,
});
expect(stateCopy.showEmptyState).toBe(false);
});
it('sets metricsWithData value', () => {
const uniqId = uniqMetricsId({
metric_id: metricId,
id,
});
mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId: uniqId,
result,
});
expect(stateCopy.metricsWithData).toEqual([uniqId]);
});
it('does not store empty results', () => {
it('adds results to the store', () => {
expect(getMetrics()[0].result).toBe(undefined);
mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId,
result: [],
result,
});
expect(stateCopy.metricsWithData).toEqual([]);
expect(getMetrics()[0].result).toHaveLength(result.length);
});
});
describe('SET_ALL_DASHBOARDS', () => {
......
......@@ -4,11 +4,13 @@ import { GlToast } from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue';
import EmptyState from '~/monitoring/components/empty_state.vue';
import * as types from '~/monitoring/stores/mutation_types';
import { createStore } from '~/monitoring/stores';
import axios from '~/lib/utils/axios_utils';
import {
metricsGroupsAPIResponse,
mockedEmptyResult,
mockedQueryResultPayload,
mockedQueryResultPayloadCoresTotal,
mockApiEndpoint,
......@@ -29,6 +31,7 @@ const propsData = {
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
environmentsEndpoint: '/root/hello-prometheus/environments/35',
currentEnvironmentName: 'production',
......@@ -43,15 +46,17 @@ const resetSpy = spy => {
}
};
export default propsData;
let expectedPanelCount;
function setupComponentStore(component) {
// Load 2 panel groups
component.$store.commit(
`monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`,
metricsGroupsAPIResponse,
);
// Load 2 panels to the dashboard
// Load 3 panels to the dashboard, one with an empty result
component.$store.commit(`monitoringDashboard/${types.SET_QUERY_RESULT}`, mockedEmptyResult);
component.$store.commit(
`monitoringDashboard/${types.SET_QUERY_RESULT}`,
mockedQueryResultPayload,
......@@ -61,6 +66,8 @@ function setupComponentStore(component) {
mockedQueryResultPayloadCoresTotal,
);
expectedPanelCount = 2;
component.$store.commit(
`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`,
environmentData,
......@@ -126,13 +133,9 @@ describe('Dashboard', () => {
describe('no data found', () => {
it('shows the environment selector dropdown', () => {
component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
propsData: { ...propsData, showEmptyState: true },
store,
});
createComponentWrapper();
expect(component.$el.querySelector('.js-environments-dropdown')).toBeTruthy();
expect(wrapper.find('.js-environments-dropdown').exists()).toBeTruthy();
});
});
......@@ -389,9 +392,36 @@ describe('Dashboard', () => {
});
});
describe('drag and drop function', () => {
let expectedPanelCount; // also called metrics, naming to be improved: https://gitlab.com/gitlab-org/gitlab/issues/31565
describe('when one of the metrics is missing', () => {
beforeEach(() => {
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
});
beforeEach(done => {
createComponentWrapper({ hasMetrics: true }, { attachToDocument: true });
setupComponentStore(wrapper.vm);
wrapper.vm.$nextTick(done);
});
it('shows a group empty area', () => {
const emptyGroup = wrapper.findAll({ ref: 'empty-group' });
expect(emptyGroup).toHaveLength(1);
expect(emptyGroup.is(EmptyState)).toBe(true);
});
it('group empty area displays a "noDataGroup"', () => {
expect(
wrapper
.findAll({ ref: 'empty-group' })
.at(0)
.props('selectedState'),
).toEqual('noDataGroup');
});
});
describe('drag and drop function', () => {
const findDraggables = () => wrapper.findAll(VueDraggable);
const findEnabledDraggables = () => findDraggables().filter(f => !f.attributes('disabled'));
const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel');
......@@ -399,10 +429,6 @@ describe('Dashboard', () => {
beforeEach(() => {
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
expectedPanelCount = metricsGroupsAPIResponse.reduce(
(acc, group) => group.panels.length + acc,
0,
);
});
beforeEach(done => {
......@@ -417,10 +443,6 @@ describe('Dashboard', () => {
wrapper.destroy();
});
afterEach(() => {
wrapper.destroy();
});
it('wraps vuedraggable', () => {
expect(findDraggablePanels().exists()).toBe(true);
expect(findDraggablePanels().length).toEqual(expectedPanelCount);
......@@ -459,22 +481,20 @@ describe('Dashboard', () => {
it('metrics can be swapped', done => {
const firstDraggable = findDraggables().at(0);
const mockMetrics = [...metricsGroupsAPIResponse[0].panels];
const value = () => firstDraggable.props('value');
const mockMetrics = [...metricsGroupsAPIResponse[1].panels];
expect(value().length).toBe(mockMetrics.length);
value().forEach((metric, i) => {
expect(metric.title).toBe(mockMetrics[i].title);
});
const firstTitle = mockMetrics[0].title;
const secondTitle = mockMetrics[1].title;
// swap two elements and `input` them
[mockMetrics[0], mockMetrics[1]] = [mockMetrics[1], mockMetrics[0]];
firstDraggable.vm.$emit('input', mockMetrics);
firstDraggable.vm.$nextTick(() => {
value().forEach((metric, i) => {
expect(metric.title).toBe(mockMetrics[i].title);
});
wrapper.vm.$nextTick(() => {
const { panels } = wrapper.vm.dashboard.panel_groups[1];
expect(panels[1].title).toEqual(firstTitle);
expect(panels[0].title).toEqual(secondTitle);
done();
});
});
......@@ -584,7 +604,7 @@ describe('Dashboard', () => {
setupComponentStore(component);
return Vue.nextTick().then(() => {
promPanel = component.$el.querySelector('.prometheus-panel');
[, promPanel] = component.$el.querySelectorAll('.prometheus-panel');
promGroup = promPanel.querySelector('.prometheus-graph-group');
panelToggle = promPanel.querySelector('.js-graph-group-toggle');
chart = promGroup.querySelector('.position-relative svg');
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import GraphGroup from '~/monitoring/components/graph_group.vue';
import Icon from '~/vue_shared/components/icon.vue';
const localVue = createLocalVue();
describe('Graph group component', () => {
let graphGroup;
let wrapper;
const findPrometheusGroup = () => graphGroup.find('.prometheus-graph-group');
const findPrometheusPanel = () => graphGroup.find('.prometheus-panel');
const findGroup = () => wrapper.find({ ref: 'graph-group' });
const findContent = () => wrapper.find({ ref: 'graph-group-content' });
const findCaretIcon = () => wrapper.find(Icon);
const createComponent = propsData => {
graphGroup = shallowMount(localVue.extend(GraphGroup), {
wrapper = shallowMount(localVue.extend(GraphGroup), {
propsData,
sync: false,
localVue,
......@@ -18,57 +20,100 @@ describe('Graph group component', () => {
};
afterEach(() => {
graphGroup.destroy();
wrapper.destroy();
});
describe('When groups can be collapsed', () => {
describe('When group is not collapsed', () => {
beforeEach(() => {
createComponent({
name: 'panel',
collapseGroup: true,
collapseGroup: false,
});
});
it('should show the angle-down caret icon when collapseGroup is true', () => {
expect(graphGroup.vm.caretIcon).toBe('angle-down');
it('should show the angle-down caret icon', () => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().props('name')).toBe('angle-down');
});
it('should show the angle-right caret icon when collapseGroup is false', () => {
graphGroup.vm.collapse();
it('should show the angle-right caret icon when the user collapses the group', done => {
wrapper.vm.collapse();
expect(graphGroup.vm.caretIcon).toBe('angle-right');
wrapper.vm.$nextTick(() => {
expect(findContent().isVisible()).toBe(false);
expect(findCaretIcon().props('name')).toBe('angle-right');
done();
});
});
});
describe('When groups can not be collapsed', () => {
beforeEach(() => {
createComponent({
name: 'panel',
it('should show the open the group when collapseGroup is set to true', done => {
wrapper.setProps({
collapseGroup: true,
showPanels: false,
});
wrapper.vm.$nextTick(() => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().props('name')).toBe('angle-down');
done();
});
});
it('should not contain a prometheus-panel container when showPanels is false', () => {
expect(findPrometheusPanel().exists()).toBe(false);
describe('When group is collapsed', () => {
beforeEach(() => {
createComponent({
name: 'panel',
collapseGroup: true,
});
});
it('should show the angle-down caret icon when collapseGroup is true', () => {
expect(wrapper.vm.caretIcon).toBe('angle-right');
});
it('should show the angle-right caret icon when collapseGroup is false', () => {
wrapper.vm.collapse();
expect(wrapper.vm.caretIcon).toBe('angle-down');
});
});
});
describe('When collapseGroup prop is updated', () => {
beforeEach(() => {
createComponent({ name: 'panel', collapseGroup: false });
describe('When groups can not be collapsed', () => {
beforeEach(() => {
createComponent({
name: 'panel',
showPanels: false,
collapseGroup: false,
});
});
it('should not have a container when showPanels is false', () => {
expect(findGroup().exists()).toBe(false);
expect(findContent().exists()).toBe(true);
});
});
it('previously collapsed group should respond to the prop change', done => {
expect(findPrometheusGroup().exists()).toBe(false);
describe('When group does not show a panel heading', () => {
beforeEach(() => {
createComponent({
name: 'panel',
showPanels: false,
collapseGroup: false,
});
});
graphGroup.setProps({
collapseGroup: true,
it('should collapse the panel content', () => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().exists()).toBe(false);
});
graphGroup.vm.$nextTick(() => {
expect(findPrometheusGroup().exists()).toBe(true);
done();
it('should show the panel content when clicked', done => {
wrapper.vm.collapse();
wrapper.vm.$nextTick(() => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().exists()).toBe(false);
done();
});
});
});
});
......
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