Commit a9a7547b authored by Mike Greiling's avatar Mike Greiling

Merge branch '6954-dashboard-chart' into 'master'

Resolve "Group Security Dashboard metrics MVC"

See merge request gitlab-org/gitlab-ee!8631
parents 110004ce 54f7a47e
......@@ -6,6 +6,7 @@ import Tabs from '~/vue_shared/components/tabs/tabs';
import Tab from '~/vue_shared/components/tabs/tab.vue';
import IssueModal from 'ee/vue_shared/security_reports/components/modal.vue';
import SecurityDashboardTable from './security_dashboard_table.vue';
import VulnerabilityChart from './vulnerability_chart.vue';
import VulnerabilityCountList from './vulnerability_count_list.vue';
import Icon from '~/vue_shared/components/icon.vue';
import popover from '~/vue_shared/directives/popover';
......@@ -21,6 +22,7 @@ export default {
SecurityDashboardTable,
Tab,
Tabs,
VulnerabilityChart,
VulnerabilityCountList,
},
props: {
......@@ -40,6 +42,10 @@ export default {
type: String,
required: true,
},
vulnerabilitiesHistoryEndpoint: {
type: String,
required: true,
},
vulnerabilityFeedbackHelpPath: {
type: String,
required: true,
......@@ -73,15 +79,20 @@ export default {
html: true,
};
},
chartFlagEnabled() {
return gon.features && gon.features.groupSecurityDashboardHistory;
},
},
created() {
this.setVulnerabilitiesEndpoint(this.vulnerabilitiesEndpoint);
this.setVulnerabilitiesCountEndpoint(this.vulnerabilitiesCountEndpoint);
this.setVulnerabilitiesHistoryEndpoint(this.vulnerabilitiesHistoryEndpoint);
this.fetchVulnerabilitiesCount();
},
methods: {
...mapActions('vulnerabilities', [
'setVulnerabilitiesCountEndpoint',
'setVulnerabilitiesHistoryEndpoint',
'setVulnerabilitiesEndpoint',
'fetchVulnerabilitiesCount',
'createIssue',
......@@ -108,7 +119,11 @@ export default {
</span>
</template>
<vulnerability-count-list />
<h5 class="mt-4 mb-4">{{ __('Vulnerability List') }}</h5>
<template v-if="chartFlagEnabled">
<h4 class="my-4">{{ __('Vulnerability Chart') }}</h4>
<vulnerability-chart />
</template>
<h4 class="my-4">{{ __('Vulnerability List') }}</h4>
<security-dashboard-table
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
......
<script>
import dateFormat from 'dateformat';
import { mapState, mapActions } from 'vuex';
import { GlChart } from '@gitlab/ui';
import ChartTooltip from './vulnerability_chart_tooltip.vue';
export default {
name: 'VulnerabilityChart',
components: {
GlChart,
ChartTooltip,
},
data: () => ({
tooltipTitle: '',
tooltipEntries: [],
lines: [
{
name: 'Critical',
color: '#C0341D',
},
{
name: 'High',
color: '#DE7E00',
},
{
name: 'Medium',
color: '#6E49CB',
},
{
name: 'Low',
color: '#4F4F4F',
},
{
name: 'Total',
color: '#1F78D1',
},
],
}),
computed: {
...mapState('vulnerabilities', ['vulnerabilitiesHistory']),
series() {
return this.lines.map(line => {
const { name, color } = line;
const history = this.vulnerabilitiesHistory[name.toLowerCase()];
const data = history ? Object.entries(history) : [];
return {
borderWidth: 2,
color,
data,
name,
symbol: 'circle',
symbolSize: 6,
type: 'line',
};
});
},
options() {
return {
grid: {
bottom: 85,
left: 75,
right: 15,
top: 10,
},
tooltip: {
backgroundColor: '#fff',
borderColor: 'rgba(0, 0, 0, 0.1)',
borderWidth: 1,
confine: true,
formatter: this.renderTooltip,
padding: 0,
textStyle: {
color: '#4F4F4F',
},
trigger: 'axis',
},
xAxis: {
axisLabel: {
color: '#707070',
formatter: date => dateFormat(date, 'd mmm'),
margin: 8,
rotate: 45,
},
axisLine: {
lineStyle: {
color: '#dedede',
width: 2,
},
},
axisTick: {
show: false,
},
maxInterval: 1000 * 60 * 60 * 24 * 7,
min: Date.now() - 1000 * 60 * 60 * 24 * 28,
name: 'Date',
nameGap: 50,
nameLocation: 'center',
nameTextStyle: {
color: '#2e2e2e',
fontWeight: 'bold',
},
splitNumber: 12,
type: 'time',
},
yAxis: {
axisLabel: {
color: '#707070',
},
axisLine: {
lineStyle: {
color: '#dedede',
width: 2,
},
},
axisTick: {
show: false,
},
interval: 25,
name: 'Vulnerabilities',
nameGap: 42,
nameLocation: 'center',
nameRotation: 90,
nameTextStyle: {
color: '#2e2e2e',
fontWeight: 'bold',
},
type: 'value',
},
legend: {
bottom: 0,
icon: 'path://M0,0H120V40H0Z',
itemGap: 15,
left: 70,
textStyle: {
color: '#4F4F4F',
fontWeight: 'bold',
},
type: 'scroll',
},
series: this.series,
};
},
},
created() {
this.fetchVulnerabilitiesHistory();
},
methods: {
...mapActions('vulnerabilities', ['fetchVulnerabilitiesHistory']),
renderTooltip(params, ticket, callback) {
this.tooltipTitle = dateFormat(params[0].axisValue, 'd mmmm');
this.tooltipEntries = params;
this.$nextTick(() => callback(ticket, this.$refs.tooltip.$el.innerHTML));
return ' ';
},
},
};
</script>
<template>
<div class="vulnerabilities-chart">
<div class="vulnerabilities-chart-wrapper">
<gl-chart :options="options" :width="1240" />
<chart-tooltip v-show="false" ref="tooltip" :title="tooltipTitle" :entries="tooltipEntries" />
</div>
</div>
</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: {
title: {
type: String,
required: false,
default: '',
},
entries: {
type: Array,
required: true,
},
},
};
</script>
<template>
<div class="card">
<div class="card-header">
<strong> {{ title }} </strong>
</div>
<div class="card-body">
<vulnerability-chart-label
v-for="entry in entries"
:key="entry.seriesId + entry.dataIndex"
:name="entry.seriesName"
:value="entry.data[1]"
:color="entry.color"
/>
</div>
</div>
</template>
......@@ -19,6 +19,7 @@ export default () => {
vulnerabilityFeedbackHelpPath: el.dataset.vulnerabilityFeedbackHelpPath,
vulnerabilitiesEndpoint: el.dataset.vulnerabilitiesEndpoint,
vulnerabilitiesCountEndpoint: el.dataset.vulnerabilitiesSummaryEndpoint,
vulnerabilitiesHistoryEndpoint: el.dataset.vulnerabilitiesHistoryEndpoint,
},
});
},
......
......@@ -204,4 +204,38 @@ export const receiveRevertDismissalError = ({ commit }, { flashError }) => {
}
};
export const setVulnerabilitiesHistoryEndpoint = ({ commit }, endpoint) => {
commit(types.SET_VULNERABILITIES_HISTORY_ENDPOINT, endpoint);
};
export const fetchVulnerabilitiesHistory = ({ state, dispatch }) => {
dispatch('requestVulnerabilitiesHistory');
axios({
method: 'GET',
url: state.vulnerabilitiesHistoryEndpoint,
})
.then(response => {
const { data } = response;
dispatch('receiveVulnerabilitiesHistorySuccess', { data });
})
.catch(() => {
dispatch('receiveVulnerabilitiesHistoryError');
});
};
export const requestVulnerabilitiesHistory = ({ commit }) => {
commit(types.REQUEST_VULNERABILITIES_HISTORY);
};
export const receiveVulnerabilitiesHistorySuccess = ({ commit }, { data }) => {
commit(types.RECEIVE_VULNERABILITIES_HISTORY_SUCCESS, data);
};
export const receiveVulnerabilitiesHistoryError = ({ commit }) => {
commit(types.RECEIVE_VULNERABILITIES_HISTORY_ERROR);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
// This is no longer needed after gitlab-ce#52179 is merged
export default () => {};
......@@ -8,6 +8,11 @@ export const REQUEST_VULNERABILITIES_COUNT = 'REQUEST_VULNERABILITIES_COUNT';
export const RECEIVE_VULNERABILITIES_COUNT_SUCCESS = 'RECEIVE_VULNERABILITIES_COUNT_SUCCESS';
export const RECEIVE_VULNERABILITIES_COUNT_ERROR = 'RECEIVE_VULNERABILITIES_COUNT_ERROR';
export const SET_VULNERABILITIES_HISTORY_ENDPOINT = 'SET_VULNERABILITIES_HISTORY_ENDPOINT';
export const REQUEST_VULNERABILITIES_HISTORY = 'REQUEST_VULNERABILITIES_HISTORY';
export const RECEIVE_VULNERABILITIES_HISTORY_SUCCESS = 'RECEIVE_VULNERABILITIES_HISTORY_SUCCESS';
export const RECEIVE_VULNERABILITIES_HISTORY_ERROR = 'RECEIVE_VULNERABILITIES_HISTORY_ERROR';
export const SET_MODAL_DATA = 'SET_MODAL_DATA';
export const REQUEST_CREATE_ISSUE = 'REQUEST_CREATE_ISSUE';
......
......@@ -35,6 +35,21 @@ export default {
state.isLoadingVulnerabilitiesCount = false;
state.errorLoadingVulnerabilitiesCount = true;
},
[types.SET_VULNERABILITIES_HISTORY_ENDPOINT](state, payload) {
state.vulnerabilitiesHistoryEndpoint = payload;
},
[types.REQUEST_VULNERABILITIES_HISTORY](state) {
state.isLoadingVulnerabilitiesHistory = true;
state.errorLoadingVulnerabilitiesHistory = false;
},
[types.RECEIVE_VULNERABILITIES_HISTORY_SUCCESS](state, payload) {
state.isLoadingVulnerabilitiesHistory = false;
state.vulnerabilitiesHistory = payload;
},
[types.RECEIVE_VULNERABILITIES_HISTORY_ERROR](state) {
state.isLoadingVulnerabilitiesHistory = false;
state.errorLoadingVulnerabilitiesHistory = true;
},
[types.SET_MODAL_DATA](state, payload) {
const { vulnerability } = payload;
......
......@@ -3,12 +3,16 @@ import { s__ } from '~/locale';
export default () => ({
isLoadingVulnerabilities: true,
errorLoadingVulnerabilities: false,
vulnerabilities: [],
isLoadingVulnerabilitiesCount: true,
errorLoadingVulnerabilitiesCount: false,
pageInfo: {},
vulnerabilities: [],
vulnerabilitiesCount: {},
isLoadingVulnerabilitiesHistory: true,
errorLoadingVulnerabilitiesHistory: false,
vulnerabilitiesHistory: {},
pageInfo: {},
vulnerabilitiesCountEndpoint: null,
vulnerabilitiesHistoryEndpoint: null,
vulnerabilitiesEndpoint: null,
activeVulnerability: null,
modal: {
......
$trans-white: rgba(255, 255, 255, 0);
.vulnerabilities-chart-wrapper {
-webkit-overflow-scrolling: touch;
overflow: scroll;
}
@media screen and (max-width: 1240px) {
.vulnerabilities-chart {
position: relative;
}
.vulnerabilities-chart::after {
background-image: linear-gradient(to right, $trans-white, $gl-gray-350);
bottom: 0;
content: '';
height: 310px;
position: absolute;
right: -1px;
top: 10px;
width: 32px;
}
}
# frozen_string_literal: true
class Groups::Security::DashboardController < Groups::Security::ApplicationController
layout 'group'
before_action do
push_frontend_feature_flag(:group_security_dashboard_history, group)
end
end
- breadcrumb_title _("Security Dashboard")
- page_title _("Security Dashboard")
- vulnerabilities_history_endpoint = Feature.enabled?(:group_security_dashboard_history, @group) ? history_group_security_vulnerabilities_path(@group) : ''
#js-group-security-dashboard{ data: { vulnerabilities_endpoint: group_security_vulnerabilities_path(@group),
vulnerabilities_summary_endpoint: summary_group_security_vulnerabilities_path(@group),
vulnerabilities_history_endpoint: vulnerabilities_history_endpoint,
vulnerability_feedback_help_path: help_page_path("user/project/merge_requests/index", anchor: "interacting-with-security-reports-ultimate"),
empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'),
dashboard_documentation: help_page_path('user/group/security_dashboard/index') } }
---
title: Adds group security dashboard metrics chart
merge_request: 8631
author:
type: added
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 MockAdapater 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 { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import waitForPromises from 'spec/helpers/wait_for_promises';
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 vm;
beforeEach(() => {
store = createStore();
store.state.vulnerabilities.vulnerabilitiesHistoryEndpoint = vulnerabilitiesHistoryEndpoint;
mock = new MockAdapater(axios);
mock.onGet(vulnerabilitiesHistoryEndpoint).replyOnce(200, mockDataVulnerabilitiesHistory);
vm = mountComponentWithStore(Component, { store });
});
afterEach(() => {
resetStore(store);
vm.$destroy();
mock.restore();
});
it('should render the e-chart instance', done => {
waitForPromises()
.then(() => {
expect(vm.$el.querySelector('[_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 = {
title: 'Tooltip Title',
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 the title', () => {
const header = vm.$el.querySelector('.card-header');
expect(header.textContent).toContain(props.title);
});
it('should render three legends', () => {
const legends = vm.$el.querySelectorAll('.js-chart-label');
expect(legends).toHaveLength(3);
});
});
......@@ -9,6 +9,7 @@ import * as actions from 'ee/security_dashboard/store/modules/vulnerabilities/ac
import mockDataVulnerabilities from './data/mock_data_vulnerabilities.json';
import mockDataVulnerabilitiesCount from './data/mock_data_vulnerabilities_count.json';
import mockDataVulnerabilitiesHistory from './data/mock_data_vulnerabilities_history.json';
describe('vulnerabiliites count actions', () => {
const data = mockDataVulnerabilitiesCount;
......@@ -634,3 +635,130 @@ describe('revert vulnerability dismissal', () => {
});
});
});
describe('vulnerabiliites timeline actions', () => {
const data = mockDataVulnerabilitiesHistory;
describe('setVulnerabilitiesHistoryEndpoint', () => {
it('should commit the correct mutuation', done => {
const state = initialState;
const endpoint = 'fakepath.json';
testAction(
actions.setVulnerabilitiesHistoryEndpoint,
endpoint,
state,
[
{
type: types.SET_VULNERABILITIES_HISTORY_ENDPOINT,
payload: endpoint,
},
],
[],
done,
);
});
});
describe('fetchVulnerabilitesTimeline', () => {
let mock;
const state = initialState;
beforeEach(() => {
state.vulnerabilitiesCountEndpoint = `${TEST_HOST}/vulnerabilitIES_HISTORY.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
beforeEach(() => {
mock.onGet(state.vulnerabilitiesHistoryEndpoint).replyOnce(200, data);
});
it('should dispatch the request and success actions', done => {
testAction(
actions.fetchVulnerabilitiesHistory,
{},
state,
[],
[
{ type: 'requestVulnerabilitiesHistory' },
{
type: 'receiveVulnerabilitiesHistorySuccess',
payload: { data },
},
],
done,
);
});
});
describe('on error', () => {
beforeEach(() => {
mock.onGet(state.vulnerabilitiesHistoryEndpoint).replyOnce(404, {});
});
it('should dispatch the request and error actions', done => {
testAction(
actions.fetchVulnerabilitiesHistory,
{},
state,
[],
[
{ type: 'requestVulnerabilitiesHistory' },
{ type: 'receiveVulnerabilitiesHistoryError' },
],
done,
);
});
});
});
describe('requestVulnerabilitesTimeline', () => {
it('should commit the request mutation', done => {
const state = initialState;
testAction(
actions.requestVulnerabilitiesHistory,
{},
state,
[{ type: types.REQUEST_VULNERABILITIES_HISTORY }],
[],
done,
);
});
});
describe('receiveVulnerabilitesTimelineSuccess', () => {
it('should commit the success mutation', done => {
const state = initialState;
testAction(
actions.receiveVulnerabilitiesHistorySuccess,
{ data },
state,
[{ type: types.RECEIVE_VULNERABILITIES_HISTORY_SUCCESS, payload: data }],
[],
done,
);
});
});
describe('receivetVulnerabilitesTimelineError', () => {
it('should commit the error mutation', done => {
const state = initialState;
testAction(
actions.receiveVulnerabilitiesHistoryError,
{},
state,
[{ type: types.RECEIVE_VULNERABILITIES_HISTORY_ERROR }],
[],
done,
);
});
});
});
......@@ -131,6 +131,66 @@ describe('vulnerabilities module mutations', () => {
});
});
describe('SET_VULNERABILITIES_HISTORY_ENDPOINT', () => {
it('should set `vulnerabilitiesHistoryEndpoint` to `fakepath.json`', () => {
const state = createState();
const endpoint = 'fakepath.json';
mutations[types.SET_VULNERABILITIES_HISTORY_ENDPOINT](state, endpoint);
expect(state.vulnerabilitiesHistoryEndpoint).toEqual(endpoint);
});
});
describe('REQUEST_VULNERABILITIES_HISTORY', () => {
let state;
beforeEach(() => {
state = {
...createState(),
errorLoadingVulnerabilitiesHistory: true,
};
mutations[types.REQUEST_VULNERABILITIES_HISTORY](state);
});
it('should set `isLoadingVulnerabilitiesHistory` to `true`', () => {
expect(state.isLoadingVulnerabilitiesHistory).toBeTruthy();
});
it('should set `errorLoadingVulnerabilitiesHistory` to `false`', () => {
expect(state.errorLoadingVulnerabilitiesHistory).toBeFalsy();
});
});
describe('RECEIVE_VULNERABILITIES_HISTORY_SUCCESS', () => {
let payload;
let state;
beforeEach(() => {
payload = mockData;
state = createState();
mutations[types.RECEIVE_VULNERABILITIES_HISTORY_SUCCESS](state, payload);
});
it('should set `isLoadingVulnerabilitiesHistory` to `false`', () => {
expect(state.isLoadingVulnerabilitiesHistory).toBeFalsy();
});
it('should set `vulnerabilitiesHistory`', () => {
expect(state.vulnerabilitiesHistory).toBe(payload);
});
});
describe('RECEIVE_VULNERABILITIES_HISTORY_ERROR', () => {
it('should set `isLoadingVulnerabilitiesHistory` to `false`', () => {
const state = createState();
mutations[types.RECEIVE_VULNERABILITIES_HISTORY_ERROR](state);
expect(state.isLoadingVulnerabilitiesHistory).toBeFalsy();
});
});
describe('SET_MODAL_DATA', () => {
describe('with all the data', () => {
const vulnerability = mockData[0];
......
......@@ -9349,6 +9349,9 @@ msgstr ""
msgid "VisibilityLevel|Unknown"
msgstr ""
msgid "Vulnerability Chart"
msgstr ""
msgid "Vulnerability List"
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