Commit 16be6f5d authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '37276-add-time-filtering-to-log-view' into 'master'

Add time filters to log view

Closes #37986

See merge request gitlab-org/gitlab!22638
parents bb1e7015 2f4adf82
......@@ -41,6 +41,7 @@
.mh-50vh { max-height: 50vh; }
.gl-w-64 { width: px-to-rem($grid-size * 8); }
.gl-h-32 { height: px-to-rem($grid-size * 4); }
.gl-h-64 { height: px-to-rem($grid-size * 8); }
.gl-text-purple { color: $purple; }
......@@ -59,4 +60,3 @@
.gl-text-red-700 { @include gl-text-red-700; }
.gl-text-orange-700 { @include gl-text-orange-700; }
.gl-text-green-700 { @include gl-text-green-700; }
......@@ -88,13 +88,15 @@ export default {
* Returns pods logs for an environment with an optional pod and container
*
* @param {Object} params
* @param {string} param.projectFullPath - Path of the project, in format `/<namespace>/<project-key>`
* @param {number} param.environmentId - Id of the environment
* @param {string} params.projectFullPath - Path of the project, in format `/<namespace>/<project-key>`
* @param {number} params.environmentId - Id of the environment
* @param {string=} params.podName - Pod name, if not set the backend assumes a default one
* @param {string=} params.containerName - Container name, if not set the backend assumes a default one
* @param {string=} params.start - Starting date to query the logs in ISO format
* @param {string=} params.end - Ending date to query the logs in ISO format
* @returns {Promise} Axios promise for the result of a GET request of logs
*/
getPodLogs({ projectPath, environmentName, podName, containerName, search }) {
getPodLogs({ projectPath, environmentName, podName, containerName, search, start, end }) {
const url = this.buildUrl(this.podLogsPath).replace(':project_full_path', projectPath);
const params = {
......@@ -110,6 +112,12 @@ export default {
if (search) {
params.search = search;
}
if (start) {
params.start = start;
}
if (end) {
params.end = end;
}
return axios.get(url, { params });
},
......
......@@ -45,7 +45,13 @@ export default {
};
},
computed: {
...mapState('environmentLogs', ['environments', 'logs', 'pods', 'enableAdvancedQuerying']),
...mapState('environmentLogs', [
'environments',
'timeWindow',
'logs',
'pods',
'enableAdvancedQuerying',
]),
...mapGetters('environmentLogs', ['trace']),
showLoader() {
return this.logs.isLoading || !this.logs.isComplete;
......@@ -60,6 +66,7 @@ export default {
return (
!this.isElasticStackCalloutDismissed &&
!this.environments.isLoading &&
!this.logs.isLoading &&
!this.advancedFeaturesEnabled
);
},
......@@ -87,6 +94,7 @@ export default {
...mapActions('environmentLogs', [
'setInitData',
'setSearch',
'setTimeWindow',
'showPodLogs',
'showEnvironment',
'fetchEnvironments',
......@@ -113,19 +121,20 @@ export default {
</a>
</gl-alert>
<div class="top-bar js-top-bar d-flex">
<div class="row">
<div class="row mx-n1">
<gl-form-group
id="environments-dropdown-fg"
:label="s__('Environments|Environment')"
label-size="sm"
label-for="environments-dropdown"
:class="featureElasticEnabled ? 'col-4' : 'col-6'"
class="px-1"
:class="featureElasticEnabled ? 'col-3' : 'col-6'"
>
<gl-dropdown
id="environments-dropdown"
:text="environments.current"
:disabled="environments.isLoading"
class="d-flex js-environments-dropdown"
class="d-flex gl-h-32 js-environments-dropdown"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
......@@ -142,13 +151,14 @@ export default {
:label="s__('Environments|Pod logs from')"
label-size="sm"
label-for="pods-dropdown"
:class="featureElasticEnabled ? 'col-4' : 'col-6'"
class="px-1"
:class="featureElasticEnabled ? 'col-3' : 'col-6'"
>
<gl-dropdown
id="pods-dropdown"
:text="pods.current || s__('Environments|No pods to display')"
:disabled="logs.isLoading"
class="d-flex js-pods-dropdown"
class="d-flex gl-h-32 js-pods-dropdown"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
......@@ -160,29 +170,57 @@ export default {
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
v-if="featureElasticEnabled"
id="search-fg"
:label="s__('Environments|Search')"
label-size="sm"
label-for="search"
class="col-4"
>
<gl-search-box-by-click
v-model.trim="searchQuery"
:disabled="environments.isLoading || !advancedFeaturesEnabled"
:placeholder="s__('Environments|Search')"
class="js-logs-search"
type="search"
autofocus
@submit="setSearch(searchQuery)"
/>
</gl-form-group>
<template v-if="featureElasticEnabled">
<gl-form-group
id="dates-fg"
:label="s__('Environments|Show last')"
label-size="sm"
label-for="time-window-dropdown"
class="col-3 px-1"
>
<gl-dropdown
id="time-window-dropdown"
ref="time-window-dropdown"
:disabled="environments.isLoading || !advancedFeaturesEnabled"
:text="timeWindow.options[timeWindow.current].label"
class="d-flex gl-h-32"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="(option, key) in timeWindow.options"
:key="key"
@click="setTimeWindow(key)"
>
{{ option.label }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
id="search-fg"
:label="s__('Environments|Search')"
label-size="sm"
label-for="search"
class="col-3 px-1"
>
<gl-search-box-by-click
v-model.trim="searchQuery"
:disabled="environments.isLoading || !advancedFeaturesEnabled"
:placeholder="s__('Environments|Search')"
class="js-logs-search"
type="search"
autofocus
@submit="
(environments.isLoading || !advancedFeaturesEnabled) && setSearch(searchQuery)
"
/>
</gl-form-group>
</template>
</div>
<log-control-buttons
ref="scrollButtons"
class="controllers align-self-end"
class="controllers align-self-end mb-1"
@refresh="showPodLogs(pods.current)"
/>
</div>
......
import { __ } from '~/locale';
export const defaultTimeWindow = 'oneHour';
export const timeWindows = {
oneHour: {
label: __('1 hour'),
seconds: 60 * 60,
},
fourHours: {
label: __('4 hours'),
seconds: 60 * 60 * 4,
},
oneDay: {
label: __('1 day'),
seconds: 60 * 60 * 24,
},
twoDays: {
label: __('2 days'),
seconds: 60 * 60 * 24 * 3,
},
pastWeek: {
label: __('Past week'),
seconds: 60 * 60 * 24 * 7,
},
twoWeeks: {
label: __('2 weeks'),
seconds: 60 * 60 * 24 * 15,
},
};
......@@ -6,6 +6,9 @@ import flash from '~/flash';
import { s__ } from '~/locale';
import * as types from './mutation_types';
import { getTimeRange } from '../utils';
import { timeWindows } from '../constants';
const requestLogsUntilData = params =>
backOff((next, stop) => {
Api.getPodLogs(params)
......@@ -38,6 +41,11 @@ export const setSearch = ({ dispatch, commit }, searchQuery) => {
dispatch('fetchLogs');
};
export const setTimeWindow = ({ dispatch, commit }, timeWindowKey) => {
commit(types.SET_TIME_WINDOW, timeWindowKey);
dispatch('fetchLogs');
};
export const showEnvironment = ({ dispatch, commit }, environmentName) => {
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, null);
......@@ -66,6 +74,14 @@ export const fetchLogs = ({ commit, state }) => {
search: state.search,
};
if (state.timeWindow.current) {
const { current } = state.timeWindow;
const { start, end } = getTimeRange(timeWindows[current].seconds);
params.start = start;
params.end = end;
}
commit(types.REQUEST_PODS_DATA);
commit(types.REQUEST_LOGS_DATA);
......
......@@ -2,6 +2,7 @@ export const SET_PROJECT_PATH = 'SET_PROJECT_PATH';
export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT';
export const SET_SEARCH = 'SET_SEARCH';
export const ENABLE_ADVANCED_QUERYING = 'ENABLE_ADVANCED_QUERYING';
export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
......
......@@ -15,6 +15,10 @@ export default {
[types.ENABLE_ADVANCED_QUERYING](state, enableAdvancedQuerying) {
state.enableAdvancedQuerying = enableAdvancedQuerying;
},
/** Time Range data */
[types.SET_TIME_WINDOW](state, timeWindowKey) {
state.timeWindow.current = timeWindowKey;
},
/** Environments data */
[types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
......
import { defaultTimeWindow, timeWindows } from '../constants';
export default () => ({
/**
* Current project path
......@@ -14,6 +16,14 @@ export default () => ({
*/
enableAdvancedQuerying: false,
/**
* Time range (Show last)
*/
timeWindow: {
options: { ...timeWindows },
current: defaultTimeWindow,
},
/**
* Environments list information
*/
......
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
/**
* Returns a time range (`start`, `end`) where `start` is the
* current time minus a given number of seconds and `end`
* is the current time (`now()`).
*
* @param {Number} seconds Seconds duration, defaults to 0.
* @returns {Object} range Time range
* @returns {String} range.start ISO String of current time minus given seconds
* @returns {String} range.end ISO String of current time
*/
export const getTimeRange = (seconds = 0) => {
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
const start = end - seconds;
return {
start: new Date(secondsToMilliseconds(start)).toISOString(),
end: new Date(secondsToMilliseconds(end)).toISOString(),
};
};
export default {};
import Vue from 'vue';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EnvironmentLogs from 'ee/logs/components/environment_logs.vue';
......@@ -44,9 +44,33 @@ describe('EnvironmentLogs', () => {
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
const findPodsDropdown = () => wrapper.find('.js-pods-dropdown');
const findSearchBar = () => wrapper.find('.js-logs-search');
const findTimeWindowDropdown = () => wrapper.find({ ref: 'time-window-dropdown' });
const findLogControlButtons = () => wrapper.find({ name: 'log-control-buttons-stub' });
const findLogTrace = () => wrapper.find('.js-log-trace');
const mockSetInitData = () => {
state.pods.options = mockPods;
state.environments.current = mockEnvName;
[state.pods.current] = state.pods.options;
state.enableAdvancedQuerying = true;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult;
};
const mockShowPodLogs = podName => {
state.pods.options = mockPods;
[state.pods.current] = podName;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult;
};
const mockFetchEnvs = () => {
state.environments.options = mockEnvironments;
};
const initWrapper = () => {
wrapper = shallowMount(EnvironmentLogsComponent, {
propsData,
......@@ -87,12 +111,21 @@ describe('EnvironmentLogs', () => {
expect(wrapper.isVueInstance()).toBe(true);
expect(wrapper.isEmpty()).toBe(false);
expect(findLogTrace().isEmpty()).toBe(false);
// top bar
expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true);
expect(findPodsDropdown().is(GlDropdown)).toBe(true);
expect(findLogControlButtons().exists()).toBe(true);
expect(findSearchBar().exists()).toBe(false); // behind ff
expect(findTimeWindowDropdown().exists()).toBe(false); // behind ff
// log trace
expect(findLogTrace().isEmpty()).toBe(false);
// layout
expect(wrapper.find('#environments-dropdown-fg').attributes('class')).toMatch('col-6');
expect(wrapper.find('#pods-dropdown-fg').attributes('class')).toMatch('col-6');
});
it('mounted inits data', () => {
......@@ -119,9 +152,6 @@ describe('EnvironmentLogs', () => {
state.environments.options = [];
state.environments.isLoading = true;
gon.features = gon.features || {};
gon.features.enableClusterApplicationElasticStack = true;
initWrapper();
});
......@@ -135,11 +165,6 @@ describe('EnvironmentLogs', () => {
expect(findPodsDropdown().findAll(GlDropdownItem).length).toBe(0);
});
it('displays a disabled search bar', () => {
expect(findSearchBar().exists()).toEqual(true);
expect(findSearchBar().attributes('disabled')).toEqual('true');
});
it('does not update buttons state', () => {
expect(updateControlBtnsMock).not.toHaveBeenCalled();
});
......@@ -154,69 +179,11 @@ describe('EnvironmentLogs', () => {
});
});
describe('elastic stack disabled', () => {
beforeEach(() => {
gon.features = gon.features || {};
gon.features.enableClusterApplicationElasticStack = false;
initWrapper();
});
it("doesn't display the search bar", () => {
expect(findSearchBar().exists()).toEqual(false);
expect(wrapper.find('#environments-dropdown-fg').attributes('class')).toEqual('col-6');
expect(wrapper.find('#pods-dropdown-fg').attributes('class')).toEqual('col-6');
});
});
describe('ES enabled and legacy environment', () => {
beforeEach(() => {
state.pods.options = [];
state.logs.lines = [];
state.logs.isLoading = false;
state.environments.options = [];
state.environments.isLoading = false;
state.enableAdvancedQuerying = false;
gon.features = gon.features || {};
gon.features.enableClusterApplicationElasticStack = true;
initWrapper();
});
it('displays a disabled search bar', () => {
expect(findSearchBar().exists()).toEqual(true);
expect(findSearchBar().attributes('disabled')).toEqual('true');
});
});
describe('state with data', () => {
beforeEach(() => {
actionMocks.setInitData.mockImplementation(() => {
state.pods.options = mockPods;
state.environments.current = mockEnvName;
[state.pods.current] = state.pods.options;
state.enableAdvancedQuerying = true;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult;
});
actionMocks.showPodLogs.mockImplementation(podName => {
state.pods.options = mockPods;
[state.pods.current] = podName;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult;
});
actionMocks.fetchEnvironments.mockImplementation(() => {
state.environments.options = mockEnvironments;
});
gon.features = gon.features || {};
gon.features.enableClusterApplicationElasticStack = true;
actionMocks.setInitData.mockImplementation(mockSetInitData);
actionMocks.showPodLogs.mockImplementation(mockShowPodLogs);
actionMocks.fetchEnvironments.mockImplementation(mockFetchEnvs);
initWrapper();
});
......@@ -238,7 +205,6 @@ describe('EnvironmentLogs', () => {
const item = items.at(i);
expect(item.text()).toBe(env.name);
});
expect(wrapper.find('#environments-dropdown-fg').attributes('class')).toEqual('col-4');
});
it('populates pods dropdown', () => {
......@@ -250,7 +216,6 @@ describe('EnvironmentLogs', () => {
const item = items.at(i);
expect(item.text()).toBe(pod);
});
expect(wrapper.find('#pods-dropdown-fg').attributes('class')).toEqual('col-4');
});
it('populates logs trace', () => {
......@@ -259,12 +224,6 @@ describe('EnvironmentLogs', () => {
expect(trace.text().split('\n')).toEqual(mockTrace);
});
it('displays an enabled search bar', () => {
expect(findSearchBar().exists()).toEqual(true);
expect(findSearchBar().attributes('disabled')).toEqual(undefined);
expect(wrapper.find('#search-fg').attributes('class')).toEqual('col-4');
});
it('update control buttons state', () => {
expect(updateControlBtnsMock).toHaveBeenCalledTimes(1);
});
......@@ -308,4 +267,95 @@ describe('EnvironmentLogs', () => {
});
});
});
describe('when feature flag enable_cluster_application_elastic_stack is enabled', () => {
let originalGon;
beforeEach(() => {
originalGon = window.gon;
window.gon = { features: { enableClusterApplicationElasticStack: true } };
});
afterEach(() => {
window.gon = originalGon;
});
it('displays UI elements', () => {
initWrapper();
// elements
expect(findSearchBar().exists()).toBe(true);
expect(findSearchBar().is(GlSearchBoxByClick)).toBe(true);
expect(findTimeWindowDropdown().exists()).toBe(true);
expect(findTimeWindowDropdown().is(GlDropdown)).toBe(true);
// layout
expect(wrapper.find('#environments-dropdown-fg').attributes('class')).toMatch('col-3');
expect(wrapper.find('#pods-dropdown-fg').attributes('class')).toMatch('col-3');
expect(wrapper.find('#dates-fg').attributes('class')).toMatch('col-3');
expect(wrapper.find('#search-fg').attributes('class')).toMatch('col-3');
});
describe('loading state', () => {
beforeEach(() => {
state.pods.options = [];
state.logs.lines = [];
state.logs.isLoading = true;
state.environments.options = [];
state.environments.isLoading = true;
initWrapper();
});
it('displays a disabled search bar', () => {
expect(findSearchBar().exists()).toEqual(true);
expect(findSearchBar().attributes('disabled')).toEqual('true');
});
it('displays a disabled time window dropdown', () => {
expect(findTimeWindowDropdown().attributes('disabled')).toEqual('true');
});
});
describe('when advanced querying is disabled', () => {
beforeEach(() => {
state.pods.options = [];
state.logs.lines = [];
state.logs.isLoading = false;
state.environments.options = [];
state.environments.isLoading = false;
state.enableAdvancedQuerying = false;
initWrapper();
});
it('search bar and time window dropdown are disabled', () => {
expect(findSearchBar().attributes('disabled')).toEqual('true');
expect(findTimeWindowDropdown().attributes('disabled')).toEqual('true');
});
});
describe('state with data', () => {
beforeEach(() => {
actionMocks.setInitData.mockImplementation(mockSetInitData);
actionMocks.showPodLogs.mockImplementation(mockShowPodLogs);
actionMocks.fetchEnvironments.mockImplementation(mockFetchEnvs);
initWrapper();
});
it('displays an enabled search bar', () => {
expect(findSearchBar().attributes('disabled')).toBeFalsy();
});
it('displays an enabled time window dropdown', () => {
expect(findTimeWindowDropdown().attributes('disabled')).toBeFalsy();
});
});
});
});
......@@ -10,8 +10,10 @@ import {
fetchEnvironments,
fetchLogs,
} from 'ee/logs/stores/actions';
import axios from '~/lib/utils/axios_utils';
import { getTimeRange } from 'ee/logs/utils';
import { timeWindows } from 'ee/logs/constants';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import {
......@@ -27,13 +29,21 @@ import {
} from '../mock_data';
jest.mock('~/flash');
jest.mock('ee/logs/utils');
describe('Logs Store actions', () => {
let state;
let mock;
const mockThirtyMinutesSeconds = 3600;
const mockThirtyMinutes = {
start: '2020-01-09T18:06:20.000Z',
end: '2020-01-09T18:36:20.000Z',
};
beforeEach(() => {
state = logsPageState();
getTimeRange.mockReturnValue(mockThirtyMinutes);
});
afterEach(() => {
......@@ -139,7 +149,9 @@ describe('Logs Store actions', () => {
const endpoint = `/${mockProjectPath}/-/logs/k8s.json`;
mock
.onGet(endpoint, { params: { environment_name: mockEnvName, pod_name: mockPodName } })
.onGet(endpoint, {
params: { environment_name: mockEnvName, pod_name: mockPodName, ...mockThirtyMinutes },
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
......@@ -162,7 +174,57 @@ describe('Logs Store actions', () => {
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
done,
() => {
expect(getTimeRange).toHaveBeenCalledWith(mockThirtyMinutesSeconds);
done();
},
);
});
it('should commit logs and pod data when there is pod name defined and a non-default date range', done => {
const mockOneDaySeconds = timeWindows.oneDay.seconds;
const mockOneDay = {
start: '2020-01-08T18:41:39.000Z',
end: '2020-01-09T18:41:39.000Z',
};
getTimeRange.mockReturnValueOnce(mockOneDay);
state.projectPath = mockProjectPath;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
state.timeWindow.current = 'oneDay';
const endpoint = `/${mockProjectPath}/-/logs/k8s.json`;
mock
.onGet(endpoint, {
params: { environment_name: mockEnvName, pod_name: mockPodName, ...mockOneDay },
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
enable_advanced_querying: true,
logs: mockLogsResult,
});
testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.ENABLE_ADVANCED_QUERYING, payload: true },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
() => {
expect(getTimeRange).toHaveBeenCalledWith(mockOneDaySeconds);
done();
},
);
});
......@@ -177,7 +239,12 @@ describe('Logs Store actions', () => {
mock
.onGet(endpoint, {
params: { environment_name: mockEnvName, pod_name: mockPodName, search: mockSearch },
params: {
environment_name: mockEnvName,
pod_name: mockPodName,
search: mockSearch,
...mockThirtyMinutes,
},
})
.reply(200, {
pod_name: mockPodName,
......@@ -211,12 +278,14 @@ describe('Logs Store actions', () => {
const endpoint = `/${mockProjectPath}/-/logs/k8s.json`;
mock.onGet(endpoint, { params: { environment_name: mockEnvName } }).reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
enable_advanced_querying: mockEnableAdvancedQuerying,
});
mock
.onGet(endpoint, { params: { environment_name: mockEnvName, ...mockThirtyMinutes } })
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
enable_advanced_querying: mockEnableAdvancedQuerying,
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
testAction(
......
......@@ -136,6 +136,15 @@ describe('Logs Store Mutations', () => {
});
});
describe('SET_TIME_WINDOW', () => {
it('sets a time window Key', () => {
const mockKey = 'fourHours';
mutations[types.SET_TIME_WINDOW](state, mockKey);
expect(state.timeWindow.current).toEqual(mockKey);
});
});
describe('REQUEST_PODS_DATA', () => {
it('receives log data error and stops loading', () => {
mutations[types.REQUEST_PODS_DATA](state);
......
import { getTimeRange } from 'ee/logs/utils';
describe('logs/utils', () => {
describe('getTimeRange', () => {
const nowTimestamp = 1577836800000;
const nowString = '2020-01-01T00:00:00.000Z';
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => nowTimestamp);
});
afterEach(() => {
Date.now.mockRestore();
});
it('returns the right values', () => {
expect(getTimeRange(0)).toEqual({
start: '2020-01-01T00:00:00.000Z',
end: nowString,
});
expect(getTimeRange(60 * 30)).toEqual({
start: '2019-12-31T23:30:00.000Z',
end: nowString,
});
expect(getTimeRange(60 * 60 * 24 * 7 * 1)).toEqual({
start: '2019-12-25T00:00:00.000Z',
end: nowString,
});
expect(getTimeRange(60 * 60 * 24 * 7 * 4)).toEqual({
start: '2019-12-04T00:00:00.000Z',
end: nowString,
});
});
});
});
......@@ -638,6 +638,12 @@ msgstr ""
msgid "1st contribution!"
msgstr ""
msgid "2 days"
msgstr ""
msgid "2 weeks"
msgstr ""
msgid "20-29 contributions"
msgstr ""
......@@ -665,6 +671,9 @@ msgstr ""
msgid "30+ contributions"
msgstr ""
msgid "4 hours"
msgstr ""
msgid "403|Please contact your GitLab administrator to get permission."
msgstr ""
......@@ -7183,6 +7192,9 @@ msgstr ""
msgid "Environments|Show all"
msgstr ""
msgid "Environments|Show last"
msgstr ""
msgid "Environments|Stop"
msgstr ""
......@@ -13082,6 +13094,9 @@ msgstr ""
msgid "Past due"
msgstr ""
msgid "Past week"
msgstr ""
msgid "Paste a machine public key here. Read more about how to generate it %{link_start}here%{link_end}"
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