Add ability to embed metrics

See https://gitlab.com/gitlab-org/gitlab-ce/issues/30423
This commit is contained in:
Tristan Read 2019-07-22 12:01:42 +00:00 committed by Fatih Acet
parent 86e002147c
commit 97b325a4a2
15 changed files with 410 additions and 50 deletions

View file

@ -2,6 +2,7 @@ import $ from 'jquery';
import syntaxHighlight from '~/syntax_highlight'; import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math'; import renderMath from './render_math';
import renderMermaid from './render_mermaid'; import renderMermaid from './render_mermaid';
import renderMetrics from './render_metrics';
import highlightCurrentUser from './highlight_current_user'; import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers'; import initUserPopovers from '../../user_popovers';
import initMRPopovers from '../../mr_popover'; import initMRPopovers from '../../mr_popover';
@ -17,6 +18,9 @@ $.fn.renderGFM = function renderGFM() {
highlightCurrentUser(this.find('.gfm-project_member').get()); highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get()); initUserPopovers(this.find('.gfm-project_member').get());
initMRPopovers(this.find('.gfm-merge_request').get()); initMRPopovers(this.find('.gfm-merge_request').get());
if (gon.features && gon.features.gfmEmbeddedMetrics) {
renderMetrics(this.find('.js-render-metrics').get());
}
return this; return this;
}; };

View file

@ -0,0 +1,24 @@
import Vue from 'vue';
import Metrics from '~/monitoring/components/embed.vue';
import { createStore } from '~/monitoring/stores';
// TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-ce/issues/64369.
export default function renderMetrics(elements) {
if (!elements.length) {
return;
}
elements.forEach(element => {
const { dashboardUrl } = element.dataset;
const MetricsComponent = Vue.extend(Metrics);
// eslint-disable-next-line no-new
new MetricsComponent({
el: element,
store: createStore(),
propsData: {
dashboardUrl,
},
});
});
}

View file

