Add ability to embed metrics
See https://gitlab.com/gitlab-org/gitlab-ce/issues/30423
This commit is contained in:
parent
86e002147c
commit
97b325a4a2
15 changed files with 410 additions and 50 deletions
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
24
app/assets/javascripts/behaviors/markdown/render_metrics.js
Normal file
24
app/assets/javascripts/behaviors/markdown/render_metrics.js
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
97
app/assets/javascripts/monitoring/components/embed.vue
Normal file
97
app/assets/javascripts/monitoring/components/embed.vue
Normal 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>
|
|
@ -1,5 +1,7 @@
|
|||
import { __ } from '~/locale';
|
||||
|
||||
export const sidebarAnimationDuration = 300; // milliseconds.
|
||||
|
||||
export const chartHeight = 300;
|
||||
|
||||
export const graphTypes = {
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ export default () => ({
|
|||
additionalPanelTypesEnabled: false,
|
||||
emptyState: 'gettingStarted',
|
||||
showEmptyState: true,
|
||||
showErrorBanner: true,
|
||||
groups: [],
|
||||
deploymentData: [],
|
||||
environments: [],
|
||||
|
|
|
@ -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;
|
||||
|
|
37
spec/frontend/behaviors/markdown/render_metrics_spec.js
Normal file
37
spec/frontend/behaviors/markdown/render_metrics_spec.js
Normal 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();
|
||||
});
|
||||
});
|
78
spec/frontend/monitoring/embed/embed_spec.js
Normal file
78
spec/frontend/monitoring/embed/embed_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
87
spec/frontend/monitoring/embed/mock_data.js
Normal file
87
spec/frontend/monitoring/embed/mock_data.js
Normal 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,
|
||||
};
|
|
@ -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: () => {},
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue