Commit e976515c authored by Savas Vedova's avatar Savas Vedova

Create GraphQL backed Vuln History component

- Add support for instance level dashboard
- Add support for group level dashboard
parent e793189a
......@@ -2,13 +2,16 @@
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import GroupSecurityVulnerabilities from 'ee/security_dashboard/components/first_class_group_security_dashboard_vulnerabilities.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import VulnerabilitySeverity from './vulnerability_severity.vue';
import vulnerabilityHistoryQuery from '../graphql/group_vulnerability_history.graphql';
export default {
components: {
SecurityDashboardLayout,
GroupSecurityVulnerabilities,
VulnerabilitySeverity,
VulnerabilityChart,
Filters,
},
props: {
......@@ -33,6 +36,7 @@ export default {
return {
filters: {},
projects: [],
vulnerabilityHistoryQuery,
};
},
methods: {
......@@ -59,6 +63,11 @@ export default {
@projectFetch="handleProjectsFetch"
/>
<template #aside>
<vulnerability-chart
:query="vulnerabilityHistoryQuery"
:group-full-path="groupFullPath"
class="mb-4"
/>
<vulnerability-severity :endpoint="vulnerableProjectsEndpoint" />
</template>
</security-dashboard-layout>
......
......@@ -5,9 +5,11 @@ import { s__ } from '~/locale';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import ProjectManager from './project_manager.vue';
import CsvExportButton from './csv_export_button.vue';
import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.graphql';
export default {
components: {
......@@ -16,6 +18,7 @@ export default {
SecurityDashboardLayout,
InstanceSecurityVulnerabilities,
VulnerabilitySeverity,
VulnerabilityChart,
Filters,
GlEmptyState,
GlLoadingIcon,
......@@ -54,6 +57,7 @@ export default {
filters: {},
graphqlProjectList: [], // TODO: Rename me to projects once we back the project selector with GraphQL as well
showProjectSelector: false,
vulnerabilityHistoryQuery,
};
},
computed: {
......@@ -156,6 +160,11 @@ export default {
<gl-loading-icon v-else size="lg" class="mt-4" />
</div>
<template #aside>
<vulnerability-chart
v-if="shouldShowDashboard"
:query="vulnerabilityHistoryQuery"
class="mb-4"
/>
<vulnerability-severity v-if="shouldShowDashboard" :endpoint="vulnerableProjectsEndpoint" />
</template>
</security-dashboard-layout>
......
<script>
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 { s__, sprintf } from '~/locale';
import { firstAndLastY } from '~/lib/utils/chart_utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import { formattedChangeInPercent } from '~/lib/utils/number_utils';
import ChartButtons from './vulnerability_chart_buttons.vue';
import { DAY_IN_MS } from '../store/modules/vulnerabilities/constants';
import { SEVERITY_LEVELS } from '../store/constants';
const ISO_DATE = 'isoDate';
const DAYS = { thirty: 30, sixty: 60, ninety: 90 };
const MAX_DAY_INTERVAL_ALLOWED_BY_BACKEND = 10;
export default {
components: {
ChartButtons,
GlSparklineChart,
GlTable,
SeverityBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
query: { type: Object, required: true },
groupFullPath: { type: String, required: false, default: undefined },
},
data() {
return {
vulnerabilitiesHistory: {},
vulnerabilitiesHistoryDayRange: DAYS.thirty,
errorLoadingVulnerabilitiesHistory: false,
currentStartDateCursor: undefined,
};
},
days: Object.values(DAYS),
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,
].map(l => l.toLowerCase()),
apollo: {
vulnerabilitiesHistory: {
query() {
return this.query;
},
variables() {
return {
fullPath: this.groupFullPath,
startDate: formatDate(new Date(this.startDateCursor), ISO_DATE),
endDate: this.formattedEndDateCursor,
};
},
update(results) {
return this.processRawData(results);
},
result() {
if (this.formattedEndDateCursor !== formatDate(new Date(), ISO_DATE)) {
this.currentStartDateCursor = this.endDateCursor;
}
},
error() {
this.errorLoadingVulnerabilitiesHistory = true;
},
},
},
computed: {
startDate() {
return Date.now() - DAY_IN_MS * this.vulnerabilitiesHistoryDayRange;
},
startDateCursor() {
return this.currentStartDateCursor || this.startDate;
},
endDateCursor() {
return Math.min(
this.startDateCursor + DAY_IN_MS * (MAX_DAY_INTERVAL_ALLOWED_BY_BACKEND - 1),
Date.now(),
);
},
formattedEndDateCursor() {
return formatDate(new Date(this.endDateCursor), ISO_DATE);
},
charts() {
const { severityLevels } = this.$options;
return severityLevels.map(severityLevel => {
const history = Object.entries(this.vulnerabilitiesHistory[severityLevel] || {});
const chartData = history.length ? history : this.emptyDataSet;
const [pastCount, currentCount] = firstAndLastY(chartData);
const changeInPercent = formattedChangeInPercent(pastCount, currentCount);
return {
severityLevel,
chartData,
changeInPercent,
currentVulnerabilitiesCount: currentCount,
};
});
},
dateInfo() {
return sprintf(s__('VulnerabilityChart|%{formattedStartDate} to today'), {
formattedStartDate: formatDate(this.startDate, 'mmmm dS'),
});
},
emptyDataSet() {
const formattedStartDate = formatDate(this.startDate, ISO_DATE);
const formattedEndDate = formatDate(Date.now(), ISO_DATE);
return [[formattedStartDate, 0], [formattedEndDate, 0]];
},
},
watch: {
startDateCursor() {
this.$apollo.queries.vulnerabilitiesHistory.refetch();
},
},
methods: {
setVulnerabilitiesHistoryDayRange(days) {
this.vulnerabilitiesHistory = {};
this.vulnerabilitiesHistoryDayRange = days;
this.currentStartDateCursor = undefined;
},
processRawData(results) {
let { vulnerabilitiesCountByDayAndSeverity } = results;
if (this.groupFullPath) {
vulnerabilitiesCountByDayAndSeverity = results.group.vulnerabilitiesCountByDayAndSeverity;
}
const vulnerabilitiesData = vulnerabilitiesCountByDayAndSeverity.nodes.reduce(
(acc, v) => {
const severity = v.severity.toLowerCase();
acc[severity] = acc[severity] || {};
acc[severity][v.day] = v.count;
return acc;
},
{ ...this.vulnerabilitiesHistory },
);
// backend provide the data not sorted - we need to sort it by day first.
return Object.keys(vulnerabilitiesData).reduce((acc, severity) => {
acc[severity] = {};
Object.keys(vulnerabilitiesData[severity])
.sort()
.forEach(day => {
acc[severity][day] = vulnerabilitiesData[severity][day];
}, {});
return acc;
}, {});
},
},
};
</script>
<template>
<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="$options.days"
:active-day="vulnerabilitiesHistoryDayRange"
@click="setVulnerabilitiesHistoryDayRange"
/>
</div>
<gl-table
:fields="$options.fields"
:items="charts"
:borderless="true"
thead-class="thead-white"
class="js-vulnerabilities-chart-severity-level-breakdown mb-2"
>
<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 #cell(severityLevel)="{ value }">
<severity-badge :ref="`severityBadge${value}`" :severity="value" />
</template>
<template #cell(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 #cell(changeInPercent)="{ value }">
<span ref="changeInPercent">{{ value }}</span>
</template>
<template #cell(currentVulnerabilitiesCount)="{ value }">
<span ref="currentVulnerabilitiesCount">{{ value }}</span>
</template>
</gl-table>
</section>
</template>
query groupVulnerabilityHistory(
$fullPath: ID!
$startDate: ISO8601Date!
$endDate: ISO8601Date!
) {
group(fullPath: $fullPath) {
vulnerabilitiesCountByDayAndSeverity(startDate: $startDate, endDate: $endDate) {
nodes {
day
count
severity
}
}
}
}
query instanceVulnerabilityHistory($startDate: ISO8601Date!, $endDate: ISO8601Date!) {
vulnerabilitiesCountByDayAndSeverity(startDate: $startDate, endDate: $endDate) {
nodes {
day
count
severity
}
}
}
......@@ -3,6 +3,7 @@ import SecurityDashboardLayout from 'ee/security_dashboard/components/security_d
import FirstClassGroupDashboard from 'ee/security_dashboard/components/first_class_group_security_dashboard.vue';
import FirstClassGroupVulnerabilities from 'ee/security_dashboard/components/first_class_group_security_dashboard_vulnerabilities.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
describe('First Class Group Dashboard Component', () => {
......@@ -15,6 +16,7 @@ describe('First Class Group Dashboard Component', () => {
const findGroupVulnerabilities = () => wrapper.find(FirstClassGroupVulnerabilities);
const findVulnerabilitySeverity = () => wrapper.find(VulnerabilitySeverity);
const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart);
const findFilters = () => wrapper.find(Filters);
const createWrapper = () => {
......@@ -52,6 +54,10 @@ describe('First Class Group Dashboard Component', () => {
expect(findFilters().exists()).toBe(true);
});
it('has the vulnerability history chart', () => {
expect(findVulnerabilityChart().props('groupFullPath')).toBe(groupFullPath);
});
it('responds to the projectFetch event', () => {
const projects = [{ id: 1, name: 'GitLab Org' }];
findGroupVulnerabilities().vm.$listeners.projectFetch(projects);
......
......@@ -5,6 +5,7 @@ import SecurityDashboardLayout from 'ee/security_dashboard/components/security_d
import FirstClassInstanceDashboard from 'ee/security_dashboard/components/first_class_instance_security_dashboard.vue';
import FirstClassInstanceVulnerabilities from 'ee/security_dashboard/components/first_class_instance_security_dashboard_vulnerabilities.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import CsvExportButton from 'ee/security_dashboard/components/csv_export_button.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import ProjectManager from 'ee/security_dashboard/components/project_manager.vue';
......@@ -25,6 +26,7 @@ describe('First Class Instance Dashboard Component', () => {
const findInstanceVulnerabilities = () => wrapper.find(FirstClassInstanceVulnerabilities);
const findVulnerabilitySeverity = () => wrapper.find(VulnerabilitySeverity);
const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart);
const findCsvExportButton = () => wrapper.find(CsvExportButton);
const findProjectManager = () => wrapper.find(ProjectManager);
const findEmptyState = () => wrapper.find(GlEmptyState);
......@@ -92,6 +94,10 @@ describe('First Class Instance Dashboard Component', () => {
expect(findFilters().exists()).toBe(true);
});
it('does not pass down a groupFullPath to the vulnerability chart', () => {
expect(findVulnerabilityChart().props('groupFullPath')).toBeUndefined();
});
it('responds to the projectFetch event', () => {
const projects = [{ id: 1, name: 'GitLab Org' }];
findInstanceVulnerabilities().vm.$listeners.projectFetch(projects);
......
import { shallowMount } from '@vue/test-utils';
import stubChildren from 'helpers/stub_children';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart';
import ChartButtons from 'ee/security_dashboard/components/vulnerability_chart_buttons.vue';
describe('First class vulnerability chart component', () => {
let wrapper;
const responseData = {
vulnerabilitiesCountByDayAndSeverity: {
nodes: [
{ day: '2020-05-18', count: 5, severity: 'MEDIUM' },
{ day: '2020-05-19', count: 2, severity: 'MEDIUM' },
{ day: '2020-05-18', count: 2, severity: 'CRITICAL' },
],
},
};
const findTimeInfo = () => wrapper.find({ ref: 'timeInfo' });
const findChartButtons = () => wrapper.find(ChartButtons);
const findActiveChartButton = () => findChartButtons().find('.active');
const find90DaysChartButton = () => findChartButtons().find('[data-days="90"]');
const createComponent = ({ $apollo, propsData, stubs, data } = {}) => {
const instance = shallowMount(VulnerabilityChart, {
$apollo,
propsData: { query: {}, ...propsData },
stubs: {
...stubChildren(VulnerabilityChart),
...stubs,
},
data,
});
instance.vm.$apollo = { queries: { vulnerabilitiesHistory: { refetch: jest.fn() } } };
return instance;
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
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));
wrapper = createComponent({ data: () => ({ vulnerabilitiesHistoryDayRange: dayRange }) });
return wrapper.vm.$nextTick().then(() => {
expect(findTimeInfo().text()).toContain(expectedStartDate);
});
},
);
});
describe('date range selectors', () => {
beforeEach(() => {
wrapper = createComponent({ stubs: { ChartButtons } });
});
it('should contain the chart buttons', () => {
expect(findChartButtons().text()).toContain('30 Days');
expect(findChartButtons().text()).toContain('60 Days');
expect(findChartButtons().text()).toContain('90 Days');
});
it('should change the actively selected chart button and refetch the new data', () => {
expect(findActiveChartButton().text()).toContain('30 Days');
find90DaysChartButton().vm.$emit('click');
return wrapper.vm.$nextTick(() => {
expect(findActiveChartButton().text()).toContain('90 Days');
expect(wrapper.vm.$apollo.queries.vulnerabilitiesHistory.refetch).toHaveBeenCalledTimes(1);
});
});
});
describe('when loading the history chart for group level dashboard', () => {
beforeEach(() => {
wrapper = createComponent({
propsData: { groupFullPath: 'gitlab-org' },
$apollo: {
queries: { vulnerabilitiesHistory: { group: responseData } },
},
});
});
it('should process the data returned from GraphQL properly', () => {
expect(wrapper.vm.processRawData({ group: responseData })).toEqual({
critical: { '2020-05-18': 2 },
medium: { '2020-05-18': 5, '2020-05-19': 2 },
});
});
});
describe('when loading the history chart for instance level dashboard', () => {
beforeEach(() => {
wrapper = createComponent({
$apollo: {
queries: { vulnerabilitiesHistory: responseData },
},
});
});
it('should process the data returned from GraphQL properly', () => {
expect(wrapper.vm.processRawData(responseData)).toEqual({
critical: { '2020-05-18': 2 },
medium: { '2020-05-18': 5, '2020-05-19': 2 },
});
});
});
});
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