@ -37,7 +37,13 @@ export default {
}, },
projectPath: { projectPath: {
type: String, type: String,
required: true, required: false,
default: () => '',
},
showBorder: {
type: Boolean,
required: false,
default: () => false,
}, },
thresholds: { thresholds: {
type: Array, type: Array,
@ -234,52 +240,54 @@ export default {
</script> </script>
<template> <template>
<div class="prometheus-graph col-12 col-lg-6"> <div class="col-12 col-lg-6" :class="[showBorder ? 'p-2' : 'p-0']">
<div class="prometheus-graph-header"> <div class="prometheus-graph" :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }">
<h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> <div class="prometheus-graph-header">
<div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
</div> <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
<gl-area-chart </div>
ref="areaChart" <gl-area-chart
v-bind="$attrs" ref="areaChart"
:data="chartData" v-bind="$attrs"
:option="chartOptions" :data="chartData"
:format-tooltip-text="formatTooltipText" :option="chartOptions"
:thresholds="thresholds" :format-tooltip-text="formatTooltipText"
:width="width" :thresholds="thresholds"
:height="height" :width="width"
@updated="onChartUpdated" :height="height"
> @updated="onChartUpdated"
<template v-if="tooltip.isDeployment"> >
<template slot="tooltipTitle"> <template v-if="tooltip.isDeployment">
{{ __('Deployed') }} <template slot="tooltipTitle">
</template> {{ __('Deployed') }}
<div slot="tooltipContent" class="d-flex align-items-center"> </template>
<icon name="commit" class="mr-2" /> <div slot="tooltipContent" class="d-flex align-items-center">
<gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> <icon name="commit" class="mr-2" />
</div> <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</template>
<template v-else>
<template slot="tooltipTitle">
<div class="text-nowrap">
{{ tooltip.title }}
</div> </div>
</template> </template>
<template slot="tooltipContent"> <template v-else>
<div <template slot="tooltipTitle">
v-for="(content, key) in tooltip.content" <div class="text-nowrap">
:key="key" {{ tooltip.title }}
class="d-flex justify-content-between"
>
<gl-chart-series-label :color="isMultiSeries ? content.color : ''">
{{ content.name }}
</gl-chart-series-label>
<div class="prepend-left-32">
{{ content.value }}
</div> </div>
</div> </template>
<template slot="tooltipContent">
<div
v-for="(content, key) in tooltip.content"
:key="key"
class="d-flex justify-content-between"
>
<gl-chart-series-label :color="isMultiSeries ? content.color : ''">
{{ content.name }}
</gl-chart-series-label>
<div class="prepend-left-32">
{{ content.value }}
</div>
</div>
</template>
</template> </template>
</template> </gl-area-chart>
</gl-area-chart> </div>
</div> </div>
</template> </template>

View file

@ -11,10 +11,9 @@ import MonitorSingleStatChart from './charts/single_stat.vue';
import PanelType from './panel_type.vue'; import PanelType from './panel_type.vue';
import GraphGroup from './graph_group.vue'; import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
import { timeWindows, timeWindowsKeyNames } from '../constants'; import { sidebarAnimationDuration, timeWindows, timeWindowsKeyNames } from '../constants';
import { getTimeDiff } from '../utils'; import { getTimeDiff } from '../utils';
const sidebarAnimationDuration = 150;
let sidebarMutationObserver; let sidebarMutationObserver;
export default { export default {
@ -370,8 +369,8 @@ export default {
</div> </div>
<div v-if="!showEmptyState"> <div v-if="!showEmptyState">
<graph-group <graph-group
v-for="(groupData, index) in groupsWithData" v-for="groupData in groupsWithData"
:key="index" :key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group" :name="groupData.group"
:show-panels="showPanels" :show-panels="showPanels"
> >

View file

@ -0,0 +1,97 @@
<script>
import { mapActions, mapState } from 'vuex';
import GraphGroup from './graph_group.vue';
import MonitorAreaChart from './charts/area.vue';
import { sidebarAnimationDuration, timeWindowsKeyNames, timeWindows } from '../constants';
import { getTimeDiff } from '../utils';
let sidebarMutationObserver;
export default {
components: {
GraphGroup,
MonitorAreaChart,
},
props: {
dashboardUrl: {
type: String,
required: true,
},
},
data() {
return {
params: {
...getTimeDiff(timeWindows[timeWindowsKeyNames.eightHours]),
},
elWidth: 0,
};
},
computed: {
...mapState('monitoringDashboard', ['groups', 'metricsWithData']),
groupData() {
const groupsWithData = this.groups.filter(group => this.chartsWithData(group.metrics).length);
if (groupsWithData.length) {
return groupsWithData[0];
}
return null;
},
},
mounted() {
this.setInitialState();
this.fetchMetricsData(this.params);
sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
attributes: true,
childList: false,
subtree: false,
});
},
beforeDestroy() {
if (sidebarMutationObserver) {
sidebarMutationObserver.disconnect();
}
},
methods: {
...mapActions('monitoringDashboard', [
'fetchMetricsData',
'setEndpoints',
'setFeatureFlags',
'setShowErrorBanner',
]),
chartsWithData(charts) {
return charts.filter(chart =>
chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
);
},
onSidebarMutation() {
setTimeout(() => {
this.elWidth = this.$el.clientWidth;
}, sidebarAnimationDuration);
},
setInitialState() {
this.setFeatureFlags({
prometheusEndpointEnabled: true,
});
this.setEndpoints({
dashboardEndpoint: this.dashboardUrl,
});
this.setShowErrorBanner(false);
},
},
};
</script>
<template>
<div class="metrics-embed">
<div v-if="groupData" class="row w-100 m-n2 pb-4">
<monitor-area-chart
v-for="graphData in chartsWithData(groupData.metrics)"
:key="graphData.title"
:graph-data="graphData"
:container-width="elWidth"
group-id="monitor-area-chart"
:project-path="null"
:show-border="true"
/>
</div>
</div>
</template>

View file

@ -1,5 +1,7 @@
import { __ } from '~/locale'; import { __ } from '~/locale';
export const sidebarAnimationDuration = 300; // milliseconds.
export const chartHeight = 300; export const chartHeight = 300;
export const graphTypes = { export const graphTypes = {

View file

@ -44,6 +44,10 @@ export const setFeatureFlags = (
commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled); commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled);
}; };
export const setShowErrorBanner = ({ commit }, enabled) => {
commit(types.SET_SHOW_ERROR_BANNER, enabled);
};
export const requestMetricsDashboard = ({ commit }) => { export const requestMetricsDashboard = ({ commit }) => {
commit(types.REQUEST_METRICS_DATA); commit(types.REQUEST_METRICS_DATA);
}; };
@ -99,7 +103,9 @@ export const fetchMetricsData = ({ state, dispatch }, params) => {
}) })
.catch(error => { .catch(error => {
dispatch('receiveMetricsDataFailure', error); dispatch('receiveMetricsDataFailure', error);
createFlash(s__('Metrics|There was an error while retrieving metrics')); if (state.setShowErrorBanner) {
createFlash(s__('Metrics|There was an error while retrieving metrics'));
}
}); });
}; };
@ -119,7 +125,9 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
}) })
.catch(error => { .catch(error => {
dispatch('receiveMetricsDashboardFailure', error); dispatch('receiveMetricsDashboardFailure', error);
createFlash(s__('Metrics|There was an error while retrieving metrics')); if (state.setShowErrorBanner) {
createFlash(s__('Metrics|There was an error while retrieving metrics'));
}
}); });
}; };

View file

@ -16,3 +16,4 @@ export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';

View file

@ -96,4 +96,7 @@ export default {
[types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) { [types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) {
state.additionalPanelTypesEnabled = enabled; state.additionalPanelTypesEnabled = enabled;
}, },
[types.SET_SHOW_ERROR_BANNER](state, enabled) {
state.showErrorBanner = enabled;
},
}; };

