Commit 88f45b39 authored by Fatih Acet's avatar Fatih Acet

Merge branch '14707-add-threat-monitoring-charts-ee' into 'master'

Render WAF statistics

See merge request gitlab-org/gitlab!21916
parents d87f2e8f e6b6d2ab
<script> <script>
import { mapActions } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { GlAlert, GlEmptyState, GlIcon, GlLink, GlPopover } from '@gitlab/ui'; import { GlAlert, GlEmptyState, GlIcon, GlLink, GlPopover } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ThreatMonitoringFilters from './threat_monitoring_filters.vue'; import ThreatMonitoringFilters from './threat_monitoring_filters.vue';
import WafLoadingSkeleton from './waf_loading_skeleton.vue';
import WafStatisticsSummary from './waf_statistics_summary.vue';
import WafStatisticsHistory from './waf_statistics_history.vue';
export default { export default {
name: 'ThreatMonitoring', name: 'ThreatMonitoring',
...@@ -13,6 +16,9 @@ export default { ...@@ -13,6 +16,9 @@ export default {
GlLink, GlLink,
GlPopover, GlPopover,
ThreatMonitoringFilters, ThreatMonitoringFilters,
WafLoadingSkeleton,
WafStatisticsSummary,
WafStatisticsHistory,
}, },
props: { props: {
defaultEnvironmentId: { defaultEnvironmentId: {
...@@ -39,6 +45,9 @@ export default { ...@@ -39,6 +45,9 @@ export default {
isWafMaybeSetUp: this.isValidEnvironmentId(this.defaultEnvironmentId), isWafMaybeSetUp: this.isValidEnvironmentId(this.defaultEnvironmentId),
}; };
}, },
computed: {
...mapState('threatMonitoring', ['isLoadingWafStatistics']),
},
created() { created() {
if (this.isWafMaybeSetUp) { if (this.isWafMaybeSetUp) {
this.setCurrentEnvironmentId(this.defaultEnvironmentId); this.setCurrentEnvironmentId(this.defaultEnvironmentId);
...@@ -66,7 +75,7 @@ export default { ...@@ -66,7 +75,7 @@ export default {
malicious traffic is trying to access your app. The docs link is also malicious traffic is trying to access your app. The docs link is also
accessible by clicking the "?" icon next to the title below.`, accessible by clicking the "?" icon next to the title below.`,
), ),
helpPopoverTitle: s__('ThreatMonitoring|At this time, threat monitoring only supports WAF data.'), helpPopoverText: s__('ThreatMonitoring|At this time, threat monitoring only supports WAF data.'),
}; };
</script> </script>
...@@ -102,18 +111,19 @@ export default { ...@@ -102,18 +111,19 @@ export default {
> >
<gl-icon name="question" /> <gl-icon name="question" />
</gl-link> </gl-link>
<gl-popover <gl-popover :target="() => $refs.helpLink" triggers="hover focus">
:target="() => $refs.helpLink" {{ $options.helpPopoverText }}
triggers="hover focus"
:title="$options.helpPopoverTitle"
>
<gl-link :href="documentationPath">{{
s__('ThreatMonitoring|View WAF documentation')
}}</gl-link>
</gl-popover> </gl-popover>
</h2> </h2>
</header> </header>
<threat-monitoring-filters /> <threat-monitoring-filters />
<waf-loading-skeleton v-if="isLoadingWafStatistics" class="mt-3" />
<template v-else>
<waf-statistics-summary class="mt-3" />
<waf-statistics-history class="mt-3" />
</template>
</section> </section>
</template> </template>
import { gray700, orange400 } from '@gitlab/ui/scss_to_js/scss_variables';
import { s__ } from '~/locale';
export const TOTAL_REQUESTS = s__('ThreatMonitoring|Total Requests');
export const ANOMALOUS_REQUESTS = s__('ThreatMonitoring|Anomalous Requests');
export const TIME = s__('ThreatMonitoring|Time');
export const REQUESTS = s__('ThreatMonitoring|Requests');
export const COLORS = {
nominal: gray700,
anomalous: orange400,
};
// Reuse existing definitions rather than defining them again here,
// otherwise they could get out of sync.
// See https://gitlab.com/gitlab-org/gitlab-ui/issues/554.
export { dateFormats as DATE_FORMATS } from 'ee/analytics/shared/constants';
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
export default {
components: {
GlSkeletonLoader,
},
};
</script>
<template>
<gl-skeleton-loader :width="640" :height="276">
<rect x="0" y="0" width="150" height="60" rx="2" />
<rect x="166" y="0" width="150" height="60" rx="2" />
<rect x="0" y="76" width="640" height="200" rx="2" />
</gl-skeleton-loader>
</template>
<script>
import _ from 'underscore';
import dateFormat from 'dateformat';
import { mapState } from 'vuex';
import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import {
ANOMALOUS_REQUESTS,
COLORS,
DATE_FORMATS,
REQUESTS,
TIME,
TOTAL_REQUESTS,
} from './constants';
export default {
name: 'WafStatisticsHistoryChart',
components: {
GlAreaChart,
},
directives: {
GlResizeObserverDirective,
},
data() {
return {
chartInstance: null,
tooltipSeriesData: null,
tooltipTitle: '',
};
},
computed: {
...mapState('threatMonitoring', ['wafStatistics']),
chartData() {
const { anomalous, nominal } = this.wafStatistics.history;
const anomalousStyle = { color: COLORS.anomalous };
const nominalStyle = { color: COLORS.nominal };
return [
{
name: ANOMALOUS_REQUESTS,
data: anomalous,
areaStyle: anomalousStyle,
lineStyle: anomalousStyle,
itemStyle: anomalousStyle,
},
{
name: TOTAL_REQUESTS,
data: nominal,
areaStyle: nominalStyle,
lineStyle: nominalStyle,
itemStyle: nominalStyle,
},
];
},
},
chartOptions: {
xAxis: {
name: TIME,
type: 'time',
axisLabel: {
formatter: value => dateFormat(value, DATE_FORMATS.defaultDate),
},
},
yAxis: {
name: REQUESTS,
},
},
methods: {
formatTooltipText({ seriesData }) {
this.tooltipSeriesData = seriesData;
const [timestamp] = seriesData[0].value;
this.tooltipTitle = dateFormat(timestamp, DATE_FORMATS.defaultDateTime);
},
onChartCreated(chartInstance) {
this.chartInstance = chartInstance;
},
onResize() {
if (_.isFunction(this.chartInstance?.resize)) {
this.chartInstance.resize();
}
},
},
};
</script>
<template>
<gl-area-chart
v-gl-resize-observer-directive="onResize"
:data="chartData"
:option="$options.chartOptions"
:include-legend-avg-max="false"
:format-tooltip-text="formatTooltipText"
@created="onChartCreated"
>
<template #tooltipTitle>
<div>{{ tooltipTitle }} ({{ $options.chartOptions.xAxis.name }})</div>
</template>
<template #tooltipContent>
<div v-for="series in tooltipSeriesData" :key="series.seriesName" class="d-flex">
<div class="flex-grow-1 mr-5">{{ series.seriesName }}</div>
<div class="font-weight-bold">{{ series.value[1] }}</div>
</div>
</template>
</gl-area-chart>
</template>
<script>
import { mapState } from 'vuex';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { engineeringNotation } from '@gitlab/ui/src/utils/number_utils';
import { ANOMALOUS_REQUESTS, TOTAL_REQUESTS } from './constants';
export default {
name: 'WafStatisticsSummary',
components: {
GlSingleStat,
},
computed: {
...mapState('threatMonitoring', ['wafStatistics']),
statistics() {
return [
{
key: 'anomalousTraffic',
title: ANOMALOUS_REQUESTS,
value: `${Math.round(this.wafStatistics.anomalousTraffic * 100)}%`,
variant: 'warning',
},
{
key: 'totalTraffic',
title: TOTAL_REQUESTS,
value: engineeringNotation(this.wafStatistics.totalTraffic),
variant: 'secondary',
},
];
},
},
};
</script>
<template>
<div class="row">
<gl-single-stat
v-for="stat in statistics"
:key="stat.key"
class="col-sm-6 col-md-4 col-lg-3"
v-bind="stat"
/>
</div>
</template>
...@@ -14,23 +14,23 @@ export const receiveEnvironmentsError = ({ commit }) => { ...@@ -14,23 +14,23 @@ export const receiveEnvironmentsError = ({ commit }) => {
}; };
const getAllEnvironments = (url, page = 1) => const getAllEnvironments = (url, page = 1) =>
axios({ axios
method: 'GET', .get(url, {
url, params: {
params: { per_page: 100,
per_page: 100, page,
page, },
}, })
}).then(({ headers, data }) => { .then(({ headers, data }) => {
const nextPage = headers && headers['x-next-page']; const nextPage = headers && headers['x-next-page'];
return nextPage return nextPage
? // eslint-disable-next-line promise/no-nesting ? // eslint-disable-next-line promise/no-nesting
getAllEnvironments(url, nextPage).then(environments => [ getAllEnvironments(url, nextPage).then(environments => [
...data.environments, ...data.environments,
...environments, ...environments,
]) ])
: data.environments; : data.environments;
}); });
export const fetchEnvironments = ({ state, dispatch }) => { export const fetchEnvironments = ({ state, dispatch }) => {
if (!state.environmentsEndpoint) { if (!state.environmentsEndpoint) {
......
...@@ -7,13 +7,12 @@ export default () => ({ ...@@ -7,13 +7,12 @@ export default () => ({
wafStatisticsEndpoint: '', wafStatisticsEndpoint: '',
wafStatistics: { wafStatistics: {
totalTraffic: 0, totalTraffic: 0,
trafficAllowed: 0, anomalousTraffic: 0,
trafficBlocked: 0,
history: { history: {
allowed: [], nominal: [],
blocked: [], anomalous: [],
}, },
}, },
isWafStatisticsLoading: false, isLoadingWafStatistics: false,
errorLoadingWafStatistics: false, errorLoadingWafStatistics: false,
}); });
...@@ -3,9 +3,5 @@ ...@@ -3,9 +3,5 @@
module Projects module Projects
class ThreatMonitoringController < Projects::ApplicationController class ThreatMonitoringController < Projects::ApplicationController
before_action :authorize_read_threat_monitoring! before_action :authorize_read_threat_monitoring!
before_action only: [:show] do
push_frontend_feature_flag(:threat_monitoring)
end
end end
end end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`WafStatisticsHistory component chart tooltip renders the title and series data correctly 1`] = `
<div
data="[object Object],[object Object]"
option="[object Object]"
>
<div>
Dec 4, 2019 12:00am (Time)
</div>
<div
class="d-flex"
>
<div
class="flex-grow-1 mr-5"
>
Anomalous Requests
</div>
<div
class="font-weight-bold"
>
1
</div>
</div>
<div
class="d-flex"
>
<div
class="flex-grow-1 mr-5"
>
Total Requests
</div>
<div
class="font-weight-bold"
>
56
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`WafStatisticsSummary component renders the anomalous traffic percentage 1`] = `
<glsinglestat-stub
class="col-sm-6 col-md-4 col-lg-3"
title="Anomalous Requests"
value="3%"
variant="warning"
/>
`;
exports[`WafStatisticsSummary component renders the nominal traffic count 1`] = `
<glsinglestat-stub
class="col-sm-6 col-md-4 col-lg-3"
title="Total Requests"
value="2.7k"
variant="secondary"
/>
`;
...@@ -4,6 +4,9 @@ import { TEST_HOST } from 'helpers/test_constants'; ...@@ -4,6 +4,9 @@ import { TEST_HOST } from 'helpers/test_constants';
import createStore from 'ee/threat_monitoring/store'; import createStore from 'ee/threat_monitoring/store';
import ThreatMonitoringApp from 'ee/threat_monitoring/components/app.vue'; import ThreatMonitoringApp from 'ee/threat_monitoring/components/app.vue';
import ThreatMonitoringFilters from 'ee/threat_monitoring/components/threat_monitoring_filters.vue'; import ThreatMonitoringFilters from 'ee/threat_monitoring/components/threat_monitoring_filters.vue';
import WafLoadingSkeleton from 'ee/threat_monitoring/components/waf_loading_skeleton.vue';
import WafStatisticsHistory from 'ee/threat_monitoring/components/waf_statistics_history.vue';
import WafStatisticsSummary from 'ee/threat_monitoring/components/waf_statistics_summary.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
const defaultEnvironmentId = 3; const defaultEnvironmentId = 3;
...@@ -34,6 +37,9 @@ describe('ThreatMonitoringApp component', () => { ...@@ -34,6 +37,9 @@ describe('ThreatMonitoringApp component', () => {
}; };
const findAlert = () => wrapper.find(GlAlert); const findAlert = () => wrapper.find(GlAlert);
const findWafLoadingSkeleton = () => wrapper.find(WafLoadingSkeleton);
const findWafStatisticsHistory = () => wrapper.find(WafStatisticsHistory);
const findWafStatisticsSummary = () => wrapper.find(WafStatisticsSummary);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -89,6 +95,15 @@ describe('ThreatMonitoringApp component', () => { ...@@ -89,6 +95,15 @@ describe('ThreatMonitoringApp component', () => {
expect(wrapper.find(ThreatMonitoringFilters).exists()).toBe(true); expect(wrapper.find(ThreatMonitoringFilters).exists()).toBe(true);
}); });
it('shows the summary and history statistics', () => {
expect(findWafStatisticsSummary().exists()).toBe(true);
expect(findWafStatisticsHistory().exists()).toBe(true);
});
it('does not show the loading skeleton', () => {
expect(findWafLoadingSkeleton().exists()).toBe(false);
});
describe('dismissing the alert', () => { describe('dismissing the alert', () => {
beforeEach(() => { beforeEach(() => {
findAlert().vm.$emit('dismiss'); findAlert().vm.$emit('dismiss');
...@@ -99,5 +114,20 @@ describe('ThreatMonitoringApp component', () => { ...@@ -99,5 +114,20 @@ describe('ThreatMonitoringApp component', () => {
expect(findAlert().exists()).toBe(false); expect(findAlert().exists()).toBe(false);
}); });
}); });
describe('given the statistics are loading', () => {
beforeEach(() => {
store.state.threatMonitoring.isLoadingWafStatistics = true;
});
it('does not show the summary or history statistics', () => {
expect(findWafStatisticsSummary().exists()).toBe(false);
expect(findWafStatisticsHistory().exists()).toBe(false);
});
it('displays the loading skeleton', () => {
expect(findWafLoadingSkeleton().exists()).toBe(true);
});
});
}); });
}); });
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import createStore from 'ee/threat_monitoring/store';
import WafStatisticsHistory from 'ee/threat_monitoring/components/waf_statistics_history.vue';
import { TOTAL_REQUESTS, ANOMALOUS_REQUESTS } from 'ee/threat_monitoring/components/constants';
import { mockWafStatisticsResponse } from '../mock_data';
let resizeCallback = null;
const MockResizeObserverDirective = {
bind(el, { value }) {
resizeCallback = value;
},
simulateResize() {
// Let tests fail if callback throws or isn't callable
resizeCallback();
},
unbind() {
resizeCallback = null;
},
};
const localVue = createLocalVue();
localVue.directive('gl-resize-observer-directive', MockResizeObserverDirective);
describe('WafStatisticsHistory component', () => {
let store;
let wrapper;
const factory = ({ state, options } = {}) => {
store = createStore();
Object.assign(store.state.threatMonitoring, state);
wrapper = shallowMount(WafStatisticsHistory, {
localVue,
store,
sync: false,
...options,
});
};
afterEach(() => {
wrapper.destroy();
});
const findChart = () => wrapper.find(GlAreaChart);
describe('the data passed to the chart', () => {
beforeEach(() => {
factory({
state: {
wafStatistics: {
history: mockWafStatisticsResponse.history,
},
},
});
});
it('is structured correctly', () => {
const { nominal, anomalous } = mockWafStatisticsResponse.history;
expect(findChart().props('data')).toMatchObject([{ data: anomalous }, { data: nominal }]);
});
});
describe('given the component needs to resize', () => {
let mockChartInstance;
beforeEach(() => {
factory();
mockChartInstance = {
resize: jest.fn(),
};
});
describe('given the chart has not emitted the created event', () => {
beforeEach(() => {
MockResizeObserverDirective.simulateResize();
});
it('there is no attempt to resize the chart instance', () => {
expect(mockChartInstance.resize).not.toHaveBeenCalled();
});
});
describe('given the chart has emitted the created event', () => {
beforeEach(() => {
findChart().vm.$emit('created', mockChartInstance);
MockResizeObserverDirective.simulateResize();
});
it('the chart instance is resized', () => {
expect(mockChartInstance.resize).toHaveBeenCalledTimes(1);
});
});
});
describe('chart tooltip', () => {
beforeEach(() => {
const mockTotalSeriesDatum = mockWafStatisticsResponse.history.nominal[0];
const mockAnomalousSeriesDatum = mockWafStatisticsResponse.history.anomalous[0];
const mockParams = {
seriesData: [
{
seriesName: ANOMALOUS_REQUESTS,
value: mockAnomalousSeriesDatum,
},
{
seriesName: TOTAL_REQUESTS,
value: mockTotalSeriesDatum,
},
],
};
factory({
options: {
stubs: {
GlAreaChart: {
props: ['formatTooltipText'],
mounted() {
this.formatTooltipText(mockParams);
},
template: `
<div>
<slot name="tooltipTitle"></slot>
<slot name="tooltipContent"></slot>
</div>`,
},
},
},
});
return wrapper.vm.$nextTick();
});
it('renders the title and series data correctly', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import createStore from 'ee/threat_monitoring/store';
import WafStatisticsSummary from 'ee/threat_monitoring/components/waf_statistics_summary.vue';
import { mockWafStatisticsResponse } from '../mock_data';
const localVue = createLocalVue();
describe('WafStatisticsSummary component', () => {
let store;
let wrapper;
const factory = state => {
store = createStore();
Object.assign(store.state.threatMonitoring, state);
wrapper = shallowMount(WafStatisticsSummary, {
localVue,
store,
sync: false,
});
};
const findAnomalousStat = () => wrapper.findAll(GlSingleStat).at(0);
const findNominalStat = () => wrapper.findAll(GlSingleStat).at(1);
beforeEach(() => {
factory({
wafStatistics: {
totalTraffic: mockWafStatisticsResponse.total_traffic,
anomalousTraffic: mockWafStatisticsResponse.anomalous_traffic,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders the anomalous traffic percentage', () => {
expect(findAnomalousStat().element).toMatchSnapshot();
});
it('renders the nominal traffic count', () => {
expect(findNominalStat().element).toMatchSnapshot();
});
});
...@@ -16,11 +16,10 @@ export const mockEnvironmentsResponse = { ...@@ -16,11 +16,10 @@ export const mockEnvironmentsResponse = {
}; };
export const mockWafStatisticsResponse = { export const mockWafStatisticsResponse = {
total_traffic: 31500, total_traffic: 2703,
traffic_allowed: 0.11, anomalous_traffic: 0.03,
traffic_blocked: 0.89,
history: { history: {
allowed: [['<timestamp>', 25], ['<timestamp>', 30]], nominal: [['2019-12-04T00:00:00.000Z', 56], ['2019-12-05T00:00:00.000Z', 2647]],
blocked: [['<timestamp>', 15], ['<timestamp>', 20]], anomalous: [['2019-12-04T00:00:00.000Z', 1], ['2019-12-05T00:00:00.000Z', 83]],
}, },
}; };
...@@ -102,8 +102,7 @@ describe('Threat Monitoring mutations', () => { ...@@ -102,8 +102,7 @@ describe('Threat Monitoring mutations', () => {
it('sets wafStatistics according to the payload', () => { it('sets wafStatistics according to the payload', () => {
expect(state.wafStatistics).toEqual({ expect(state.wafStatistics).toEqual({
totalTraffic: mockWafStatisticsResponse.total_traffic, totalTraffic: mockWafStatisticsResponse.total_traffic,
trafficAllowed: mockWafStatisticsResponse.traffic_allowed, anomalousTraffic: mockWafStatisticsResponse.anomalous_traffic,
trafficBlocked: mockWafStatisticsResponse.traffic_blocked,
history: mockWafStatisticsResponse.history, history: mockWafStatisticsResponse.history,
}); });
}); });
......
...@@ -18695,12 +18695,18 @@ msgstr "" ...@@ -18695,12 +18695,18 @@ msgstr ""
msgid "ThreatMonitoring|A Web Application Firewall (WAF) provides monitoring and rules to protect production applications. GitLab adds the modsecurity WAF plug-in when you install the Ingress app in your Kubernetes cluster." msgid "ThreatMonitoring|A Web Application Firewall (WAF) provides monitoring and rules to protect production applications. GitLab adds the modsecurity WAF plug-in when you install the Ingress app in your Kubernetes cluster."
msgstr "" msgstr ""
msgid "ThreatMonitoring|Anomalous Requests"
msgstr ""
msgid "ThreatMonitoring|At this time, threat monitoring only supports WAF data." msgid "ThreatMonitoring|At this time, threat monitoring only supports WAF data."
msgstr "" msgstr ""
msgid "ThreatMonitoring|Environment" msgid "ThreatMonitoring|Environment"
msgstr "" msgstr ""
msgid "ThreatMonitoring|Requests"
msgstr ""
msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics" msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics"
msgstr "" msgstr ""
...@@ -18716,7 +18722,10 @@ msgstr "" ...@@ -18716,7 +18722,10 @@ msgstr ""
msgid "ThreatMonitoring|Threat Monitoring help page link" msgid "ThreatMonitoring|Threat Monitoring help page link"
msgstr "" msgstr ""
msgid "ThreatMonitoring|View WAF documentation" msgid "ThreatMonitoring|Time"
msgstr ""
msgid "ThreatMonitoring|Total Requests"
msgstr "" msgstr ""
msgid "ThreatMonitoring|Web Application Firewall not enabled" msgid "ThreatMonitoring|Web Application Firewall not enabled"
......
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