diff options
15 files changed, 410 insertions, 50 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index bfb073fdcdc..789a057caf8 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -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; }; diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js new file mode 100644 index 00000000000..252b98610b6 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_metrics.js @@ -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, + }, + }); + }); +} diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 454ff4f284e..edf9423c74c 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -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> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 6eaced0c108..c7c55880040 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -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" > diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue new file mode 100644 index 00000000000..e17f03de0fd --- /dev/null +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -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> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 26f1bf3f68d..605c95e6da5 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -1,5 +1,7 @@ import { __ } from '~/locale'; +export const sidebarAnimationDuration = 300; // milliseconds. + export const chartHeight = 300; export const graphTypes = { diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 5b3da51e9a6..245cc2eaca3 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -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')); + } }); }; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index c89daba3df7..4b1aadbcf05 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -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'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 0104dcb867d..b19520d6638 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -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; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index e54bb712695..440bdc951e0 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -12,6 +12,7 @@ export default () => ({ additionalPanelTypesEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, + showErrorBanner: true, groups: [], deploymentData: [], environments: [], diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index 2d600e3aef6..05a4cc168a8 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -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; diff --git a/spec/frontend/behaviors/markdown/render_metrics_spec.js b/spec/frontend/behaviors/markdown/render_metrics_spec.js new file mode 100644 index 00000000000..6db0eabc16b --- /dev/null +++ b/spec/frontend/behaviors/markdown/render_metrics_spec.js @@ -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(); + }); +}); diff --git a/spec/frontend/monitoring/embed/embed_spec.js b/spec/frontend/monitoring/embed/embed_spec.js new file mode 100644 index 00000000000..3b18a0f77c7 --- /dev/null +++ b/spec/frontend/monitoring/embed/embed_spec.js @@ -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); + }); + }); +}); diff --git a/spec/frontend/monitoring/embed/mock_data.js b/spec/frontend/monitoring/embed/mock_data.js new file mode 100644 index 00000000000..df4acb82e95 --- /dev/null +++ b/spec/frontend/monitoring/embed/mock_data.js @@ -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, +}; diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 634c78ec029..e4d62b044ca 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -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: () => {}, +}); |