Commit e68e12e6 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '338282-metrics-mr-report-widget' into 'master'

Add metrics widget extension

See merge request gitlab-org/gitlab!80345
parents d8b850ff 27b9004a
import { uniqueId } from 'lodash';
import { __, n__, s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
export default {
name: 'WidgetMetrics',
props: ['metricsReportsPath'],
enablePolling: true,
i18n: {
loading: s__('Reports|Metrics reports are loading'),
error: s__('Reports|Metrics reports failed to load results'),
},
expandEvent: 'i_testing_metrics_report_widget_total',
computed: {
numberOfChanges() {
const changedMetrics =
this.collapsedData?.existing_metrics?.filter((metric) => metric?.previous_value) || [];
const newMetrics = this.collapsedData?.new_metrics || [];
const removedMetrics = this.collapsedData?.removed_metrics || [];
return changedMetrics.length + newMetrics.length + removedMetrics.length;
},
hasChanges() {
return this.numberOfChanges() > 0;
},
statusIcon() {
return this.hasChanges() ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
},
},
methods: {
summary() {
const hasChanges = this.hasChanges();
const numberOfChanges = this.numberOfChanges();
const changesSummary = sprintf(
s__('Reports|Metrics reports: %{strong_start}%{numberOfChanges}%{strong_end} %{changes}'),
{
numberOfChanges,
changes: n__('change', 'changes', numberOfChanges),
},
);
const noChangesSummary = s__('Reports|Metrics report scanning detected no new changes');
return hasChanges ? changesSummary : noChangesSummary;
},
fetchCollapsedData() {
return axios.get(this.metricsReportsPath);
},
fetchFullData() {
return Promise.resolve(this.prepareReports());
},
formatMetricDelta(metric) {
// calculate metric delta for sorting if numeric
const delta = Math.abs(parseFloat(metric.value) - parseFloat(metric.previous_value));
// give non-numeric metrics high delta so they appear first
return Number.isNaN(delta) ? Infinity : delta;
},
prepareReports() {
const {
new_metrics: newMetrics = [],
existing_metrics: existingMetrics = [],
removed_metrics: removedMetrics = [],
} = this.collapsedData;
return [
...newMetrics.map((metric, index) => {
return {
header: index === 0 && __('New'),
id: uniqueId('new-metric-'),
text: `${metric.name}: ${metric.value}`,
icon: { name: EXTENSION_ICONS.neutral },
};
}),
...removedMetrics.map((metric, index) => {
return {
header: index === 0 && __('Removed'),
id: uniqueId('resolved-metric-'),
text: `${metric.name}: ${metric.value}`,
icon: { name: EXTENSION_ICONS.neutral },
};
}),
...existingMetrics
.filter((metric) => metric?.previous_value)
.map((metric) => {
return {
id: uniqueId('changed-metric-'),
text: `${metric.name}: ${metric.value} (${metric.previous_value})`,
icon: { name: EXTENSION_ICONS.neutral },
delta: this.formatMetricDelta(metric),
};
})
.sort((a, b) => b.delta - a.delta)
.map((metric, index) => {
return {
header: index === 0 && __('Changed'),
...metric,
};
}),
...existingMetrics
.filter((metric) => !metric?.previous_value)
.map((metric, index) => {
return {
header: index === 0 && __('No changes'),
id: uniqueId('unchanged-metric-'),
text: `${metric.name}: ${metric.value}`,
icon: { name: EXTENSION_ICONS.neutral },
};
}),
];
},
},
};
...@@ -12,6 +12,7 @@ import MrWidgetGeoSecondaryNode from './components/states/mr_widget_secondary_ge ...@@ -12,6 +12,7 @@ import MrWidgetGeoSecondaryNode from './components/states/mr_widget_secondary_ge
import loadPerformanceExtension from './extensions/load_performance'; import loadPerformanceExtension from './extensions/load_performance';
import browserPerformanceExtension from './extensions/browser_performance'; import browserPerformanceExtension from './extensions/browser_performance';
import statusChecksExtension from './extensions/status_checks'; import statusChecksExtension from './extensions/status_checks';
import metricsExtension from './extensions/metrics';
export default { export default {
components: { components: {
...@@ -115,6 +116,9 @@ export default { ...@@ -115,6 +116,9 @@ export default {
shouldRenderStatusReport() { shouldRenderStatusReport() {
return this.mr?.apiStatusChecksPath && !this.mr?.isNothingToMergeState; return this.mr?.apiStatusChecksPath && !this.mr?.isNothingToMergeState;
}, },
shouldRenderMetricsReport() {
return Boolean(this.mr?.metricsReportsPath);
},
browserPerformanceText() { browserPerformanceText() {
const { improved, degraded, same } = this.mr.browserPerformanceMetrics; const { improved, degraded, same } = this.mr.browserPerformanceMetrics;
...@@ -202,6 +206,11 @@ export default { ...@@ -202,6 +206,11 @@ export default {
this.registerStatusCheck(); this.registerStatusCheck();
} }
}, },
shouldRenderMetricsReport(newVal) {
if (newVal) {
this.registerMetrics();
}
},
}, },
methods: { methods: {
registerLoadPerformance() { registerLoadPerformance() {
...@@ -219,6 +228,11 @@ export default { ...@@ -219,6 +228,11 @@ export default {
registerExtension(statusChecksExtension); registerExtension(statusChecksExtension);
} }
}, },
registerMetrics() {
if (this.shouldShowExtension) {
registerExtension(metricsExtension);
}
},
getServiceEndpoints(store) { getServiceEndpoints(store) {
const base = CEWidgetOptions.methods.getServiceEndpoints(store); const base = CEWidgetOptions.methods.getServiceEndpoints(store);
......
import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import metricsExtension from 'ee/vue_merge_request_widget/extensions/metrics';
import httpStatusCodes from '~/lib/utils/http_status';
import { metricsResponse, changedMetric, unchangedMetric } from './mock_data';
describe('Metrics extension', () => {
let wrapper;
let mock;
registerExtension(metricsExtension);
const endpoint = '/root/repo/-/merge_requests/4/metrics_reports.json';
const mockApi = (statusCode, data) => {
mock.onGet(endpoint).reply(statusCode, data);
};
const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
const createComponent = () => {
wrapper = mountExtended(extensionsContainer, {
propsData: {
mr: {
metricsReportsPath: endpoint,
},
},
});
};
const createExpandedWidgetWithData = async (data = metricsResponse) => {
mockApi(httpStatusCodes.OK, data);
createComponent();
await waitForPromises();
findToggleCollapsedButton().trigger('click');
await waitForPromises();
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('summary', () => {
it('displays loading text', () => {
mockApi(httpStatusCodes.OK);
createComponent();
expect(wrapper.text()).toBe('Metrics reports are loading');
});
it('displays failed loading text', async () => {
mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('Metrics reports failed to load results');
});
it('displays detected changes', async () => {
mockApi(httpStatusCodes.OK, { existing_metrics: [changedMetric, changedMetric] });
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('Metrics reports: 2 changes');
});
it('displays no detected changes', async () => {
mockApi(httpStatusCodes.OK, { existing_metrics: [unchangedMetric] });
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('Metrics report scanning detected no new changes');
});
});
describe('expanded data', () => {
describe('new and removed metrics', () => {
beforeEach(async () => {
await createExpandedWidgetWithData();
});
it.each`
index | ordinal | type | expectedText
${0} | ${'first'} | ${'new'} | ${'New gem_size_mb{name=pg}: 3.0'}
${1} | ${'second'} | ${'new'} | ${'memory_static_objects_retained_items: 258835'}
${2} | ${'first'} | ${'removed'} | ${'Removed gem_size_mb{name=charlock_holmes}: 2.7'}
${3} | ${'second'} | ${'removed'} | ${'gem_size_mb{name=omniauth-auth0}: 0.5'}
`('formats $ordinal $type metric correctly', ({ index, expectedText }) => {
expect(trimText(findAllExtensionListItems().at(index).text())).toBe(expectedText);
});
});
describe('changed and unchanged metrics', () => {
beforeEach(async () => {
await createExpandedWidgetWithData({
existing_metrics: metricsResponse.existing_metrics,
});
});
it.each`
index | ordinal | type | expectedText
${0} | ${'first'} | ${'changed'} | ${'Changed memory_static_objects_allocated_items: 1 (1552382)'}
${1} | ${'second'} | ${'changed'} | ${'memory_static_objects_retained_mb: 30.6 (30.5)'}
${2} | ${'first'} | ${'unchanged'} | ${'No changes gem_total_size_mb: 194.8'}
${3} | ${'second'} | ${'unchanged'} | ${'memory_static_objects_allocated_mb: 163.7'}
`('formats $ordinal $type metric correctly', ({ index, expectedText }) => {
expect(trimText(findAllExtensionListItems().at(index).text())).toBe(expectedText);
});
});
});
describe('changed metrics sorting', () => {
it('sorts changed metrics by delta', async () => {
await createExpandedWidgetWithData({
existing_metrics: [
{ name: 'small_change', value: '1', previous_value: '0' },
{ name: 'medium_change', value: '-10', previous_value: '0' },
{ name: 'large_change', value: '100.1', previous_value: '0' },
],
});
expect(findAllExtensionListItems().at(0).text()).toContain('large_change');
expect(findAllExtensionListItems().at(1).text()).toContain('medium_change');
expect(findAllExtensionListItems().at(2).text()).toContain('small_change');
});
it('sorts non-numeric metrics before numeric metrics', async () => {
await createExpandedWidgetWithData({
existing_metrics: [
{ name: 'medium_change', value: '-10', previous_value: '0' },
{ name: 'large_change', value: '100.1', previous_value: '0' },
{
name: 'non-numeric_change',
value: 'group::pipeline insights',
previous_value: 'group::testing',
},
],
});
expect(findAllExtensionListItems().at(0).text()).toContain('non-numeric_change');
expect(findAllExtensionListItems().at(1).text()).toContain('large_change');
expect(findAllExtensionListItems().at(2).text()).toContain('medium_change');
});
});
});
export const metricsResponse = {
new_metrics: [
{ name: 'gem_size_mb{name=pg}', value: '3.0', previous_value: null },
{ name: 'memory_static_objects_retained_items', value: '258835', previous_value: null },
],
existing_metrics: [
{ name: 'gem_total_size_mb', value: '194.8' },
{ name: 'memory_static_objects_allocated_mb', value: '163.7' },
{ name: 'memory_static_objects_retained_mb', value: '30.6', previous_value: '30.5' },
{ name: 'memory_static_objects_allocated_items', value: '1', previous_value: '1552382' },
],
removed_metrics: [
{ name: 'gem_size_mb{name=charlock_holmes}', value: '2.7', previous_value: null },
{ name: 'gem_size_mb{name=omniauth-auth0}', value: '0.5', previous_value: null },
],
};
export const changedMetric = {
name: 'name',
value: 'value',
previous_value: 'prev',
};
export const unchangedMetric = {
name: 'name',
value: 'value',
};
...@@ -6840,6 +6840,9 @@ msgstr "" ...@@ -6840,6 +6840,9 @@ msgstr ""
msgid "ChangeTypeAction|Your changes will be committed to %{branchName} because a merge request is open." msgid "ChangeTypeAction|Your changes will be committed to %{branchName} because a merge request is open."
msgstr "" msgstr ""
msgid "Changed"
msgstr ""
msgid "Changed assignee(s)." msgid "Changed assignee(s)."
msgstr "" msgstr ""
...@@ -30901,6 +30904,9 @@ msgstr "" ...@@ -30901,6 +30904,9 @@ msgstr ""
msgid "Reports|Identifier" msgid "Reports|Identifier"
msgstr "" msgstr ""
msgid "Reports|Metrics report scanning detected no new changes"
msgstr ""
msgid "Reports|Metrics reports are loading" msgid "Reports|Metrics reports are loading"
msgstr "" msgstr ""
...@@ -30913,6 +30919,12 @@ msgstr "" ...@@ -30913,6 +30919,12 @@ msgstr ""
msgid "Reports|Metrics reports failed loading results" msgid "Reports|Metrics reports failed loading results"
msgstr "" msgstr ""
msgid "Reports|Metrics reports failed to load results"
msgstr ""
msgid "Reports|Metrics reports: %{strong_start}%{numberOfChanges}%{strong_end} %{changes}"
msgstr ""
msgid "Reports|Scanner" msgid "Reports|Scanner"
msgstr "" msgstr ""
...@@ -43009,6 +43021,11 @@ msgstr "" ...@@ -43009,6 +43021,11 @@ msgstr ""
msgid "cannot merge" msgid "cannot merge"
msgstr "" msgstr ""
msgid "change"
msgid_plural "changes"
msgstr[0] ""
msgstr[1] ""
msgid "ciReport|%{danger_start}%{degradedNum} degraded%{danger_end}, %{same_start}%{sameNum} same%{same_end}, and %{success_start}%{improvedNum} improved%{success_end}" msgid "ciReport|%{danger_start}%{degradedNum} degraded%{danger_end}, %{same_start}%{sameNum} same%{same_end}, and %{success_start}%{improvedNum} improved%{success_end}"
msgstr "" msgstr ""
......
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