View file

@ -12,6 +12,7 @@ export default () => ({
additionalPanelTypesEnabled: false, additionalPanelTypesEnabled: false,
emptyState: 'gettingStarted', emptyState: 'gettingStarted',
showEmptyState: true, showEmptyState: true,
showErrorBanner: true,
groups: [], groups: [],
deploymentData: [], deploymentData: [],
environments: [], environments: [],

View file

@ -29,6 +29,11 @@
padding: $gl-padding / 2; padding: $gl-padding / 2;
} }
.prometheus-graph-embed {
border: 1px solid $border-color;
border-radius: $border-radius-default;
}
.prometheus-graph-header { .prometheus-graph-header {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -0,0 +1,37 @@
import Vue from 'vue';
import renderMetrics from '~/behaviors/markdown/render_metrics';
import { TEST_HOST } from 'helpers/test_constants';
const originalExtend = Vue.extend;
describe('Render metrics for Gitlab Flavoured Markdown', () => {
const container = {
Metrics() {},
};
let spyExtend;
beforeEach(() => {
Vue.extend = () => container.Metrics;
spyExtend = jest.spyOn(Vue, 'extend');
});
afterEach(() => {
Vue.extend = originalExtend;
});
it('does nothing when no elements are found', () => {
renderMetrics([]);
expect(spyExtend).not.toHaveBeenCalled();
});
it('renders a vue component when elements are found', () => {
const element = document.createElement('div');
element.setAttribute('data-dashboard-url', TEST_HOST);
renderMetrics([element]);
expect(spyExtend).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,78 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import Embed from '~/monitoring/components/embed.vue';
import MonitorAreaChart from '~/monitoring/components/charts/area.vue';
import { TEST_HOST } from 'helpers/test_constants';
import { groups, initialState, metricsData, metricsWithData } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Embed', () => {
let wrapper;
let store;
let actions;
function mountComponent() {
wrapper = shallowMount(Embed, {
localVue,
store,
propsData: {
dashboardUrl: TEST_HOST,
},
});
}
beforeEach(() => {
actions = {
setFeatureFlags: () => {},
setShowErrorBanner: () => {},
setEndpoints: () => {},
fetchMetricsData: () => {},
};
store = new Vuex.Store({
modules: {
monitoringDashboard: {
namespaced: true,
actions,
state: initialState,
},
},
});
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
});
describe('no metrics are available yet', () => {
beforeEach(() => {
mountComponent();
});
it('shows an empty state when no metrics are present', () => {
expect(wrapper.find('.metrics-embed').exists()).toBe(true);
expect(wrapper.find(MonitorAreaChart).exists()).toBe(false);
});
});
describe('metrics are available', () => {
beforeEach(() => {
store.state.monitoringDashboard.groups = groups;
store.state.monitoringDashboard.groups[0].metrics = metricsData;
store.state.monitoringDashboard.metricsWithData = metricsWithData;
mountComponent();
});
it('shows a chart when metrics are present', () => {
wrapper.setProps({});
expect(wrapper.find('.metrics-embed').exists()).toBe(true);
expect(wrapper.find(MonitorAreaChart).exists()).toBe(true);
expect(wrapper.findAll(MonitorAreaChart).length).toBe(2);
});
});
});

View file

@ -0,0 +1,87 @@
export const metricsWithData = [15, 16];
export const groups = [
{
panels: [
{
title: 'Memory Usage (Total)',
type: 'area-chart',
y_label: 'Total Memory Used',
weight: 4,
metrics: [
{
id: 'system_metrics_kubernetes_container_memory_total',
metric_id: 15,
},
],
},
{
title: 'Core Usage (Total)',
type: 'area-chart',
y_label: 'Total Cores',
weight: 3,
metrics: [
{
id: 'system_metrics_kubernetes_container_cores_total',
metric_id: 16,
},
],
},
],
},
];
export const metrics = [
{
id: 'system_metrics_kubernetes_container_memory_total',
metric_id: 15,
},
{
id: 'system_metrics_kubernetes_container_cores_total',
metric_id: 16,
},
];
const queries = [
{
result: [
{
values: [
['Mon', 1220],
['Tue', 932],
['Wed', 901],
['Thu', 934],
['Fri', 1290],
['Sat', 1330],
['Sun', 1320],
],
},
],
},
];
export const metricsData = [
{
queries,
metrics: [
{
metric_id: 15,
},
],
},
{
queries,
metrics: [
{
metric_id: 16,
},
],
},
];
export const initialState = {
monitoringDashboard: {},
groups: [],
metricsWithData: [],
useDashboardEndpoint: true,
};

View file

@ -69,3 +69,9 @@ Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => {
// Tech debt issue TBD // Tech debt issue TBD
testUtilsConfig.logModifiedComponents = false; testUtilsConfig.logModifiedComponents = false;
// Basic stub for MutationObserver
global.MutationObserver = () => ({
disconnect: () => {},
observe: () => {},
});