Commit 8a64864b authored by Payton Burdette's avatar Payton Burdette Committed by Phil Hughes

Refactor load performance widget to use extensions

parent e56d9a8f
......@@ -160,7 +160,12 @@ export default {
wclass="report-block-list"
class="report-block-container"
>
<li v-for="data in fullData" :key="data.id" class="gl-display-flex gl-align-items-center">
<li
v-for="data in fullData"
:key="data.id"
class="gl-display-flex gl-align-items-center"
data-testid="extension-list-item"
>
<status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" />
<div class="gl-mt-2 gl-mb-2 gl-flex-wrap gl-align-self-center gl-display-flex">
<div v-safe-html="data.text" class="gl-mr-4"></div>
......
---
name: refactor_mr_widgets_extensions
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70993
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/341759
milestone: '14.4'
type: development
group: group::code review
default_enabled: false
---
name: refactor_mr_widgets_extensions_user
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70993
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/341759
milestone: '14.4'
type: development
group: group::code review
default_enabled: false
import { s__, sprintf, n__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { formattedChangeInPercent } from '~/lib/utils/number_utils';
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
export default {
name: 'WidgetLoadPerformance',
props: ['loadPerformance'],
computed: {
summary() {
const { improved, degraded, same } = this.collapsedData;
const changesFound = improved.length + degraded.length + same.length;
const text = sprintf(
n__(
'ciReport|Load performance test metrics detected %{strongStart}%{changesFound}%{strongEnd} change',
'ciReport|Load performance test metrics detected %{strongStart}%{changesFound}%{strongEnd} changes',
changesFound,
),
{
changesFound,
strongStart: `<strong>`,
strongEnd: `</strong>`,
},
false,
);
const reportNumbers = [];
if (degraded.length > 0) {
reportNumbers.push(
`<strong class="gl-text-red-500">${sprintf(s__('ciReport|%{degradedNum} degraded'), {
degradedNum: degraded.length,
})}</strong>`,
);
}
if (same.length > 0) {
reportNumbers.push(
`<strong class="gl-text-gray-700">${sprintf(s__('ciReport|%{sameNum} same'), {
sameNum: same.length,
})}</strong>`,
);
}
if (improved.length > 0) {
reportNumbers.push(
`<strong class="gl-text-green-500">${sprintf(s__('ciReport|%{improvedNum} improved'), {
improvedNum: improved.length,
})}</strong>`,
);
}
return `${text}
<br>
${reportNumbers.join(', ')}
`;
},
statusIcon() {
if (this.collapsedData.degraded.length || this.collapsedData.same.length) {
return EXTENSION_ICONS.warning;
}
return EXTENSION_ICONS.success;
},
},
methods: {
fetchCollapsedData() {
return Promise.all([
this.fetchReport(this.loadPerformance?.head_path),
this.fetchReport(this.loadPerformance?.base_path),
]).then((values) => {
return this.compareLoadPerformanceMetrics(values[0], values[1]);
});
},
fetchFullData() {
const { improved, degraded, same } = this.collapsedData;
return Promise.resolve([...improved, ...degraded, ...same]);
},
compareLoadPerformanceMetrics(headMetrics, baseMetrics) {
const headMetricsIndexed = this.normalizeLoadPerformanceMetrics(headMetrics);
const baseMetricsIndexed = this.normalizeLoadPerformanceMetrics(baseMetrics);
const improved = [];
const degraded = [];
const same = [];
Object.keys(headMetricsIndexed).forEach((metric) => {
const headMetricData = headMetricsIndexed[metric];
if (metric in baseMetricsIndexed) {
const baseMetricData = baseMetricsIndexed[metric];
const metricData = {
name: metric,
score: headMetricData,
delta: parseFloat((parseFloat(headMetricData) - parseFloat(baseMetricData)).toFixed(2)),
};
if (metricData.delta !== 0.0) {
const isImproved = [s__('ciReport|RPS'), s__('ciReport|Checks')].includes(metric)
? metricData.delta > 0
: metricData.delta < 0;
if (isImproved) {
improved.push(
this.prepareMetricData(metricData, {
name: EXTENSION_ICONS.success,
}),
);
} else {
degraded.push(
this.prepareMetricData(metricData, {
name: EXTENSION_ICONS.failed,
}),
);
}
} else {
same.push(
this.prepareMetricData(metricData, {
name: EXTENSION_ICONS.neutral,
}),
);
}
}
});
return { improved, degraded, same };
},
// normalize load performance metrics for comsumption
normalizeLoadPerformanceMetrics(loadPerformanceData) {
if (!('metrics' in loadPerformanceData)) return {};
const { metrics } = loadPerformanceData;
const indexedMetrics = {};
Object.keys(loadPerformanceData.metrics).forEach((metric) => {
switch (metric) {
case 'http_reqs':
indexedMetrics[s__('ciReport|RPS')] = metrics.http_reqs.rate;
break;
case 'http_req_waiting':
indexedMetrics[s__('ciReport|TTFB P90')] = metrics.http_req_waiting['p(90)'];
indexedMetrics[s__('ciReport|TTFB P95')] = metrics.http_req_waiting['p(95)'];
break;
case 'checks':
indexedMetrics[s__('ciReport|Checks')] = `${(
(metrics.checks.passes / (metrics.checks.passes + metrics.checks.fails)) *
100.0
).toFixed(2)}%`;
break;
default:
break;
}
});
return indexedMetrics;
},
prepareMetricData(metricData, icon) {
const preparedMetricData = metricData;
const prefix = metricData.score ? `${metricData.name}:` : metricData.name;
const score = metricData.score
? `<strong>${this.formatScore(metricData.score)}</strong>`
: '';
const delta = metricData.delta ? `(${this.formatScore(metricData.delta)})` : '';
let deltaPercent = '';
if (metricData.delta && metricData.score) {
const oldScore = parseFloat(metricData.score) - metricData.delta;
deltaPercent = `(${formattedChangeInPercent(oldScore, metricData.score)})`;
}
const text = `${prefix} ${score} ${delta} ${deltaPercent}`;
preparedMetricData.icon = icon;
preparedMetricData.text = text;
return preparedMetricData;
},
formatScore(value) {
if (Number(value) && !Number.isInteger(value)) {
return (Math.floor(parseFloat(value) * 100) / 100).toFixed(2);
}
return value;
},
fetchReport(endpoint) {
return axios.get(endpoint).then((res) => res.data);
},
},
};
......@@ -2,12 +2,14 @@
import { GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
import MrWidgetLicenses from 'ee/vue_shared/license_compliance/mr_widget_license_report.vue';
import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import { s__, __, sprintf } from '~/locale';
import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import MrWidgetEnableFeaturePrompt from './components/states/mr_widget_enable_feature_prompt.vue';
import MrWidgetJiraAssociationMissing from './components/states/mr_widget_jira_association_missing.vue';
import MrWidgetPolicyViolation from './components/states/mr_widget_policy_violation.vue';
import MrWidgetGeoSecondaryNode from './components/states/mr_widget_secondary_geo_node.vue';
import loadPerformanceExtension from './extensions/load_performance';
export default {
components: {
......@@ -184,11 +186,21 @@ export default {
},
hasLoadPerformancePaths(newVal) {
if (newVal) {
this.registerLoadPerformance();
this.fetchLoadPerformance();
}
},
},
methods: {
registerLoadPerformance() {
const shouldShowExtension =
window.gon.features.refactorMrWidgetsExtensions ||
window.gon.features.refactorMrWidgetsExtensionsUser;
if (shouldShowExtension) {
registerExtension(loadPerformanceExtension);
}
},
getServiceEndpoints(store) {
const base = CEWidgetOptions.methods.getServiceEndpoints(store);
......
......@@ -12,6 +12,8 @@ module EE
experiment(:security_reports_mr_widget_prompt, namespace: @project.namespace).publish
push_frontend_feature_flag(:anonymous_visual_review_feedback)
push_frontend_feature_flag(:missing_mr_security_scan_types, @project)
push_frontend_feature_flag(:refactor_mr_widgets_extensions, @project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_mr_widgets_extensions_user, current_user, default_enabled: :yaml)
end
before_action :authorize_read_pipeline!, only: [:container_scanning_reports, :dependency_scanning_reports,
......
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
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 loadPerformanceExtension from 'ee/vue_merge_request_widget/extensions/load_performance';
import waitForPromises from 'helpers/wait_for_promises';
import { baseLoadPerformance, headLoadPerformance } from '../../mock_data';
describe('Load performance extension', () => {
let wrapper;
let mock;
const DEFAULT_LOAD_PERFORMANCE = {
head_path: 'head.json',
base_path: 'base.json',
};
const createComponent = () => {
wrapper = mount(extensionsContainer, {
propsData: {
mr: {
loadPerformance: {
...DEFAULT_LOAD_PERFORMANCE,
},
},
},
});
};
beforeEach(() => {
createComponent();
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
});
describe('summary', () => {
it('should render info about all issues', async () => {
mock.onGet(DEFAULT_LOAD_PERFORMANCE.head_path).reply(200, headLoadPerformance);
mock.onGet(DEFAULT_LOAD_PERFORMANCE.base_path).reply(200, baseLoadPerformance);
registerExtension(loadPerformanceExtension);
await waitForPromises();
expect(wrapper.text()).toContain('Load performance test metrics detected 4 changes');
expect(wrapper.text()).toContain('1 degraded, 1 same, 2 improved');
});
it('should render info about fixed issues', async () => {
const head = {
metrics: {
checks: {
fails: 0,
passes: 100,
value: 0,
},
},
};
const base = {
metrics: {
checks: {
fails: 2,
passes: 55,
value: 0,
},
},
};
mock.onGet(DEFAULT_LOAD_PERFORMANCE.head_path).reply(200, head);
mock.onGet(DEFAULT_LOAD_PERFORMANCE.base_path).reply(200, base);
registerExtension(loadPerformanceExtension);
await waitForPromises();
expect(wrapper.text()).toContain('Load performance test metrics detected 1 change');
expect(wrapper.text()).toContain('1 improved');
});
it('should render info about added issues', async () => {
const head = {
metrics: {
checks: {
fails: 1,
passes: 100,
value: 0,
},
},
};
const base = {
metrics: {
checks: {
fails: 0,
passes: 55,
value: 0,
},
},
};
mock.onGet(DEFAULT_LOAD_PERFORMANCE.head_path).reply(200, head);
mock.onGet(DEFAULT_LOAD_PERFORMANCE.base_path).reply(200, base);
registerExtension(loadPerformanceExtension);
await waitForPromises();
expect(wrapper.text()).toContain('Load performance test metrics detected 1 change');
expect(wrapper.text()).toContain('1 degraded');
});
});
describe('expanded data', () => {
beforeEach(async () => {
mock.onGet(DEFAULT_LOAD_PERFORMANCE.head_path).reply(200, headLoadPerformance);
mock.onGet(DEFAULT_LOAD_PERFORMANCE.base_path).reply(200, baseLoadPerformance);
registerExtension(loadPerformanceExtension);
await waitForPromises();
wrapper
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click');
await nextTick();
});
it('expanded data list items text', () => {
const listItems = wrapper.findAll('[data-testid="extension-list-item"]');
expect(listItems.at(0).text()).toBe('TTFB P90: 100.60 (-3.50) (-3%)');
expect(listItems.at(1).text()).toBe('RPS: 8.99 (1.20) (+15%)');
expect(listItems.at(2).text()).toBe('TTFB P95: 125.45 (24.23) (+24%)');
expect(listItems.at(3).text()).toBe('Checks: 100.00%');
});
});
});
......@@ -39775,6 +39775,11 @@ msgstr ""
msgid "ciReport|Investigate this vulnerability by creating an issue"
msgstr ""
msgid "ciReport|Load performance test metrics detected %{strongStart}%{changesFound}%{strongEnd} change"
msgid_plural "ciReport|Load performance test metrics detected %{strongStart}%{changesFound}%{strongEnd} changes"
msgstr[0] ""
msgstr[1] ""
msgid "ciReport|Load performance test metrics: "
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