Commit e6b6d2ab authored by Mark Florian's avatar Mark Florian Committed by Fatih Acet

Render Threat Monitoring statistics

Part of [WAF statistics reporting][1].

This adds a WAF statistics summary component and a chart for viewing the
history of WAF traffic.

In addition, this:

- Updates the expected response structure from the WAF statistics
  endpoint, according to the latest from the ongoing [backend MR][2].
- Removes the link from the popover per [this discussion][3].
- Stops pushing the `threat_monitoring` feature flag to the frontend,
  since it wasn't being read anyway, and the controller itself is
  guarded behind the flag, which is sufficient.

[1]: https://gitlab.com/gitlab-org/gitlab/issues/14707
[2]: https://gitlab.com/gitlab-org/gitlab/merge_requests/19789
[3]: https://gitlab.com/gitlab-org/gitlab/issues/14707/designs/ee_14707-waf-statistics-reporting_popover.png#note_260158615
parent d87f2e8f
<script>
import { mapActions } from 'vuex';
import { mapActions, mapState } from 'vuex';
import { GlAlert, GlEmptyState, GlIcon, GlLink, GlPopover } from '@gitlab/ui';
import { s__ } from '~/locale';
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 {
name: 'ThreatMonitoring',
......@@ -13,6 +16,9 @@ export default {
GlLink,
GlPopover,
ThreatMonitoringFilters,
WafLoadingSkeleton,
WafStatisticsSummary,
WafStatisticsHistory,
},
props: {
defaultEnvironmentId: {
......@@ -39,6 +45,9 @@ export default {
isWafMaybeSetUp: this.isValidEnvironmentId(this.defaultEnvironmentId),
};
},
computed: {
...mapState('threatMonitoring', ['isLoadingWafStatistics']),
},
created() {
if (this.isWafMaybeSetUp) {
this.setCurrentEnvironmentId(this.defaultEnvironmentId);
......@@ -66,7 +75,7 @@ export default {
malicious traffic is trying to access your app. The docs link is also
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>
......@@ -102,18 +111,19 @@ export default {
>
<gl-icon name="question" />
</gl-link>
<gl-popover
:target="() => $refs.helpLink"
triggers="hover focus"
:title="$options.helpPopoverTitle"
>
<gl-link :href="documentationPath">{{
s__('ThreatMonitoring|View WAF documentation')
}}</gl-link>
<gl-popover :target="() => $refs.helpLink" triggers="hover focus">
{{ $options.helpPopoverText }}
</gl-popover>
</h2>
</header>
<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>
</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 }) => {
};
const getAllEnvironments = (url, page = 1) =>
axios({
method: 'GET',
url,
params: {
per_page: 100,
page,
},
}).then(({ headers, data }) => {
const nextPage = headers && headers['x-next-page'];
return nextPage
? // eslint-disable-next-line promise/no-nesting
getAllEnvironments(url, nextPage).then(environments => [
...data.environments,
...environments,
])
: data.environments;
});
axios
.get(url, {
params: {
per_page: 100,
page,
},
})
.then(({ headers, data }) => {
const nextPage = headers && headers['x-next-page'];
return nextPage
? // eslint-disable-next-line promise/no-nesting
getAllEnvironments(url, nextPage).then(environments => [
...data.environments,
...environments,
])
: data.environments;
});
export const fetchEnvironments = ({ state, dispatch }) => {
if (!state.environmentsEndpoint) {
......
......@@ -7,13 +7,12 @@ export default () => ({
wafStatisticsEndpoint: '',
wafStatistics: {
totalTraffic: 0,
trafficAllowed: 0,
trafficBlocked: 0,
anomalousTraffic: 0,
history: {
allowed: [],
blocked: [],
nominal: [],
anomalous: [],
},
},
isWafStatisticsLoading: false,
isLoadingWafStatistics: false,
errorLoadingWafStatistics: false,
});
......@@ -3,9 +3,5 @@
module Projects
class ThreatMonitoringController < Projects::ApplicationController
before_action :authorize_read_threat_monitoring!
before_action only: [:show] do
push_frontend_feature_flag(:threat_monitoring)
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';
import createStore from 'ee/threat_monitoring/store';
import ThreatMonitoringApp from 'ee/threat_monitoring/components/app.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 defaultEnvironmentId = 3;
......@@ -34,6 +37,9 @@ describe('ThreatMonitoringApp component', () => {
};
const findAlert = () => wrapper.find(GlAlert);
const findWafLoadingSkeleton = () => wrapper.find(WafLoadingSkeleton);
const findWafStatisticsHistory = () => wrapper.find(WafStatisticsHistory);
const findWafStatisticsSummary = () => wrapper.find(WafStatisticsSummary);
afterEach(() => {
wrapper.destroy();
......@@ -89,6 +95,15 @@ describe('ThreatMonitoringApp component', () => {
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', () => {
beforeEach(() => {
findAlert().vm.$emit('dismiss');
......@@ -99,5 +114,20 @@ describe('ThreatMonitoringApp component', () => {
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 = {
};
export const mockWafStatisticsResponse = {
total_traffic: 31500,
traffic_allowed: 0.11,
traffic_blocked: 0.89,
total_traffic: 2703,
anomalous_traffic: 0.03,
history: {
allowed: [['<timestamp>', 25], ['<timestamp>', 30]],
blocked: [['<timestamp>', 15], ['<timestamp>', 20]],
nominal: [['2019-12-04T00:00:00.000Z', 56], ['2019-12-05T00:00:00.000Z', 2647]],
anomalous: [['2019-12-04T00:00:00.000Z', 1], ['2019-12-05T00:00:00.000Z', 83]],
},
};
......@@ -102,8 +102,7 @@ describe('Threat Monitoring mutations', () => {
it('sets wafStatistics according to the payload', () => {
expect(state.wafStatistics).toEqual({
totalTraffic: mockWafStatisticsResponse.total_traffic,
trafficAllowed: mockWafStatisticsResponse.traffic_allowed,
trafficBlocked: mockWafStatisticsResponse.traffic_blocked,
anomalousTraffic: mockWafStatisticsResponse.anomalous_traffic,
history: mockWafStatisticsResponse.history,
});
});
......
......@@ -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."
msgstr ""
msgid "ThreatMonitoring|Anomalous Requests"
msgstr ""
msgid "ThreatMonitoring|At this time, threat monitoring only supports WAF data."
msgstr ""
msgid "ThreatMonitoring|Environment"
msgstr ""
msgid "ThreatMonitoring|Requests"
msgstr ""
msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics"
msgstr ""
......@@ -18716,7 +18722,10 @@ msgstr ""
msgid "ThreatMonitoring|Threat Monitoring help page link"
msgstr ""
msgid "ThreatMonitoring|View WAF documentation"
msgid "ThreatMonitoring|Time"
msgstr ""
msgid "ThreatMonitoring|Total Requests"
msgstr ""
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