Commit 6aa6ef27 authored by Alexander Turinske's avatar Alexander Turinske Committed by Natalia Tepluhina

Create threat monitoring alerts table

- display alerts via table
- allow for status change
parent c3ceeab9
<script>
import AlertsList from './alerts_list.vue';
export default {
name: 'ThreatAlerts',
components: {
AlertsList,
},
};
</script>
<template>
<div></div>
<alerts-list />
</template>
<script>
import { GlAlert, GlLoadingIcon, GlTable, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
// TODO once backend is settled, update by either abstracting this out to app/assets/javascripts/graphql_shared or create new, modified query in #287757
import getAlerts from '~/alert_management/graphql/queries/get_alerts.query.graphql';
import { FIELDS, MESSAGES, STATUSES } from './constants';
export default {
i18n: {
FIELDS,
MESSAGES,
STATUSES,
},
components: {
GlAlert,
GlLoadingIcon,
GlTable,
TimeAgo,
GlLink,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['documentationPath', 'projectPath'],
apollo: {
alerts: {
query: getAlerts,
variables() {
return {
projectPath: this.projectPath,
sort: this.sort,
};
},
update: ({ project }) => ({
list: project?.alertManagementAlerts.nodes || [],
pageInfo: project?.alertManagementAlerts.pageInfo || {},
}),
error() {
this.errored = true;
},
},
},
data() {
return {
alerts: {},
errored: false,
isErrorAlertDismissed: false,
sort: 'STARTED_AT_DESC',
sortBy: 'startedAt',
sortDesc: true,
sortDirection: 'desc',
};
},
computed: {
isEmpty() {
return !this.alerts?.list?.length;
},
loading() {
return this.$apollo.queries.alerts.loading;
},
showNoAlertsMsg() {
return this.isEmpty && !this.loading && !this.errored && !this.isErrorAlertDismissed;
},
},
methods: {
errorAlertDismissed() {
this.errored = false;
this.isErrorAlertDismissed = true;
},
fetchSortedData({ sortBy, sortDesc }) {
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
this.sort = `${sortingColumn}_${sortingDirection}`;
},
},
};
</script>
<template>
<div>
<gl-alert v-if="showNoAlertsMsg" data-testid="threat-alerts-unconfigured" :dismissible="false">
<gl-sprintf :message="$options.i18n.MESSAGES.CONFIGURE">
<template #link="{ content }">
<gl-link class="gl-display-inline-block" :href="documentationPath" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<gl-alert
v-if="errored"
variant="danger"
data-testid="threat-alerts-error"
@dismiss="errorAlertDismissed"
>
{{ $options.i18n.MESSAGES.ERROR }}
</gl-alert>
<gl-table
class="alert-management-table"
:items="alerts ? alerts.list : []"
:fields="$options.i18n.FIELDS"
:show-empty="true"
:busy="loading"
stacked="md"
:no-local-sorting="true"
:sort-direction="sortDirection"
:sort-desc.sync="sortDesc"
:sort-by.sync="sortBy"
sort-icon-left
responsive
@sort-changed="fetchSortedData"
>
<template #cell(startedAt)="{ item }">
<time-ago
v-if="item.startedAt"
:time="item.startedAt"
data-testid="threat-alerts-started-at"
/>
</template>
<template #cell(alertLabel)="{ item }">
<div
class="gl-word-break-all"
:title="`${item.iid} - ${item.title}`"
data-testid="threat-alerts-id"
>
{{ item.title }}
</div>
</template>
<template #cell(status)="{ item }">
<div data-testid="threat-alerts-status">
{{ $options.i18n.STATUSES[item.status] }}
</div>
</template>
<template #empty>
<div data-testid="threat-alerts-empty-state">
{{ $options.i18n.MESSAGES.NO_ALERTS }}
</div>
</template>
<template #table-busy>
<gl-loading-icon
size="lg"
color="dark"
class="gl-mt-3"
data-testid="threat-alerts-busy-state"
/>
</template>
</gl-table>
</div>
</template>
import { s__ } from '~/locale';
export const MESSAGES = {
CONFIGURE: s__(
'ThreatMonitoring|No alerts available to display. See %{linkStart}enabling threat alerts%{linkEnd} for more information on adding alerts to the list.',
),
ERROR: s__(
"ThreatMonitoring|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
),
NO_ALERTS: s__('ThreatMonitoring|No alerts to display.'),
};
export const STATUSES = {
TRIGGERED: s__('ThreatMonitoring|Unreviewed'),
ACKNOWLEDGED: s__('ThreatMonitoring|In review'),
RESOLVED: s__('ThreatMonitoring|Resolved'),
IGNORED: s__('ThreatMonitoring|Dismissed'),
};
export const FIELDS = [
{
key: 'startedAt',
label: s__('ThreatMonitoring|Date and time'),
thAttr: { 'data-testid': 'threat-alerts-started-at-header' },
thClass: `gl-w-15p`,
tdClass: `gl-pl-6!`,
sortable: true,
},
{
key: 'alertLabel',
label: s__('ThreatMonitoring|Name'),
thClass: `gl-pointer-events-none`,
},
{
key: 'status',
label: s__('ThreatMonitoring|Status'),
thAttr: { 'data-testid': 'threat-alerts-status-header' },
thClass: `gl-w-15p`,
tdClass: `gl-pl-6!`,
sortable: true,
},
];
......@@ -3,7 +3,7 @@ import { mapActions } from 'vuex';
import { GlAlert, GlEmptyState, GlIcon, GlLink, GlPopover, GlTabs, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import ThreatAlerts from './threat_alerts.vue';
import Alerts from './alerts/alerts.vue';
import ThreatMonitoringFilters from './threat_monitoring_filters.vue';
import ThreatMonitoringSection from './threat_monitoring_section.vue';
import NetworkPolicyList from './network_policy_list.vue';
......@@ -18,11 +18,12 @@ export default {
GlPopover,
GlTabs,
GlTab,
ThreatAlerts,
Alerts,
ThreatMonitoringFilters,
ThreatMonitoringSection,
NetworkPolicyList,
},
inject: ['documentationPath'],
props: {
defaultEnvironmentId: {
type: Number,
......@@ -44,10 +45,6 @@ export default {
type: String,
required: true,
},
documentationPath: {
type: String,
required: true,
},
showUserCallout: {
type: Boolean,
required: true,
......@@ -173,7 +170,7 @@ export default {
:title="s__('ThreatMonitoring|Alerts')"
data-testid="threat-monitoring-alerts-tab"
>
<threat-alerts />
<alerts />
</gl-tab>
<gl-tab ref="networkPolicyTab" :title="s__('ThreatMonitoring|Policies')">
<network-policy-list
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import ThreatMonitoringApp from './components/app.vue';
import createStore from './store';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
const el = document.querySelector('#js-threat-monitoring-app');
const {
......@@ -17,6 +25,7 @@ export default () => {
newPolicyPath,
documentationPath,
defaultEnvironmentId,
projectPath,
showUserCallout,
userCalloutId,
userCalloutsPath,
......@@ -33,7 +42,12 @@ export default () => {
});
return new Vue({
apolloProvider,
el,
provide: {
documentationPath,
projectPath,
},
store,
render(createElement) {
return createElement(ThreatMonitoringApp, {
......@@ -42,7 +56,6 @@ export default () => {
emptyStateSvgPath,
wafNoDataSvgPath,
networkPolicyNoDataSvgPath,
documentationPath,
defaultEnvironmentId: parseInt(defaultEnvironmentId, 10),
showUserCallout: parseBoolean(showUserCallout),
userCalloutId,
......
......@@ -14,6 +14,7 @@
network_policies_endpoint: project_security_network_policies_path(@project),
new_policy_path: new_project_threat_monitoring_policy_path(@project),
default_environment_id: default_environment_id,
project_path: @project.full_path,
user_callouts_path: user_callouts_path,
user_callout_id: UserCalloutsHelper::THREAT_MONITORING_INFO,
show_user_callout: false,
......
import { GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import AlertsList from 'ee/threat_monitoring/components/alerts/alerts_list.vue';
const alerts = {
list: [
{
iid: '01',
title: 'Issue 01',
status: 'TRIGGERED',
startedAt: '2020-11-19T18:36:23Z',
},
{
iid: '02',
title: 'Issue 02',
status: 'ACKNOWLEDGED',
startedAt: '2020-11-16T21:59:28Z',
},
{
iid: '03',
title: 'Issue 03',
status: 'RESOLVED',
startedAt: '2020-11-13T20:03:04Z',
},
{
iid: '04',
title: 'Issue 04',
status: 'IGNORED',
startedAt: '2020-10-29T13:37:55Z',
},
],
pageInfo: {},
};
describe('AlertsList component', () => {
let wrapper;
const apolloMock = {
queries: { alerts: { loading: false } },
};
const findUnconfiguredAlert = () => wrapper.find("[data-testid='threat-alerts-unconfigured']");
const findErrorAlert = () => wrapper.find("[data-testid='threat-alerts-error']");
const findStartedAtColumn = () => wrapper.find("[data-testid='threat-alerts-started-at']");
const findStartedAtColumnHeader = () =>
wrapper.find("[data-testid='threat-alerts-started-at-header']");
const findIdColumn = () => wrapper.find("[data-testid='threat-alerts-id']");
const findStatusColumn = () => wrapper.find("[data-testid='threat-alerts-status']");
const findStatusColumnHeader = () => wrapper.find("[data-testid='threat-alerts-status-header']");
const findEmptyState = () => wrapper.find("[data-testid='threat-alerts-empty-state']");
const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon);
const createWrapper = ({ $apollo = apolloMock, data = {} } = {}) => {
wrapper = mount(AlertsList, {
mocks: {
$apollo,
},
provide: {
documentationPath: '#',
projectPath: '#',
},
stubs: {
GlAlert: true,
GlLoadingIcon: true,
},
data() {
return data;
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('default state', () => {
beforeEach(() => {
createWrapper({ data: { alerts } });
});
it('does show all columns', () => {
expect(findStartedAtColumn().exists()).toBe(true);
expect(findIdColumn().exists()).toBe(true);
expect(findStatusColumn().exists()).toBe(true);
});
it('does not show the empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
it('does not show the unconfigured alert error state when the list is populated', () => {
expect(findUnconfiguredAlert().exists()).toBe(false);
});
it('does not show the request error state', () => {
expect(findErrorAlert().exists()).toBe(false);
});
it('is initially sorted by started at, descending', () => {
expect(wrapper.vm.sort).toBe('STARTED_AT_DESC');
expect(findStartedAtColumnHeader().attributes('aria-sort')).toBe('descending');
});
it('updates sort with new direction and column key', async () => {
expect(findStatusColumnHeader().attributes('aria-sort')).toBe('none');
findStatusColumnHeader().trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.vm.sort).toBe('STATUS_DESC');
expect(findStatusColumnHeader().attributes('aria-sort')).toBe('descending');
findStatusColumnHeader().trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.vm.sort).toBe('STATUS_ASC');
expect(findStatusColumnHeader().attributes('aria-sort')).toBe('ascending');
});
});
describe('empty state', () => {
beforeEach(() => {
createWrapper({ data: { alerts: { list: [] } } });
});
it('does show the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
it('does show the unconfigured alert error state when the list is empty', () => {
expect(findUnconfiguredAlert().exists()).toBe(true);
});
});
describe('loading state', () => {
beforeEach(() => {
const apolloMockLoading = {
queries: { alerts: { loading: true } },
};
createWrapper({ $apollo: apolloMockLoading });
});
it('does show the loading state', () => {
expect(findGlLoadingIcon().exists()).toBe(true);
});
it('does not show all columns', () => {
expect(findStartedAtColumn().exists()).toBe(false);
expect(findIdColumn().exists()).toBe(false);
expect(findStatusColumn().exists()).toBe(false);
});
it('does not show the empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
});
describe('error state', () => {
beforeEach(() => {
createWrapper();
});
it('does not show the unconfigured alert error state when there is a request error', async () => {
wrapper.setData({
errored: true,
});
await wrapper.vm.$nextTick();
expect(findErrorAlert().exists()).toBe(true);
expect(findUnconfiguredAlert().exists()).toBe(false);
});
it('does not show the unconfigured alert error state when there is a request error that has been dismissed', async () => {
wrapper.setData({
isErrorAlertDismissed: true,
});
await wrapper.vm.$nextTick();
expect(findUnconfiguredAlert().exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import Alerts from 'ee/threat_monitoring/components/alerts/alerts.vue';
import AlertsList from 'ee/threat_monitoring/components/alerts/alerts_list.vue';
describe('Alerts component', () => {
let wrapper;
const findAlertsList = () => wrapper.find(AlertsList);
const createWrapper = () => {
wrapper = shallowMount(Alerts);
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('default state', () => {
beforeEach(() => {
createWrapper();
});
it('shows threat monitoring alerts list', () => {
expect(findAlertsList().exists()).toBe(true);
});
});
});
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import ThreatMonitoringAlerts from 'ee/threat_monitoring/components/threat_alerts.vue';
import ThreatMonitoringAlerts from 'ee/threat_monitoring/components/alerts/alerts.vue';
import ThreatMonitoringApp from 'ee/threat_monitoring/components/app.vue';
import ThreatMonitoringFilters from 'ee/threat_monitoring/components/threat_monitoring_filters.vue';
import createStore from 'ee/threat_monitoring/store';
......@@ -44,13 +44,15 @@ describe('ThreatMonitoringApp component', () => {
emptyStateSvgPath,
wafNoDataSvgPath,
networkPolicyNoDataSvgPath,
documentationPath,
newPolicyPath,
showUserCallout: true,
userCalloutId,
userCalloutsPath,
...propsData,
},
provide: {
documentationPath,
},
store,
...options,
});
......
......@@ -28362,12 +28362,30 @@ msgstr ""
msgid "ThreatMonitoring|Container NetworkPolicies not detected"
msgstr ""
msgid "ThreatMonitoring|Date and time"
msgstr ""
msgid "ThreatMonitoring|Dismissed"
msgstr ""
msgid "ThreatMonitoring|Dropped Packets"
msgstr ""
msgid "ThreatMonitoring|Environment"
msgstr ""
msgid "ThreatMonitoring|In review"
msgstr ""
msgid "ThreatMonitoring|Name"
msgstr ""
msgid "ThreatMonitoring|No alerts available to display. See %{linkStart}enabling threat alerts%{linkEnd} for more information on adding alerts to the list."
msgstr ""
msgid "ThreatMonitoring|No alerts to display."
msgstr ""
msgid "ThreatMonitoring|No environments detected"
msgstr ""
......@@ -28383,6 +28401,9 @@ msgstr ""
msgid "ThreatMonitoring|Requests"
msgstr ""
msgid "ThreatMonitoring|Resolved"
msgstr ""
msgid "ThreatMonitoring|Show last"
msgstr ""
......@@ -28395,12 +28416,18 @@ msgstr ""
msgid "ThreatMonitoring|Statistics"
msgstr ""
msgid "ThreatMonitoring|Status"
msgstr ""
msgid "ThreatMonitoring|The firewall is not installed or has been disabled. To view this data, ensure the web application firewall is installed and enabled for your cluster."
msgstr ""
msgid "ThreatMonitoring|The graph below is an overview of traffic coming to your application as tracked by the Web Application Firewall (WAF). View the docs for instructions on how to access the WAF logs to see what type of malicious traffic is trying to access your app. The docs link is also accessible by clicking the \"?\" icon next to the title below."
msgstr ""
msgid "ThreatMonitoring|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear."
msgstr ""
msgid "ThreatMonitoring|Threat Monitoring"
msgstr ""
......@@ -28419,6 +28446,9 @@ msgstr ""
msgid "ThreatMonitoring|Total Requests"
msgstr ""
msgid "ThreatMonitoring|Unreviewed"
msgstr ""
msgid "ThreatMonitoring|View documentation"
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