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 renderMath from './render_math';
import renderMermaid from './render_mermaid';
import renderMetrics from './render_metrics';
import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers';
import initMRPopovers from '../../mr_popover';
@ -17,6 +18,9 @@ $.fn.renderGFM = function renderGFM() {
highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get());
initMRPopovers(this.find('.gfm-merge_request').get());
if (gon.features && gon.features.gfmEmbeddedMetrics) {
renderMetrics(this.find('.js-render-metrics').get());
}
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: {
type: String,
required: true,
required: false,
default: () => '',
},
showBorder: {
type: Boolean,
required: false,
default: () => false,
},
thresholds: {
type: Array,
@ -234,52 +240,54 @@ export default {
</script>
<template>
<div class="prometheus-graph col-12 col-lg-6">
<div class="prometheus-graph-header">
<h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
<div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
</div>
<gl-area-chart
ref="areaChart"
v-bind="$attrs"
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
:thresholds="thresholds"
:width="width"
:height="height"
@updated="onChartUpdated"
>
<template v-if="tooltip.isDeployment">
<template slot="tooltipTitle">
{{ __('Deployed') }}
</template>
<div slot="tooltipContent" class="d-flex align-items-center">
<icon name="commit" class="mr-2" />
<gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
</template>
<template v-else>
<template slot="tooltipTitle">
<div class="text-nowrap">
{{ tooltip.title }}
<div class="col-12 col-lg-6" :class="[showBorder ? 'p-2' : 'p-0']">
<div class="prometheus-graph" :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }">
<div class="prometheus-graph-header">
<h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
<div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
</div>
<gl-area-chart
ref="areaChart"
v-bind="$attrs"
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
:thresholds="thresholds"
:width="width"
:height="height"
@updated="onChartUpdated"
>
<template v-if="tooltip.isDeployment">
<template slot="tooltipTitle">
{{ __('Deployed') }}
</template>
<div slot="tooltipContent" class="d-flex align-items-center">
<icon name="commit" class="mr-2" />
<gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</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 }}
<template v-else>
<template slot="tooltipTitle">
<div class="text-nowrap">
{{ tooltip.title }}
</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>
</gl-area-chart>
</gl-area-chart>
</div>
</div>
</template>

View file

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

View file

@ -44,6 +44,10 @@ export const setFeatureFlags = (
commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled);
};
export const setShowErrorBanner = ({ commit }, enabled) => {
commit(types.SET_SHOW_ERROR_BANNER, enabled);
};
export const requestMetricsDashboard = ({ commit }) => {
commit(types.REQUEST_METRICS_DATA);
};
@ -99,7 +103,9 @@ export const fetchMetricsData = ({ state, dispatch }, params) => {
})
.catch(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 => {
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_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_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) {
state.additionalPanelTypesEnabled = enabled;
},
[types.SET_SHOW_ERROR_BANNER](state, enabled) {
state.showErrorBanner = enabled;
},
};

View file

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

View file

@ -29,6 +29,11 @@
padding: $gl-padding / 2;
}
.prometheus-graph-embed {
border: 1px solid $border-color;
border-radius: $border-radius-default;
}
.prometheus-graph-header {
display: flex;
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
testUtilsConfig.logModifiedComponents = false;
// Basic stub for MutationObserver
global.MutationObserver = () => ({
disconnect: () => {},
observe: () => {},
});