Commit d78caac0 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch...

Merge branch '12881-security-dashboard-leveraging-sparklines-to-show-vulnerability-trends' into 'master'

Vulnerabilities history chart - use sparklines

See merge request gitlab-org/gitlab!19745
parents 462ff32b 2869e969
......@@ -81,3 +81,20 @@ export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize }
},
},
});
/**
* Takes a dataset and returns an array containing the y-values of it's first and last entry.
* (e.g., [['xValue1', 'yValue1'], ['xValue2', 'yValue2'], ['xValue3', 'yValue3']] will yield ['yValue1', 'yValue3'])
*
* @param {Array} data
* @returns {[*, *]}
*/
export const firstAndLastY = data => {
const [firstEntry] = data;
const [lastEntry] = data.slice(-1);
const firstY = firstEntry[1];
const lastY = lastEntry[1];
return [firstY, lastY];
};
......@@ -117,3 +117,36 @@ export const median = arr => {
const sorted = arr.sort((a, b) => a - b);
return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
};
/**
* Computes the change from one value to the other as a percentage.
* @param {Number} firstY
* @param {Number} lastY
* @returns {Number}
*/
export const changeInPercent = (firstY, lastY) => {
if (firstY === lastY) {
return 0;
}
return Math.round(((lastY - firstY) / Math.abs(firstY)) * 100);
};
/**
* Computes and formats the change from one value to the other as a percentage.
* Prepends the computed percentage with either "+" or "-" to indicate an in- or decrease and
* returns a given string if the result is not finite (for example, if the first value is "0").
* @param firstY
* @param lastY
* @param nonFiniteResult
* @returns {String}
*/
export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-' } = {}) => {
const change = changeInPercent(firstY, lastY);
if (!Number.isFinite(change)) {
return nonFiniteResult;
}
return `${change >= 0 ? '+' : ''}${change}%`;
};
......@@ -562,6 +562,8 @@ img.emoji {
}
.gl-font-size-small { font-size: $gl-font-size-small; }
.gl-font-size-large { font-size: $gl-font-size-large; }
.gl-line-height-24 { line-height: $gl-line-height-24; }
.gl-font-size-12 { font-size: $gl-font-size-12; }
......
---
title: Vulnerabilities history chart - use sparklines
merge_request: 19745
author:
type: changed
......@@ -76,7 +76,7 @@ To the right of the filters, you should see a **Hide dismissed** toggle button (
NOTE: **Note:**
The dashboard only shows projects with [security reports](#supported-reports) enabled in a group.
![dashboard with action buttons and metrics](img/group_security_dashboard_v12_3.png)
![dashboard with action buttons and metrics](img/group_security_dashboard_v12_4.png)
Selecting one or more filters will filter the results in this page. Disabling the **Hide dismissed**
toggle button will let you also see vulnerabilities that have been dismissed.
......
<script>
import _ from 'underscore';
import dateFormat from 'dateformat';
import { mapState, mapGetters, mapActions } from 'vuex';
import GlLineChart from '@gitlab/ui/dist/components/charts/line/line';
import { __, s__, sprintf } from '~/locale';
import ChartTooltip from './vulnerability_chart_tooltip.vue';
import dateFormat from 'dateformat';
import { s__, sprintf } from '~/locale';
import { firstAndLastY } from '~/lib/utils/chart_utils';
import { formattedChangeInPercent } from '~/lib/utils/number_utils';
import { GlTooltipDirective, GlTable } from '@gitlab/ui';
import { GlSparklineChart } from '@gitlab/ui/dist/charts';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import ChartButtons from './vulnerability_chart_buttons.vue';
import { DAY_IN_MS, DAYS } from '../store/modules/vulnerabilities/constants';
import { SEVERITY_LEVELS } from '../store/constants';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
export default {
name: 'VulnerabilityChart',
components: {
GlLineChart,
ChartTooltip,
ChartButtons,
ResizableChartContainer,
GlSparklineChart,
GlTable,
SeverityBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
},
DAYS,
data: () => ({
tooltipTitle: '',
tooltipEntries: [],
lines: [
{
name: SEVERITY_LEVELS.critical,
color: '#C0341D',
},
{
name: SEVERITY_LEVELS.high,
color: '#DE7E00',
},
{
name: SEVERITY_LEVELS.medium,
color: '#6E49CB',
},
{
name: SEVERITY_LEVELS.low,
color: '#4F4F4F',
},
{
name: __('Total'),
color: '#1F78D1',
},
],
}),
fields: [
{ key: 'severityLevel', label: s__('VulnerabilityChart|Severity'), tdClass: 'border-0' },
{ key: 'chartData', label: '', tdClass: 'border-0 w-100' },
{ key: 'changeInPercent', label: '%', thClass: 'text-right', tdClass: 'border-0 text-right' },
{
key: 'currentVulnerabilitiesCount',
label: '#',
thClass: 'text-right',
tdClass: 'border-0 text-right',
},
],
severityLevels: [
SEVERITY_LEVELS.critical,
SEVERITY_LEVELS.high,
SEVERITY_LEVELS.medium,
SEVERITY_LEVELS.low,
],
computed: {
...mapState('vulnerabilities', [
'vulnerabilitiesHistory',
......@@ -52,54 +52,28 @@ export default {
'vulnerabilitiesHistoryMaxDayInterval',
]),
...mapGetters('vulnerabilities', ['getFilteredVulnerabilitiesHistory']),
series() {
return this.lines.map(line => {
const { name, color } = line;
const data = this.getFilteredVulnerabilitiesHistory(name);
charts() {
const { severityLevels } = this.$options;
return severityLevels.map(severityLevel => {
const history = this.getFilteredVulnerabilitiesHistory(severityLevel);
const chartData = history.length ? history : this.emptyDataSet;
const [pastVulnerabilitiesCount, currentVulnerabilitiesCount] = firstAndLastY(chartData);
const changeInPercent = formattedChangeInPercent(
pastVulnerabilitiesCount,
currentVulnerabilitiesCount,
);
return {
color,
lineStyle: {
color,
},
data,
name,
severityLevel,
chartData,
currentVulnerabilitiesCount,
changeInPercent,
};
});
},
options() {
return {
grid: {
bottom: 30,
left: 30,
right: 20,
top: 10,
},
tooltip: {
formatter: this.renderTooltip,
padding: 0,
trigger: 'axis',
},
xAxis: {
axisLabel: {
color: '#707070',
formatter: date => dateFormat(date, 'd mmm'),
margin: 8,
},
maxInterval: DAY_IN_MS * this.vulnerabilitiesHistoryMaxDayInterval,
min: this.startDate,
type: 'time',
},
yAxis: {
axisLabel: {
color: '#707070',
},
boundaryGap: [0, '5%'],
minInterval: 1,
type: 'value',
},
};
},
startDate() {
return Date.now() - DAY_IN_MS * this.vulnerabilitiesHistoryDayRange;
},
......@@ -114,30 +88,31 @@ export default {
const { $options } = this;
return [$options.DAYS.THIRTY, $options.DAYS.SIXTY, $options.DAYS.NINETY];
},
emptyDataSet() {
const format = 'isoDate';
const formattedStartDate = dateFormat(this.startDate, format);
const formattedEndDate = dateFormat(Date.now(), format);
return [[formattedStartDate, 0], [formattedEndDate, 0]];
},
},
methods: {
...mapActions('vulnerabilities', ['setVulnerabilitiesHistoryDayRange']),
noop: _.noop, // Used to tell line-chart to render external tooltip
renderTooltip(params) {
this.tooltipTitle = dateFormat(params[0].axisValue, 'd mmmm');
this.tooltipEntries = params;
return ' ';
},
},
};
</script>
<template>
<section class="border rounded p-3">
<header>
<h4 class="mt-0 mb-1">
{{ __('Vulnerabilities over time') }}
</h4>
<p class="text-secondary mt-0 js-vulnerabilities-chart-time-info">
{{ dateInfo }}
</p>
</header>
<div class="align-self-center mb-3">
<section class="border rounded p-0">
<div class="p-3">
<header id="vulnerability-chart-header">
<h4 class="my-0">
{{ __('Vulnerabilities over time') }}
</h4>
<p ref="timeInfo" class="text-secondary mt-0 js-vulnerabilities-chart-time-info">
{{ dateInfo }}
</p>
</header>
<chart-buttons
:days="days"
:active-day="vulnerabilitiesHistoryDayRange"
......@@ -145,23 +120,48 @@ export default {
/>
</div>
<div class="vulnerabilities-chart">
<div class="vulnerabilities-chart-wrapper">
<resizable-chart-container>
<gl-line-chart
slot-scope="{ width }"
class="js-vulnerabilities-chart-line-chart"
:width="width"
:option="options"
:data="series"
:format-tooltip-text="noop"
:include-legend-avg-max="true"
>
<span slot="tooltipTitle">{{ tooltipTitle }}</span>
<chart-tooltip slot="tooltipContent" :entries="tooltipEntries" />
</gl-line-chart>
</resizable-chart-container>
</div>
</div>
<gl-table
:fields="$options.fields"
:items="charts"
:borderless="true"
thead-class="thead-white"
class="js-vulnerabilities-chart-severity-level-breakdown"
>
<template #HEAD_changeInPercent="{ label }">
<span v-gl-tooltip :title="__('Difference between start date and now')">{{ label }}</span>
</template>
<template #HEAD_currentVulnerabilitiesCount="{ label }">
<span v-gl-tooltip :title="__('Current vulnerabilities count')">{{ label }}</span>
</template>
<template #severityLevel="{ value }">
<div class="gl-font-size-large">
<severity-badge
:ref="`severityBadge${value}`"
:severity="value"
class="d-block p-2 text-center"
/>
</div>
</template>
<template #chartData="{ item }">
<div class="position-relative h-32-px">
<gl-sparkline-chart
:ref="`sparklineChart${item.severityLevel}`"
:height="32"
:data="item.chartData"
:tooltip-label="__('Vulnerabilities')"
:show-last-y-value="false"
class="position-absolute w-100 position-top-0 position-left-0"
/>
</div>
</template>
<template #changeInPercent="{ value }">
<span ref="changeInPercent">{{ value }}</span>
</template>
<template #currentVulnerabilitiesCount="{ value }">
<span ref="currentVulnerabilitiesCount">{{ value }}</span>
</template>
</gl-table>
</section>
</template>
<script>
export default {
name: 'VulnerabilityChartLabel',
props: {
name: {
type: String,
required: true,
},
color: {
type: String,
required: true,
},
value: {
type: [Number],
required: false,
default: null,
},
},
};
</script>
<template>
<div class="d-flex align-items-center mb-1 js-chart-label">
<div class="js-color" :style="{ backgroundColor: color, width: '12px', height: '4px' }"></div>
<strong class="ml-2 mr-3 text-capitalize js-name">{{ name }}</strong>
<span v-if="value !== null" class="ml-auto js-value">{{ value }}</span>
</div>
</template>
<script>
import VulnerabilityChartLabel from './vulnerability_chart_label.vue';
export default {
name: 'VulnerabilityChartTooltip',
components: {
VulnerabilityChartLabel,
},
props: {
entries: {
type: Array,
required: true,
},
},
};
</script>
<template>
<div>
<vulnerability-chart-label
v-for="entry in entries"
:key="entry.seriesId + entry.dataIndex"
:name="entry.seriesName"
:value="entry.data[1]"
:color="entry.color"
/>
</div>
</template>
$trans-white: rgba(255, 255, 255, 0);
.vulnerabilities-chart-wrapper {
-webkit-overflow-scrolling: touch;
}
......@@ -45,7 +45,7 @@ describe 'Group overview', :js do
page.within(find('aside')) do
expect(page).to have_content 'Vulnerabilities over time'
expect(page).to have_selector('.js-vulnerabilities-chart-time-info')
expect(page).to have_selector('.js-vulnerabilities-chart-line-chart')
expect(page).to have_selector('.js-vulnerabilities-chart-severity-level-breakdown')
end
end
end
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlTable } from '@gitlab/ui';
import { GlSparklineChart } from '@gitlab/ui/dist/charts';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import Chart from 'ee/security_dashboard/components/vulnerability_chart.vue';
import ChartButtons from 'ee/security_dashboard/components/vulnerability_chart_buttons.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Vulnerability Chart component', () => {
let actions;
let getters;
let state;
let store;
let wrapper;
const findTimeInfo = () => wrapper.find({ ref: 'timeInfo' });
const findSeverityBadgeForLevel = severityLevel =>
wrapper.find(SeverityBadge, { ref: `severityBadge${severityLevel}` });
const findSparklineChartForLevel = severityLevel =>
wrapper.find(GlSparklineChart, { ref: `sparklineChart${severityLevel}` });
const findChangeInPercent = () => wrapper.find({ ref: 'changeInPercent' });
const findCurrentVulnerabilitiesCount = () =>
wrapper.find({ ref: 'currentVulnerabilitiesCount' });
const factory = ({ vulnerabilitiesCount = [] } = {}) => {
actions = {
setVulnerabilitiesHistoryDayRange: jest.fn(),
};
getters = {
getFilteredVulnerabilitiesHistory: () => () =>
vulnerabilitiesCount.map(c => ['some-date', c]),
getVulnerabilityHistoryByName: () => () => [],
};
state = {
vulnerabilitiesHistory: {},
vulnerabilitiesHistoryDayRange: 90,
vulnerabilitiesHistoryMaxDayInterval: 7,
};
store = new Vuex.Store({
modules: {
vulnerabilities: {
namespaced: true,
actions,
getters,
state,
},
},
});
wrapper = shallowMount(Chart, {
localVue,
store,
stubs: { GlTable },
sync: false,
});
};
afterEach(() => {
wrapper.destroy();
jest.restoreAllMocks();
});
describe('header', () => {
it.each`
mockDate | dayRange | expectedStartDate
${'2000-01-01T00:00:00Z'} | ${90} | ${'October 3rd'}
${'2000-01-01T00:00:00Z'} | ${60} | ${'November 2nd'}
${'2000-01-01T00:00:00Z'} | ${30} | ${'December 2nd'}
`(
'shows "$expectedStartDate" when the date range is set to "$dayRange" days',
({ mockDate, dayRange, expectedStartDate }) => {
jest.spyOn(global.Date, 'now').mockImplementation(() => new Date(mockDate));
factory();
store.state.vulnerabilities.vulnerabilitiesHistoryDayRange = dayRange;
return wrapper.vm.$nextTick().then(() => {
expect(findTimeInfo().text()).toContain(expectedStartDate);
});
},
);
});
describe('date range selectors', () => {
beforeEach(factory);
it('shows a set of buttons to select the supported day ranges', () => {
const supportedDayRanges = [30, 60, 90];
expect(wrapper.find(ChartButtons).props('days')).toEqual(supportedDayRanges);
});
it('dispatches "setVulnerabilitiesHistoryDayRange" when a day range is selected', () => {
const selectedDayRange = 30;
wrapper.find(ChartButtons).vm.$emit('click', selectedDayRange);
expect(actions.setVulnerabilitiesHistoryDayRange).toHaveBeenCalledTimes(1);
expect(actions.setVulnerabilitiesHistoryDayRange.mock.calls[0][1]).toBe(selectedDayRange);
});
});
describe('charts table', () => {
describe.each(['Critical', 'Medium', 'High', 'Low'])(
'for the given severity level "%s"',
severityLevel => {
beforeEach(factory);
it('shows a severity badge', () => {
expect(findSeverityBadgeForLevel(severityLevel).exists()).toBe(true);
});
it('shows a chart', () => {
expect(findSparklineChartForLevel(severityLevel).exists()).toBe(true);
});
},
);
it.each`
countPast | countCurrent | expectedOutput
${1} | ${2} | ${'+100%'}
${100} | ${1} | ${'-99%'}
${1} | ${1} | ${'+0%'}
${0} | ${1} | ${'-'}
`(
'shows "$expectedOutput" when the vulnerabilities changed from "$countPast" to "$countCurrent"',
({ countPast, countCurrent, expectedOutput }) => {
factory({
vulnerabilitiesCount: [countPast, countCurrent],
});
expect(findChangeInPercent().text()).toBe(expectedOutput);
},
);
it.each`
vulnerabilitiesCount | expectedOutput
${[1, 2, 3]} | ${'3'}
`('shows the current vulnerabilities count', ({ vulnerabilitiesCount, expectedOutput }) => {
factory({ vulnerabilitiesCount });
expect(findCurrentVulnerabilitiesCount().text()).toBe(expectedOutput);
});
});
});
import Vue from 'vue';
import component from 'ee/security_dashboard/components/vulnerability_chart_label.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
function hexToRgb(hex) {
const cleanHex = hex.replace('#', '');
const [r, g, b] = [
cleanHex.substring(0, 2),
cleanHex.substring(2, 4),
cleanHex.substring(4, 6),
].map(rgb => parseInt(rgb, 16));
return `rgb(${r}, ${g}, ${b})`;
}
describe('Vulnerability Chart Label component', () => {
const Component = Vue.extend(component);
let vm;
const props = {
name: 'Chuck Norris',
color: '#BADA55',
value: 42,
};
describe('default', () => {
beforeEach(() => {
vm = mountComponent(Component, props);
});
afterEach(() => {
vm.$destroy();
});
it('should render the name', () => {
const name = vm.$el.querySelector('.js-name');
expect(name.textContent).toContain(props.name);
});
it('should render the value', () => {
const value = vm.$el.querySelector('.js-value');
expect(value.textContent).toContain(props.value);
});
it('should render the color', () => {
const color = vm.$el.querySelector('.js-color');
expect(color.style.backgroundColor).toBe(hexToRgb(props.color));
});
});
describe('when the value is 0', () => {
const newProps = { ...props, value: 0 };
beforeEach(() => {
vm = mountComponent(Component, newProps);
});
afterEach(() => {
vm.$destroy();
});
it('should still render the value, but show a "0"', () => {
const value = vm.$el.querySelector('.js-value');
expect(value.textContent).toContain(newProps.value);
});
});
});
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import component from 'ee/security_dashboard/components/vulnerability_chart.vue';
import createStore from 'ee/security_dashboard/store';
import { mount } from '@vue/test-utils';
import { resetStore } from '../helpers';
import mockDataVulnerabilitiesHistory from '../store/vulnerabilities/data/mock_data_vulnerabilities_history.json';
describe('Vulnerabilities Chart', () => {
const Component = Vue.extend(component);
const vulnerabilitiesHistoryEndpoint = '/vulnerabilitiesEndpoint.json';
let store;
let mock;
let wrapper;
beforeEach(() => {
store = createStore();
store.state.vulnerabilities.vulnerabilitiesHistoryEndpoint = vulnerabilitiesHistoryEndpoint;
mock = new MockAdapter(axios);
mock.onGet(vulnerabilitiesHistoryEndpoint).replyOnce(200, mockDataVulnerabilitiesHistory);
wrapper = mount(Component, { store, sync: false });
});
afterEach(() => {
resetStore(store);
wrapper.destroy();
mock.restore();
});
it('should render the e-chart instance', done => {
Vue.nextTick()
.then(() => {
expect(wrapper.find('[_echarts_instance_]')).not.toBeNull();
done();
})
.catch(done.fail);
});
});
import Vue from 'vue';
import component from 'ee/security_dashboard/components/vulnerability_chart_tooltip.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Vulnerability Chart Tooltip component', () => {
const Component = Vue.extend(component);
const props = {
entries: [
{
dataIndex: 1,
seriesId: 'critical_0',
seriesName: 'critical',
color: '#00f',
data: ['critical', 32],
},
{
dataIndex: 1,
seriesId: 'high_0',
seriesName: 'high',
color: '#0f0',
data: ['high', 22],
},
{
dataIndex: 1,
seriesId: 'low_0',
seriesName: 'low',
color: '#f00',
data: ['low', 2],
},
],
};
let vm;
beforeEach(() => {
vm = mountComponent(Component, props);
});
afterEach(() => {
vm.$destroy();
});
it('should render three legends', () => {
const legends = vm.$el.querySelectorAll('.js-chart-label');
expect(legends).toHaveLength(3);
});
});
......@@ -4994,6 +4994,9 @@ msgstr ""
msgid "Current password"
msgstr ""
msgid "Current vulnerabilities count"
msgstr ""
msgid "CurrentUser|Profile"
msgstr ""
......@@ -5791,6 +5794,9 @@ msgstr ""
msgid "Diff limits"
msgstr ""
msgid "Difference between start date and now"
msgstr ""
msgid "DiffsCompareBaseBranch|(base)"
msgstr ""
......@@ -19180,6 +19186,9 @@ msgstr ""
msgid "VulnerabilityChart|%{formattedStartDate} to today"
msgstr ""
msgid "VulnerabilityChart|Severity"
msgstr ""
msgid "Vulnerability|Class"
msgstr ""
......
import { firstAndLastY } from '~/lib/utils/chart_utils';
describe('Chart utils', () => {
describe('firstAndLastY', () => {
it('returns the first and last y-values of a given data set as an array', () => {
const data = [['', 1], ['', 2], ['', 3]];
expect(firstAndLastY(data)).toEqual([1, 3]);
});
});
});
......@@ -7,6 +7,8 @@ import {
sum,
isOdd,
median,
changeInPercent,
formattedChangeInPercent,
} from '~/lib/utils/number_utils';
describe('Number Utils', () => {
......@@ -122,4 +124,42 @@ describe('Number Utils', () => {
expect(median(items)).toBe(14.5);
});
});
describe('changeInPercent', () => {
it.each`
firstValue | secondValue | expectedOutput
${99} | ${100} | ${1}
${100} | ${99} | ${-1}
${0} | ${99} | ${Infinity}
${2} | ${2} | ${0}
${-100} | ${-99} | ${1}
`(
'computes the change between $firstValue and $secondValue in percent',
({ firstValue, secondValue, expectedOutput }) => {
expect(changeInPercent(firstValue, secondValue)).toBe(expectedOutput);
},
);
});
describe('formattedChangeInPercent', () => {
it('prepends "%" to the output', () => {
expect(formattedChangeInPercent(1, 2)).toMatch(/%$/);
});
it('indicates if the change was a decrease', () => {
expect(formattedChangeInPercent(100, 99)).toContain('-1');
});
it('indicates if the change was an increase', () => {
expect(formattedChangeInPercent(99, 100)).toContain('+1');
});
it('shows "-" per default if the change can not be expressed in an integer', () => {
expect(formattedChangeInPercent(0, 1)).toBe('-');
});
it('shows the given fallback if the change can not be expressed in an integer', () => {
expect(formattedChangeInPercent(0, 1, { nonFiniteResult: '*' })).toBe('*');
});
});
});
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