Commit dddefcf5 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '14707-add-show-last-filter-dropdown-ee' into 'master'

Add "Show last" Threat Monitoring filter

See merge request gitlab-org/gitlab!22487
parents 428d07da 6d836666
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { TIME_WINDOWS } from '../constants';
export default { export default {
name: 'ThreatMonitoringFilters', name: 'ThreatMonitoringFilters',
...@@ -10,13 +11,25 @@ export default { ...@@ -10,13 +11,25 @@ export default {
GlDropdownItem, GlDropdownItem,
}, },
computed: { computed: {
...mapState('threatMonitoring', ['environments', 'currentEnvironmentId']), ...mapState('threatMonitoring', [
...mapGetters('threatMonitoring', ['currentEnvironmentName']), 'environments',
'currentEnvironmentId',
'isLoadingEnvironments',
'isLoadingWafStatistics',
]),
...mapGetters('threatMonitoring', ['currentEnvironmentName', 'currentTimeWindowName']),
isDisabled() {
return (
this.isLoadingEnvironments || this.isLoadingWafStatistics || this.environments.length === 0
);
},
}, },
methods: { methods: {
...mapActions('threatMonitoring', ['setCurrentEnvironmentId']), ...mapActions('threatMonitoring', ['setCurrentEnvironmentId', 'setCurrentTimeWindow']),
}, },
environmentFilterId: 'threat-monitoring-environment-filter', environmentFilterId: 'threat-monitoring-environment-filter',
showLastFilterId: 'threat-monitoring-show-last-filter',
timeWindows: TIME_WINDOWS,
}; };
</script> </script>
...@@ -31,19 +44,45 @@ export default { ...@@ -31,19 +44,45 @@ export default {
> >
<gl-dropdown <gl-dropdown
:id="$options.environmentFilterId" :id="$options.environmentFilterId"
ref="environmentsDropdown"
class="mb-0 d-flex" class="mb-0 d-flex"
toggle-class="d-flex justify-content-between" toggle-class="d-flex justify-content-between"
:text="currentEnvironmentName" :text="currentEnvironmentName"
:disabled="environments.length === 0" :disabled="isDisabled"
> >
<gl-dropdown-item <gl-dropdown-item
v-for="environment in environments" v-for="environment in environments"
:key="environment.id" :key="environment.id"
ref="environmentsDropdownItem"
@click="setCurrentEnvironmentId(environment.id)" @click="setCurrentEnvironmentId(environment.id)"
>{{ environment.name }}</gl-dropdown-item >{{ environment.name }}</gl-dropdown-item
> >
</gl-dropdown> </gl-dropdown>
</gl-form-group> </gl-form-group>
<gl-form-group
:label="s__('ThreatMonitoring|Show last')"
label-size="sm"
:label-for="$options.showLastFilterId"
class="col-sm-6 col-md-4 col-lg-3 col-xl-2"
>
<gl-dropdown
:id="$options.showLastFilterId"
ref="showLastDropdown"
class="mb-0 d-flex"
toggle-class="d-flex justify-content-between"
:text="currentTimeWindowName"
:disabled="isDisabled"
>
<gl-dropdown-item
v-for="(timeWindowConfig, timeWindow) in $options.timeWindows"
:key="timeWindow"
ref="showLastDropdownItem"
@click="setCurrentTimeWindow(timeWindow)"
>{{ timeWindowConfig.name }}</gl-dropdown-item
>
</gl-dropdown>
</gl-form-group>
</div> </div>
</div> </div>
</template> </template>
import { __ } from '~/locale';
export const INVALID_CURRENT_ENVIRONMENT_NAME = '';
const INTERVALS = {
minute: 'minute',
hour: 'hour',
day: 'day',
};
export const TIME_WINDOWS = {
thirtyMinutes: {
name: __('30 minutes'),
durationInMilliseconds: 30 * 60 * 1000,
interval: INTERVALS.minute,
},
oneHour: {
name: __('1 hour'),
durationInMilliseconds: 60 * 60 * 1000,
interval: INTERVALS.minute,
},
twentyFourHours: {
name: __('24 hours'),
durationInMilliseconds: 24 * 60 * 60 * 1000,
interval: INTERVALS.hour,
},
sevenDays: {
name: __('7 days'),
durationInMilliseconds: 7 * 24 * 60 * 60 * 1000,
interval: INTERVALS.hour,
},
thirtyDays: {
name: __('30 days'),
durationInMilliseconds: 30 * 24 * 60 * 60 * 1000,
interval: INTERVALS.day,
},
};
export const DEFAULT_TIME_WINDOW = 'thirtyDays';
...@@ -2,6 +2,7 @@ import { s__ } from '~/locale'; ...@@ -2,6 +2,7 @@ import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { getTimeWindowParams } from './utils';
export const setEndpoints = ({ commit }, endpoints) => commit(types.SET_ENDPOINTS, endpoints); export const setEndpoints = ({ commit }, endpoints) => commit(types.SET_ENDPOINTS, endpoints);
...@@ -49,6 +50,11 @@ export const setCurrentEnvironmentId = ({ commit, dispatch }, environmentId) => ...@@ -49,6 +50,11 @@ export const setCurrentEnvironmentId = ({ commit, dispatch }, environmentId) =>
return dispatch('fetchWafStatistics'); return dispatch('fetchWafStatistics');
}; };
export const setCurrentTimeWindow = ({ commit, dispatch }, timeWindow) => {
commit(types.SET_CURRENT_TIME_WINDOW, timeWindow);
return dispatch('fetchWafStatistics');
};
export const requestWafStatistics = ({ commit }) => commit(types.REQUEST_WAF_STATISTICS); export const requestWafStatistics = ({ commit }) => commit(types.REQUEST_WAF_STATISTICS);
export const receiveWafStatisticsSuccess = ({ commit }, statistics) => export const receiveWafStatisticsSuccess = ({ commit }, statistics) =>
commit(types.RECEIVE_WAF_STATISTICS_SUCCESS, statistics); commit(types.RECEIVE_WAF_STATISTICS_SUCCESS, statistics);
...@@ -68,6 +74,7 @@ export const fetchWafStatistics = ({ state, dispatch }) => { ...@@ -68,6 +74,7 @@ export const fetchWafStatistics = ({ state, dispatch }) => {
.get(state.wafStatisticsEndpoint, { .get(state.wafStatisticsEndpoint, {
params: { params: {
environment_id: state.currentEnvironmentId, environment_id: state.currentEnvironmentId,
...getTimeWindowParams(state.currentTimeWindow, Date.now()),
}, },
}) })
.then(({ data }) => dispatch('receiveWafStatisticsSuccess', data)) .then(({ data }) => dispatch('receiveWafStatisticsSuccess', data))
......
// eslint-disable-next-line import/prefer-default-export
export const INVALID_CURRENT_ENVIRONMENT_NAME = '';
import { INVALID_CURRENT_ENVIRONMENT_NAME } from './constants'; import { INVALID_CURRENT_ENVIRONMENT_NAME } from '../../../constants';
import { getTimeWindowConfig } from './utils';
// eslint-disable-next-line import/prefer-default-export
export const currentEnvironmentName = ({ currentEnvironmentId, environments }) => { export const currentEnvironmentName = ({ currentEnvironmentId, environments }) => {
const environment = environments.find(({ id }) => id === currentEnvironmentId); const environment = environments.find(({ id }) => id === currentEnvironmentId);
return environment ? environment.name : INVALID_CURRENT_ENVIRONMENT_NAME; return environment ? environment.name : INVALID_CURRENT_ENVIRONMENT_NAME;
}; };
export const currentTimeWindowName = ({ currentTimeWindow }) =>
getTimeWindowConfig(currentTimeWindow).name;
...@@ -5,6 +5,8 @@ export const RECEIVE_ENVIRONMENTS_SUCCESS = 'RECEIVE_ENVIRONMENTS_SUCCESS'; ...@@ -5,6 +5,8 @@ export const RECEIVE_ENVIRONMENTS_SUCCESS = 'RECEIVE_ENVIRONMENTS_SUCCESS';
export const RECEIVE_ENVIRONMENTS_ERROR = 'RECEIVE_ENVIRONMENTS_ERROR'; export const RECEIVE_ENVIRONMENTS_ERROR = 'RECEIVE_ENVIRONMENTS_ERROR';
export const SET_CURRENT_ENVIRONMENT_ID = 'SET_CURRENT_ENVIRONMENT_ID'; export const SET_CURRENT_ENVIRONMENT_ID = 'SET_CURRENT_ENVIRONMENT_ID';
export const SET_CURRENT_TIME_WINDOW = 'SET_CURRENT_TIME_WINDOW';
export const REQUEST_WAF_STATISTICS = 'REQUEST_WAF_STATISTICS'; export const REQUEST_WAF_STATISTICS = 'REQUEST_WAF_STATISTICS';
export const RECEIVE_WAF_STATISTICS_SUCCESS = 'RECEIVE_WAF_STATISTICS_SUCCESS'; export const RECEIVE_WAF_STATISTICS_SUCCESS = 'RECEIVE_WAF_STATISTICS_SUCCESS';
export const RECEIVE_WAF_STATISTICS_ERROR = 'RECEIVE_WAF_STATISTICS_ERROR'; export const RECEIVE_WAF_STATISTICS_ERROR = 'RECEIVE_WAF_STATISTICS_ERROR';
...@@ -22,6 +22,9 @@ export default { ...@@ -22,6 +22,9 @@ export default {
[types.SET_CURRENT_ENVIRONMENT_ID](state, payload) { [types.SET_CURRENT_ENVIRONMENT_ID](state, payload) {
state.currentEnvironmentId = payload; state.currentEnvironmentId = payload;
}, },
[types.SET_CURRENT_TIME_WINDOW](state, payload) {
state.currentTimeWindow = payload;
},
[types.REQUEST_WAF_STATISTICS](state) { [types.REQUEST_WAF_STATISTICS](state) {
state.isLoadingWafStatistics = true; state.isLoadingWafStatistics = true;
state.errorLoadingWafStatistics = false; state.errorLoadingWafStatistics = false;
......
import { DEFAULT_TIME_WINDOW } from '../../../constants';
export default () => ({ export default () => ({
environmentsEndpoint: '', environmentsEndpoint: '',
environments: [], environments: [],
isLoadingEnvironments: false, isLoadingEnvironments: false,
errorLoadingEnvironments: false, errorLoadingEnvironments: false,
currentEnvironmentId: -1, currentEnvironmentId: -1,
currentTimeWindow: DEFAULT_TIME_WINDOW,
wafStatisticsEndpoint: '', wafStatisticsEndpoint: '',
wafStatistics: { wafStatistics: {
totalTraffic: 0, totalTraffic: 0,
......
import { TIME_WINDOWS, DEFAULT_TIME_WINDOW } from 'ee/threat_monitoring/constants';
export const getTimeWindowConfig = timeWindow =>
TIME_WINDOWS[timeWindow] || TIME_WINDOWS[DEFAULT_TIME_WINDOW];
/**
* Get the from/to/interval query parameters for the given time window.
* @param {string} timeWindow - The time window (keyof TIME_WINDOWS)
* @param {number} to - Milliseconds past the epoch corresponding to the
* returned `to` parameter
* @returns {Object} Query parameters `from` and `to` are ISO 8601 dates and
* `interval` is the configured interval for the time window.
*/
export const getTimeWindowParams = (timeWindow, to) => {
const { durationInMilliseconds, interval } = getTimeWindowConfig(timeWindow);
return {
from: new Date(to - durationInMilliseconds).toISOString(),
to: new Date(to).toISOString(),
interval,
};
};
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import createStore from 'ee/threat_monitoring/store'; import createStore from 'ee/threat_monitoring/store';
import ThreatMonitoringFilters from 'ee/threat_monitoring/components/threat_monitoring_filters.vue'; import ThreatMonitoringFilters from 'ee/threat_monitoring/components/threat_monitoring_filters.vue';
import { INVALID_CURRENT_ENVIRONMENT_NAME } from 'ee/threat_monitoring/store/modules/threat_monitoring/constants'; import { INVALID_CURRENT_ENVIRONMENT_NAME, TIME_WINDOWS } from 'ee/threat_monitoring/constants';
import { mockEnvironmentsResponse } from '../mock_data'; import { mockEnvironmentsResponse } from '../mock_data';
const mockEnvironments = mockEnvironmentsResponse.environments;
describe('ThreatMonitoringFilters component', () => { describe('ThreatMonitoringFilters component', () => {
let store; let store;
let wrapper; let wrapper;
...@@ -21,23 +22,21 @@ describe('ThreatMonitoringFilters component', () => { ...@@ -21,23 +22,21 @@ describe('ThreatMonitoringFilters component', () => {
}); });
}; };
const findEnvironmentsDropdown = () => wrapper.find(GlDropdown); const findEnvironmentsDropdown = () => wrapper.find({ ref: 'environmentsDropdown' });
const findEnvironmentsDropdownItems = () => wrapper.findAll(GlDropdownItem).wrappers; const findEnvironmentsDropdownItems = () => wrapper.findAll({ ref: 'environmentsDropdownItem' });
const findShowLastDropdown = () => wrapper.find({ ref: 'showLastDropdown' });
const findShowLastDropdownItems = () => wrapper.findAll({ ref: 'showLastDropdownItem' });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('the environments dropdown', () => {
describe('given there are no environments', () => { describe('given there are no environments', () => {
beforeEach(() => { beforeEach(() => {
factory(); factory();
}); });
describe('the environments dropdown', () => {
it('is disabled', () => {
expect(findEnvironmentsDropdown().attributes().disabled).toBe('true');
});
it('has text set to the INVALID_CURRENT_ENVIRONMENT_NAME', () => { it('has text set to the INVALID_CURRENT_ENVIRONMENT_NAME', () => {
expect(findEnvironmentsDropdown().attributes().text).toBe(INVALID_CURRENT_ENVIRONMENT_NAME); expect(findEnvironmentsDropdown().attributes().text).toBe(INVALID_CURRENT_ENVIRONMENT_NAME);
}); });
...@@ -46,20 +45,17 @@ describe('ThreatMonitoringFilters component', () => { ...@@ -46,20 +45,17 @@ describe('ThreatMonitoringFilters component', () => {
expect(findEnvironmentsDropdownItems()).toHaveLength(0); expect(findEnvironmentsDropdownItems()).toHaveLength(0);
}); });
}); });
});
describe('given there are environments', () => { describe('given there are environments', () => {
const { environments } = mockEnvironmentsResponse; const currentEnvironment = mockEnvironments[1];
const currentEnvironment = environments[1];
beforeEach(() => { beforeEach(() => {
factory({ factory({
environments, environments: mockEnvironments,
currentEnvironmentId: currentEnvironment.id, currentEnvironmentId: currentEnvironment.id,
}); });
}); });
describe('the environments dropdown', () => {
it('is not disabled', () => { it('is not disabled', () => {
expect(findEnvironmentsDropdown().attributes().disabled).toBe(undefined); expect(findEnvironmentsDropdown().attributes().disabled).toBe(undefined);
}); });
...@@ -71,10 +67,11 @@ describe('ThreatMonitoringFilters component', () => { ...@@ -71,10 +67,11 @@ describe('ThreatMonitoringFilters component', () => {
it('has dropdown items for each environment', () => { it('has dropdown items for each environment', () => {
const dropdownItems = findEnvironmentsDropdownItems(); const dropdownItems = findEnvironmentsDropdownItems();
environments.forEach((environment, i) => { mockEnvironments.forEach((environment, i) => {
expect(dropdownItems[i].text()).toBe(environment.name); const dropdownItem = dropdownItems.at(i);
expect(dropdownItem.text()).toBe(environment.name);
dropdownItems[i].vm.$emit('click'); dropdownItem.vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith( expect(store.dispatch).toHaveBeenCalledWith(
'threatMonitoring/setCurrentEnvironmentId', 'threatMonitoring/setCurrentEnvironmentId',
environment.id, environment.id,
...@@ -83,4 +80,61 @@ describe('ThreatMonitoringFilters component', () => { ...@@ -83,4 +80,61 @@ describe('ThreatMonitoringFilters component', () => {
}); });
}); });
}); });
describe('the "show last" dropdown', () => {
beforeEach(() => {
factory({
environments: mockEnvironments,
});
});
it('is not disabled', () => {
expect(findShowLastDropdown().attributes().disabled).toBe(undefined);
});
it('has text set to the current time window name', () => {
const currentTimeWindowName = store.getters['threatMonitoring/currentTimeWindowName'];
expect(findShowLastDropdown().attributes().text).toBe(currentTimeWindowName);
});
it('has dropdown items for each time window', () => {
const dropdownItems = findShowLastDropdownItems();
Object.entries(TIME_WINDOWS).forEach(([timeWindow, config], i) => {
const dropdownItem = dropdownItems.at(i);
expect(dropdownItem.text()).toBe(config.name);
dropdownItem.vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith(
'threatMonitoring/setCurrentTimeWindow',
timeWindow,
);
});
});
});
describe.each`
context | isLoadingEnvironments | isLoadingWafStatistics | environments
${'environments are loading'} | ${true} | ${false} | ${mockEnvironments}
${'WAF statistics are loading'} | ${false} | ${true} | ${mockEnvironments}
${'there are no environments'} | ${false} | ${false} | ${[]}
`('given $context', ({ isLoadingEnvironments, isLoadingWafStatistics, environments }) => {
beforeEach(() => {
factory({
environments,
isLoadingEnvironments,
isLoadingWafStatistics,
});
return wrapper.vm.$nextTick();
});
it('disables the environments dropdown', () => {
expect(findEnvironmentsDropdown().attributes('disabled')).toBe('true');
});
it('disables the "show last" dropdown', () => {
expect(findShowLastDropdown().attributes('disabled')).toBe('true');
});
});
}); });
...@@ -198,6 +198,19 @@ describe('Threat Monitoring actions', () => { ...@@ -198,6 +198,19 @@ describe('Threat Monitoring actions', () => {
)); ));
}); });
describe('setCurrentTimeWindow', () => {
const timeWindow = 'foo';
it('commits the SET_CURRENT_TIME_WINDOW mutation and dispatches fetchWafStatistics', () =>
testAction(
actions.setCurrentTimeWindow,
timeWindow,
state,
[{ type: types.SET_CURRENT_TIME_WINDOW, payload: timeWindow }],
[{ type: 'fetchWafStatistics' }],
));
});
describe('requestWafStatistics', () => { describe('requestWafStatistics', () => {
it('commits the REQUEST_WAF_STATISTICS mutation', () => it('commits the REQUEST_WAF_STATISTICS mutation', () =>
testAction( testAction(
...@@ -262,8 +275,17 @@ describe('Threat Monitoring actions', () => { ...@@ -262,8 +275,17 @@ describe('Threat Monitoring actions', () => {
describe('on success', () => { describe('on success', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(global.Date, 'now').mockImplementation(() => new Date(2019, 0, 31).getTime());
mock mock
.onGet(wafStatisticsEndpoint, { params: { environment_id: currentEnvironmentId } }) .onGet(wafStatisticsEndpoint, {
params: {
environment_id: currentEnvironmentId,
from: '2019-01-01T00:00:00.000Z',
to: '2019-01-31T00:00:00.000Z',
interval: 'day',
},
})
.replyOnce(200, mockWafStatisticsResponse); .replyOnce(200, mockWafStatisticsResponse);
}); });
......
import createState from 'ee/threat_monitoring/store/modules/threat_monitoring/state'; import createState from 'ee/threat_monitoring/store/modules/threat_monitoring/state';
import * as getters from 'ee/threat_monitoring/store/modules/threat_monitoring/getters'; import * as getters from 'ee/threat_monitoring/store/modules/threat_monitoring/getters';
import { INVALID_CURRENT_ENVIRONMENT_NAME } from 'ee/threat_monitoring/store/modules/threat_monitoring/constants'; import { INVALID_CURRENT_ENVIRONMENT_NAME, TIME_WINDOWS } from 'ee/threat_monitoring/constants';
describe('threatMonitoring module getters', () => { describe('threatMonitoring module getters', () => {
let state; let state;
...@@ -26,4 +26,18 @@ describe('threatMonitoring module getters', () => { ...@@ -26,4 +26,18 @@ describe('threatMonitoring module getters', () => {
}); });
}); });
}); });
describe('currentTimeWindowName', () => {
it('gives the correct name for a valid time window', () => {
Object.keys(TIME_WINDOWS).forEach(timeWindow => {
state.currentTimeWindow = timeWindow;
expect(getters.currentTimeWindowName(state)).toBe(TIME_WINDOWS[timeWindow].name);
});
});
it('gives the default name for an invalid time window', () => {
state.currentTimeWindowName = 'foo';
expect(getters.currentTimeWindowName(state)).toBe('30 days');
});
});
}); });
...@@ -80,6 +80,18 @@ describe('Threat Monitoring mutations', () => { ...@@ -80,6 +80,18 @@ describe('Threat Monitoring mutations', () => {
}); });
}); });
describe(types.SET_CURRENT_TIME_WINDOW, () => {
const timeWindow = 'foo';
beforeEach(() => {
mutations[types.SET_CURRENT_TIME_WINDOW](state, timeWindow);
});
it('sets currentTimeWindow', () => {
expect(state.currentTimeWindow).toBe(timeWindow);
});
});
describe(types.REQUEST_WAF_STATISTICS, () => { describe(types.REQUEST_WAF_STATISTICS, () => {
beforeEach(() => { beforeEach(() => {
mutations[types.REQUEST_WAF_STATISTICS](state); mutations[types.REQUEST_WAF_STATISTICS](state);
......
import {
getTimeWindowConfig,
getTimeWindowParams,
} from 'ee/threat_monitoring/store/modules/threat_monitoring/utils';
import { DEFAULT_TIME_WINDOW, TIME_WINDOWS } from 'ee/threat_monitoring/constants';
describe('threatMonitoring module utils', () => {
describe('getTimeWindowConfig', () => {
it('gives the correct config for a valid time window', () => {
Object.entries(TIME_WINDOWS).forEach(([timeWindow, expectedConfig]) => {
expect(getTimeWindowConfig(timeWindow)).toBe(expectedConfig);
});
});
it('gives the default name for an invalid time window', () => {
expect(getTimeWindowConfig('foo')).toBe(TIME_WINDOWS[DEFAULT_TIME_WINDOW]);
});
});
describe('getTimeWindowParams', () => {
const mockTimestamp = new Date(2020, 0, 1, 10).getTime();
it.each`
timeWindow | expectedFrom | interval
${'thirtyMinutes'} | ${'2020-01-01T09:30:00.000Z'} | ${'minute'}
${'oneHour'} | ${'2020-01-01T09:00:00.000Z'} | ${'minute'}
${'twentyFourHours'} | ${'2019-12-31T10:00:00.000Z'} | ${'hour'}
${'sevenDays'} | ${'2019-12-25T10:00:00.000Z'} | ${'hour'}
${'thirtyDays'} | ${'2019-12-02T10:00:00.000Z'} | ${'day'}
${'foo'} | ${'2019-12-02T10:00:00.000Z'} | ${'day'}
`(
'returns the expected params given "$timeWindow"',
({ timeWindow, expectedFrom, interval }) => {
expect(getTimeWindowParams(timeWindow, mockTimestamp)).toEqual({
from: expectedFrom,
to: '2020-01-01T10:00:00.000Z',
interval,
});
},
);
});
});
...@@ -557,6 +557,9 @@ msgid_plural "%d groups" ...@@ -557,6 +557,9 @@ msgid_plural "%d groups"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "1 hour"
msgstr ""
msgid "1 merged merge request" msgid "1 merged merge request"
msgid_plural "%{merge_requests} merged merge requests" msgid_plural "%{merge_requests} merged merge requests"
msgstr[0] "" msgstr[0] ""
...@@ -607,6 +610,9 @@ msgstr "" ...@@ -607,6 +610,9 @@ msgstr ""
msgid "20-29 contributions" msgid "20-29 contributions"
msgstr "" msgstr ""
msgid "24 hours"
msgstr ""
msgid "2FA" msgid "2FA"
msgstr "" msgstr ""
...@@ -619,6 +625,9 @@ msgstr "" ...@@ -619,6 +625,9 @@ msgstr ""
msgid "3 hours" msgid "3 hours"
msgstr "" msgstr ""
msgid "30 days"
msgstr ""
msgid "30 minutes" msgid "30 minutes"
msgstr "" msgstr ""
...@@ -640,6 +649,9 @@ msgstr "" ...@@ -640,6 +649,9 @@ msgstr ""
msgid "404|Please contact your GitLab administrator if you think this is a mistake." msgid "404|Please contact your GitLab administrator if you think this is a mistake."
msgstr "" msgstr ""
msgid "7 days"
msgstr ""
msgid "8 hours" msgid "8 hours"
msgstr "" msgstr ""
...@@ -18797,6 +18809,9 @@ msgstr "" ...@@ -18797,6 +18809,9 @@ msgstr ""
msgid "ThreatMonitoring|Requests" msgid "ThreatMonitoring|Requests"
msgstr "" msgstr ""
msgid "ThreatMonitoring|Show last"
msgstr ""
msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics" msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics"
msgstr "" 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