Commit bec477eb authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '198050-frontend-pagination-in-log-explorer' into 'master'

Add infinite scrolling to logs

Closes #206936

See merge request gitlab-org/gitlab!26254
parents 5dfbaf36 198cf31f
...@@ -492,41 +492,6 @@ const Api = { ...@@ -492,41 +492,6 @@ const Api = {
buildUrl(url) { buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
}, },
/**
* Returns pods logs for an environment with an optional pod and container
*
* @param {Object} params
* @param {Object} param.environment - Environment object
* @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({ environment, podName, containerName, search, start, end }) {
const url = this.buildUrl(environment.logs_api_path);
const params = {};
if (podName) {
params.pod_name = podName;
}
if (containerName) {
params.container_name = containerName;
}
if (search) {
params.search = search;
}
if (start) {
params.start = start;
}
if (end) {
params.end = end;
}
return axios.get(url, { params });
},
}; };
export default Api; export default Api;
<script> <script>
import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { GlDropdown, GlDropdownItem, GlFormGroup, GlSearchBoxByClick, GlAlert } from '@gitlab/ui'; import {
GlSprintf,
GlAlert,
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlSearchBoxByClick,
GlInfiniteScroll,
} from '@gitlab/ui';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { scrollDown } from '~/lib/utils/scroll_utils';
import LogControlButtons from './log_control_buttons.vue'; import LogControlButtons from './log_control_buttons.vue';
import { timeRanges, defaultTimeRange } from '~/monitoring/constants'; import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
import { timeRangeFromUrl } from '~/monitoring/utils'; import { timeRangeFromUrl } from '~/monitoring/utils';
import { formatDate } from '../utils';
export default { export default {
components: { components: {
GlSprintf,
GlAlert, GlAlert,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlFormGroup, GlFormGroup,
GlSearchBoxByClick, GlSearchBoxByClick,
GlInfiniteScroll,
DateTimePicker, DateTimePicker,
LogControlButtons, LogControlButtons,
}, },
filters: {
formatDate,
},
props: { props: {
environmentName: { environmentName: {
type: String, type: String,
...@@ -39,11 +53,13 @@ export default { ...@@ -39,11 +53,13 @@ export default {
required: true, required: true,
}, },
}, },
traceHeight: 600,
data() { data() {
return { return {
searchQuery: '', searchQuery: '',
timeRanges, timeRanges,
isElasticStackCalloutDismissed: false, isElasticStackCalloutDismissed: false,
scrollDownButtonDisabled: true,
}; };
}, },
computed: { computed: {
...@@ -52,7 +68,7 @@ export default { ...@@ -52,7 +68,7 @@ export default {
timeRangeModel: { timeRangeModel: {
get() { get() {
return this.timeRange.current; return this.timeRange.selected;
}, },
set(val) { set(val) {
this.setTimeRange(val); this.setTimeRange(val);
...@@ -60,7 +76,7 @@ export default { ...@@ -60,7 +76,7 @@ export default {
}, },
showLoader() { showLoader() {
return this.logs.isLoading || !this.logs.isComplete; return this.logs.isLoading;
}, },
advancedFeaturesEnabled() { advancedFeaturesEnabled() {
const environment = this.environments.options.find( const environment = this.environments.options.find(
...@@ -75,16 +91,6 @@ export default { ...@@ -75,16 +91,6 @@ export default {
return !this.isElasticStackCalloutDismissed && this.disableAdvancedControls; return !this.isElasticStackCalloutDismissed && this.disableAdvancedControls;
}, },
}, },
watch: {
trace(val) {
this.$nextTick(() => {
if (val) {
scrollDown();
}
this.$refs.scrollButtons.update();
});
},
},
mounted() { mounted() {
this.setInitData({ this.setInitData({
timeRange: timeRangeFromUrl() || defaultTimeRange, timeRange: timeRangeFromUrl() || defaultTimeRange,
...@@ -102,12 +108,26 @@ export default { ...@@ -102,12 +108,26 @@ export default {
'showPodLogs', 'showPodLogs',
'showEnvironment', 'showEnvironment',
'fetchEnvironments', 'fetchEnvironments',
'fetchMoreLogsPrepend',
]), ]),
topReached() {
if (!this.logs.isLoading) {
this.fetchMoreLogsPrepend();
}
},
scrollDown() {
this.$refs.infiniteScroll.scrollDown();
},
scroll: throttle(function scrollThrottled({ target = {} }) {
const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
}, 200),
}, },
}; };
</script> </script>
<template> <template>
<div class="build-page-pod-logs mt-3"> <div class="environment-logs-viewer mt-3">
<gl-alert <gl-alert
v-if="shouldShowElasticStackCallout" v-if="shouldShowElasticStackCallout"
class="mb-3 js-elasticsearch-alert" class="mb-3 js-elasticsearch-alert"
...@@ -209,14 +229,50 @@ export default { ...@@ -209,14 +229,50 @@ export default {
<log-control-buttons <log-control-buttons
ref="scrollButtons" ref="scrollButtons"
class="controllers align-self-end mb-1" class="controllers align-self-end mb-1"
:scroll-down-button-disabled="scrollDownButtonDisabled"
@refresh="showPodLogs(pods.current)" @refresh="showPodLogs(pods.current)"
@scrollDown="scrollDown"
/> />
</div> </div>
<pre class="build-trace js-log-trace"><code class="bash js-build-output">{{trace}}
<div v-if="showLoader" class="build-loader-animation js-build-loader-animation"> <gl-infinite-scroll
<div class="dot"></div> ref="infiniteScroll"
<div class="dot"></div> class="log-lines"
<div class="dot"></div> :style="{ height: `${$options.traceHeight}px` }"
</div></code></pre> :max-list-height="$options.traceHeight"
:fetched-items="logs.lines.length"
@topReached="topReached"
@scroll="scroll"
>
<template #items>
<pre
class="build-trace js-log-trace"
><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>{{trace}}
</code></pre>
</template>
<template #default
><div></div
></template>
</gl-infinite-scroll>
<div ref="logFooter" class="log-footer py-2 px-3">
<gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
<template #start>{{ timeRange.current.start | formatDate }}</template>
<template #end>{{ timeRange.current.end | formatDate }}</template>
</gl-sprintf>
<gl-sprintf
v-if="!logs.isComplete"
:message="s__('Environments|Currently showing %{fetched} results.')"
>
<template #fetched>{{ logs.lines.length }}</template>
</gl-sprintf>
<template v-else>
{{ s__('Environments|Currently showing all results.') }}</template
>
</div>
</div> </div>
</template> </template>
<script> <script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import {
canScroll,
isScrolledToTop,
isScrolledToBottom,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
export default { export default {
...@@ -17,32 +10,34 @@ export default { ...@@ -17,32 +10,34 @@ export default {
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
props: {
scrollUpButtonDisabled: {
type: Boolean,
required: false,
default: false,
},
scrollDownButtonDisabled: {
type: Boolean,
required: false,
default: false,
},
},
data() { data() {
return { return {
scrollToTopEnabled: false, scrollUpAvailable: Boolean(this.$listeners.scrollUp),
scrollToBottomEnabled: false, scrollDownAvailable: Boolean(this.$listeners.scrollDown),
}; };
}, },
created() {
window.addEventListener('scroll', this.update);
},
destroyed() {
window.removeEventListener('scroll', this.update);
},
methods: { methods: {
/**
* Checks if page can be scrolled and updates
* enabled/disabled state of buttons accordingly
*/
update() {
this.scrollToTopEnabled = canScroll() && !isScrolledToTop();
this.scrollToBottomEnabled = canScroll() && !isScrolledToBottom();
},
handleRefreshClick() { handleRefreshClick() {
this.$emit('refresh'); this.$emit('refresh');
}, },
scrollUp, handleScrollUp() {
scrollDown, this.$emit('scrollUp');
},
handleScrollDown() {
this.$emit('scrollDown');
},
}, },
}; };
</script> </script>
...@@ -50,6 +45,7 @@ export default { ...@@ -50,6 +45,7 @@ export default {
<template> <template>
<div> <div>
<div <div
v-if="scrollUpAvailable"
v-gl-tooltip v-gl-tooltip
class="controllers-buttons" class="controllers-buttons"
:title="__('Scroll to top')" :title="__('Scroll to top')"
...@@ -59,13 +55,15 @@ export default { ...@@ -59,13 +55,15 @@ export default {
id="scroll-to-top" id="scroll-to-top"
class="btn-blank js-scroll-to-top" class="btn-blank js-scroll-to-top"
:aria-label="__('Scroll to top')" :aria-label="__('Scroll to top')"
:disabled="!scrollToTopEnabled" :disabled="scrollUpButtonDisabled"
@click="scrollUp()" @click="handleScrollUp()"
><icon name="scroll_up" ><icon name="scroll_up"
/></gl-button> /></gl-button>
</div> </div>
<div <div
v-if="scrollDownAvailable"
v-gl-tooltip v-gl-tooltip
:disabled="scrollUpButtonDisabled"
class="controllers-buttons" class="controllers-buttons"
:title="__('Scroll to bottom')" :title="__('Scroll to bottom')"
aria-labelledby="scroll-to-bottom" aria-labelledby="scroll-to-bottom"
...@@ -74,8 +72,9 @@ export default { ...@@ -74,8 +72,9 @@ export default {
id="scroll-to-bottom" id="scroll-to-bottom"
class="btn-blank js-scroll-to-bottom" class="btn-blank js-scroll-to-bottom"
:aria-label="__('Scroll to bottom')" :aria-label="__('Scroll to bottom')"
:disabled="!scrollToBottomEnabled" :v-if="scrollDownAvailable"
@click="scrollDown()" :disabled="scrollDownButtonDisabled"
@click="handleScrollDown()"
><icon name="scroll_down" ><icon name="scroll_down"
/></gl-button> /></gl-button>
</div> </div>
......
import Api from '~/api';
import { backOff } from '~/lib/utils/common_utils'; import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -16,9 +15,10 @@ const flashLogsError = () => { ...@@ -16,9 +15,10 @@ const flashLogsError = () => {
flash(s__('Metrics|There was an error fetching the logs, please try again')); flash(s__('Metrics|There was an error fetching the logs, please try again'));
}; };
const requestLogsUntilData = params => const requestUntilData = (url, params) =>
backOff((next, stop) => { backOff((next, stop) => {
Api.getPodLogs(params) axios
.get(url, { params })
.then(res => { .then(res => {
if (res.status === httpStatusCodes.ACCEPTED) { if (res.status === httpStatusCodes.ACCEPTED) {
next(); next();
...@@ -31,10 +31,36 @@ const requestLogsUntilData = params => ...@@ -31,10 +31,36 @@ const requestLogsUntilData = params =>
}); });
}); });
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => { const requestLogsUntilData = state => {
if (timeRange) { const params = {};
commit(types.SET_TIME_RANGE, timeRange); const { logs_api_path } = state.environments.options.find(
({ name }) => name === state.environments.current,
);
if (state.pods.current) {
params.pod_name = state.pods.current;
}
if (state.search) {
params.search = state.search;
}
if (state.timeRange.current) {
try {
const { start, end } = convertToFixedRange(state.timeRange.current);
params.start = start;
params.end = end;
} catch {
flashTimeRangeWarning();
}
}
if (state.logs.cursor) {
params.cursor = state.logs.cursor;
} }
return requestUntilData(logs_api_path, params);
};
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
commit(types.SET_TIME_RANGE, timeRange);
commit(types.SET_PROJECT_ENVIRONMENT, environmentName); commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, podName); commit(types.SET_CURRENT_POD_NAME, podName);
}; };
...@@ -60,10 +86,15 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => { ...@@ -60,10 +86,15 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => {
dispatch('fetchLogs'); dispatch('fetchLogs');
}; };
/**
* Fetch environments data and initial logs
* @param {Object} store
* @param {String} environmentsPath
*/
export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => { export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
commit(types.REQUEST_ENVIRONMENTS_DATA); commit(types.REQUEST_ENVIRONMENTS_DATA);
axios return axios
.get(environmentsPath) .get(environmentsPath)
.then(({ data }) => { .then(({ data }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments); commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
...@@ -76,32 +107,16 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => { ...@@ -76,32 +107,16 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
}; };
export const fetchLogs = ({ commit, state }) => { export const fetchLogs = ({ commit, state }) => {
const params = {
environment: state.environments.options.find(({ name }) => name === state.environments.current),
podName: state.pods.current,
search: state.search,
};
if (state.timeRange.current) {
try {
const { start, end } = convertToFixedRange(state.timeRange.current);
params.start = start;
params.end = end;
} catch {
flashTimeRangeWarning();
}
}
commit(types.REQUEST_PODS_DATA); commit(types.REQUEST_PODS_DATA);
commit(types.REQUEST_LOGS_DATA); commit(types.REQUEST_LOGS_DATA);
return requestLogsUntilData(params) return requestLogsUntilData(state)
.then(({ data }) => { .then(({ data }) => {
const { pod_name, pods, logs } = data; const { pod_name, pods, logs, cursor } = data;
commit(types.SET_CURRENT_POD_NAME, pod_name); commit(types.SET_CURRENT_POD_NAME, pod_name);
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods); commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
commit(types.RECEIVE_LOGS_DATA_SUCCESS, logs); commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
}) })
.catch(() => { .catch(() => {
commit(types.RECEIVE_PODS_DATA_ERROR); commit(types.RECEIVE_PODS_DATA_ERROR);
...@@ -110,5 +125,24 @@ export const fetchLogs = ({ commit, state }) => { ...@@ -110,5 +125,24 @@ export const fetchLogs = ({ commit, state }) => {
}); });
}; };
export const fetchMoreLogsPrepend = ({ commit, state }) => {
if (state.logs.isComplete) {
// return when all logs are loaded
return Promise.resolve();
}
commit(types.REQUEST_LOGS_DATA_PREPEND);
return requestLogsUntilData(state)
.then(({ data }) => {
const { logs, cursor } = data;
commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor });
})
.catch(() => {
commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR);
flashLogsError();
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
import dateFormat from 'dateformat'; import { formatDate } from '../utils';
export const trace = state => const mapTrace = ({ timestamp = null, message = '' }) =>
state.logs.lines [timestamp ? formatDate(timestamp) : '', message].join(' | ');
.map(item => [dateFormat(item.timestamp, 'UTC:mmm dd HH:MM:ss.l"Z"'), item.message].join(' | '))
.join('\n'); export const trace = state => state.logs.lines.map(mapTrace).join('\n');
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -10,6 +10,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR' ...@@ -10,6 +10,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR'
export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA'; export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS'; export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR'; export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND';
export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS';
export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR';
export const REQUEST_PODS_DATA = 'REQUEST_PODS_DATA'; export const REQUEST_PODS_DATA = 'REQUEST_PODS_DATA';
export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS'; export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
const mapLine = ({ timestamp, message }) => ({
timestamp,
message,
});
export default { export default {
/** Search data */ // Search Data
[types.SET_SEARCH](state, searchQuery) { [types.SET_SEARCH](state, searchQuery) {
state.search = searchQuery; state.search = searchQuery;
}, },
/** Time Range data */ // Time Range Data
[types.SET_TIME_RANGE](state, timeRange) { [types.SET_TIME_RANGE](state, timeRange) {
state.timeRange.current = timeRange; state.timeRange.selected = timeRange;
state.timeRange.current = convertToFixedRange(timeRange);
}, },
/** Environments data */ // Environments Data
[types.SET_PROJECT_ENVIRONMENT](state, environmentName) { [types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
state.environments.current = environmentName; state.environments.current = environmentName;
}, },
...@@ -28,24 +35,49 @@ export default { ...@@ -28,24 +35,49 @@ export default {
state.environments.isLoading = false; state.environments.isLoading = false;
}, },
/** Logs data */ // Logs data
[types.REQUEST_LOGS_DATA](state) { [types.REQUEST_LOGS_DATA](state) {
state.timeRange.current = convertToFixedRange(state.timeRange.selected);
state.logs.lines = []; state.logs.lines = [];
state.logs.isLoading = true; state.logs.isLoading = true;
// start pagination from the beginning
state.logs.cursor = null;
state.logs.isComplete = false; state.logs.isComplete = false;
}, },
[types.RECEIVE_LOGS_DATA_SUCCESS](state, lines) { [types.RECEIVE_LOGS_DATA_SUCCESS](state, { logs = [], cursor }) {
state.logs.lines = lines; state.logs.lines = logs.map(mapLine);
state.logs.isLoading = false; state.logs.isLoading = false;
state.logs.isComplete = true; state.logs.cursor = cursor;
if (!cursor) {
state.logs.isComplete = true;
}
}, },
[types.RECEIVE_LOGS_DATA_ERROR](state) { [types.RECEIVE_LOGS_DATA_ERROR](state) {
state.logs.lines = []; state.logs.lines = [];
state.logs.isLoading = false; state.logs.isLoading = false;
state.logs.isComplete = true;
}, },
/** Pods data */ [types.REQUEST_LOGS_DATA_PREPEND](state) {
state.logs.isLoading = true;
},
[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { logs = [], cursor }) {
const lines = logs.map(mapLine);
state.logs.lines = lines.concat(state.logs.lines);
state.logs.isLoading = false;
state.logs.cursor = cursor;
if (!cursor) {
state.logs.isComplete = true;
}
},
[types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state) {
state.logs.isLoading = false;
},
// Pods data
[types.SET_CURRENT_POD_NAME](state, podName) { [types.SET_CURRENT_POD_NAME](state, podName) {
state.pods.current = podName; state.pods.current = podName;
}, },
......
import { timeRanges, defaultTimeRange } from '~/monitoring/constants'; import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
export default () => ({ export default () => ({
/** /**
...@@ -11,7 +12,10 @@ export default () => ({ ...@@ -11,7 +12,10 @@ export default () => ({
*/ */
timeRange: { timeRange: {
options: timeRanges, options: timeRanges,
current: defaultTimeRange, // Selected time range, can be fixed or relative
selected: defaultTimeRange,
// Current time range, must be fixed
current: convertToFixedRange(defaultTimeRange),
}, },
/** /**
...@@ -29,7 +33,12 @@ export default () => ({ ...@@ -29,7 +33,12 @@ export default () => ({
logs: { logs: {
lines: [], lines: [],
isLoading: false, isLoading: false,
isComplete: true, /**
* Logs `cursor` represents the current pagination position,
* Should be sent in next batch (page) of logs to be fetched
*/
cursor: null,
isComplete: false,
}, },
/** /**
......
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import dateFormat from 'dateformat';
const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
/** /**
* Returns a time range (`start`, `end`) where `start` is the * Returns a time range (`start`, `end`) where `start` is the
...@@ -20,4 +23,6 @@ export const getTimeRange = (seconds = 0) => { ...@@ -20,4 +23,6 @@ export const getTimeRange = (seconds = 0) => {
}; };
}; };
export const formatDate = timestamp => dateFormat(timestamp, dateFormatMask);
export default {}; export default {};
...@@ -257,7 +257,6 @@ ...@@ -257,7 +257,6 @@
width: 15px; width: 15px;
height: 15px; height: 15px;
display: $svg-display; display: $svg-display;
fill: $gl-text-color;
top: $svg-top; top: $svg-top;
} }
......
...@@ -358,17 +358,30 @@ ...@@ -358,17 +358,30 @@
} }
} }
.build-page-pod-logs { .environment-logs-viewer {
.build-trace-container { .build-trace-container {
position: relative; position: relative;
} }
.log-lines,
.gl-infinite-scroll-container {
// makes scrollbar visible by creating contrast
background: $black;
}
.gl-infinite-scroll-legend {
margin: 0;
}
.build-trace { .build-trace {
@include build-trace(); @include build-trace();
margin: 0;
} }
.top-bar { .top-bar {
@include build-trace-top-bar($gl-line-height * 5); @include build-trace-top-bar($gl-line-height * 5);
position: relative;
top: 0;
.dropdown-menu-toggle { .dropdown-menu-toggle {
width: 200px; width: 200px;
...@@ -395,4 +408,9 @@ ...@@ -395,4 +408,9 @@
.build-loader-animation { .build-loader-animation {
@include build-loader-animation; @include build-loader-animation;
} }
.log-footer {
color: $white-normal;
background-color: $gray-900;
}
} }
---
title: More logs entries are loaded when logs are scrolled to the top
merge_request: 26254
author:
type: added
...@@ -46,13 +46,15 @@ Logs can be displayed by clicking on a specific pod from [Deploy Boards](../depl ...@@ -46,13 +46,15 @@ Logs can be displayed by clicking on a specific pod from [Deploy Boards](../depl
### Logs view ### Logs view
The logs view will contain the last 500 lines for a pod, and has control to filter through: The logs view lets you filter the logs by:
- Pods. - Pods.
- [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments. - [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments.
- [From GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/21656), [full text search](#full-text-search). - [From GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/21656), [full text search](#full-text-search).
- [From GitLab 12.8](https://gitlab.com/gitlab-org/gitlab/issues/197879), dates. - [From GitLab 12.8](https://gitlab.com/gitlab-org/gitlab/issues/197879), dates.
Loading more than 500 log lines is possible from [GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/-/issues/198050) onwards.
Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/13404). Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/13404).
Support for historical data is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/196191). Support for historical data is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/196191).
......
...@@ -5,8 +5,6 @@ require 'spec_helper' ...@@ -5,8 +5,6 @@ require 'spec_helper'
describe 'Environment > Pod Logs', :js do describe 'Environment > Pod Logs', :js do
include KubernetesHelpers include KubernetesHelpers
SCROLL_DISTANCE = 400
let(:pod_names) { %w(kube-pod) } let(:pod_names) { %w(kube-pod) }
let(:pod_name) { pod_names.first } let(:pod_name) { pod_names.first }
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
...@@ -62,49 +60,4 @@ describe 'Environment > Pod Logs', :js do ...@@ -62,49 +60,4 @@ describe 'Environment > Pod Logs', :js do
expect(page).to have_content("Dec 13 14:04:22.123Z | Log 1 Dec 13 14:04:23.123Z | Log 2 Dec 13 14:04:24.123Z | Log 3") expect(page).to have_content("Dec 13 14:04:22.123Z | Log 1 Dec 13 14:04:23.123Z | Log 2 Dec 13 14:04:24.123Z | Log 3")
end end
end end
context 'with perf bar enabled' do
before do
allow(Gitlab::PerformanceBar).to receive(:enabled_for_request?).and_return(true)
end
it 'log header sticks to top' do
load_and_scroll_down
expect(log_header_top).to eq(navbar_height + perf_bar_height)
end
end
context 'with perf bar disabled' do
it 'log header sticks to top' do
load_and_scroll_down
expect(log_header_top).to eq(navbar_height)
end
end
def load_and_scroll_down
visit project_logs_path(environment.project, environment_name: environment.name, pod_name: pod_name)
wait_for_requests
scroll_down_build_log
end
def scroll_down_build_log
page.execute_script("$('.js-build-output').height('200vh')")
page.execute_script("window.scrollTo(0, #{SCROLL_DISTANCE})")
end
def perf_bar_height
page.evaluate_script("$('#js-peek').height()").to_i
end
def navbar_height
page.evaluate_script("$('.js-navbar').height()").to_i
end
def log_header_top
page.evaluate_script("$('.js-top-bar').offset().top") - SCROLL_DISTANCE
end
end end
...@@ -165,77 +165,6 @@ describe('Api', () => { ...@@ -165,77 +165,6 @@ describe('Api', () => {
}); });
}); });
describe('getPodLogs', () => {
const projectPath = '/root/test-project';
const podName = 'pod';
const containerName = 'container';
const search = 'foo +bar';
const expectedUrl = '/gitlab/dummy_api_path.json';
const environment = {
enable_advanced_logs_querying: false,
project_path: projectPath,
logs_api_path: '/dummy_api_path.json',
};
const getRequest = () => mock.history.get[0];
beforeEach(() => {
mock.onAny().reply(200);
});
afterEach(() => {
mock.reset();
});
it('calls `axios.get` with pod_name and container_name', done => {
Api.getPodLogs({ environment, podName, containerName })
.then(() => {
expect(getRequest().url).toBe(expectedUrl);
expect(getRequest().params).toEqual({
pod_name: podName,
container_name: containerName,
});
})
.then(done)
.catch(done.fail);
});
it('calls `axios.get` without pod_name and container_name', done => {
Api.getPodLogs({ environment })
.then(() => {
expect(getRequest().url).toBe(expectedUrl);
expect(getRequest().params).toEqual({});
})
.then(done)
.catch(done.fail);
});
it('calls `axios.get` with pod_name', done => {
Api.getPodLogs({ environment, podName })
.then(() => {
expect(getRequest().url).toBe(expectedUrl);
expect(getRequest().params).toEqual({
pod_name: podName,
});
})
.then(done)
.catch(done.fail);
});
it('calls `axios.get` with pod_name and search', done => {
Api.getPodLogs({ environment, podName, search })
.then(() => {
expect(getRequest().url).toBe(expectedUrl);
expect(getRequest().params).toEqual({
pod_name: podName,
search,
});
})
.then(done)
.catch(done.fail);
});
});
describe('packages', () => { describe('packages', () => {
const projectId = 'project_a'; const projectId = 'project_a';
const packageId = 'package_b'; const packageId = 'package_b';
......
...@@ -7634,6 +7634,12 @@ msgstr "" ...@@ -7634,6 +7634,12 @@ msgstr ""
msgid "Environments|Commit" msgid "Environments|Commit"
msgstr "" msgstr ""
msgid "Environments|Currently showing %{fetched} results."
msgstr ""
msgid "Environments|Currently showing all results."
msgstr ""
msgid "Environments|Deploy to..." msgid "Environments|Deploy to..."
msgstr "" msgstr ""
...@@ -7667,6 +7673,9 @@ msgstr "" ...@@ -7667,6 +7673,9 @@ msgstr ""
msgid "Environments|Logs from" msgid "Environments|Logs from"
msgstr "" msgstr ""
msgid "Environments|Logs from %{start} to %{end}."
msgstr ""
msgid "Environments|New environment" msgid "Environments|New environment"
msgstr "" msgstr ""
......
import Vue from 'vue'; import Vue from 'vue';
import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui'; import { GlSprintf, GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import EnvironmentLogs from '~/logs/components/environment_logs.vue'; import EnvironmentLogs from '~/logs/components/environment_logs.vue';
...@@ -20,9 +20,18 @@ import { ...@@ -20,9 +20,18 @@ import {
jest.mock('~/lib/utils/scroll_utils'); jest.mock('~/lib/utils/scroll_utils');
const module = 'environmentLogs';
jest.mock('lodash/throttle', () =>
jest.fn(func => {
return func;
}),
);
describe('EnvironmentLogs', () => { describe('EnvironmentLogs', () => {
let EnvironmentLogsComponent; let EnvironmentLogsComponent;
let store; let store;
let dispatch;
let wrapper; let wrapper;
let state; let state;
...@@ -32,14 +41,6 @@ describe('EnvironmentLogs', () => { ...@@ -32,14 +41,6 @@ describe('EnvironmentLogs', () => {
clusterApplicationsDocumentationPath: mockDocumentationPath, clusterApplicationsDocumentationPath: mockDocumentationPath,
}; };
const actionMocks = {
setInitData: jest.fn(),
setSearch: jest.fn(),
showPodLogs: jest.fn(),
showEnvironment: jest.fn(),
fetchEnvironments: jest.fn(),
};
const updateControlBtnsMock = jest.fn(); const updateControlBtnsMock = jest.fn();
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown'); const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
...@@ -47,24 +48,25 @@ describe('EnvironmentLogs', () => { ...@@ -47,24 +48,25 @@ describe('EnvironmentLogs', () => {
const findSearchBar = () => wrapper.find('.js-logs-search'); const findSearchBar = () => wrapper.find('.js-logs-search');
const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' }); const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
const findInfoAlert = () => wrapper.find('.js-elasticsearch-alert'); const findInfoAlert = () => wrapper.find('.js-elasticsearch-alert');
const findLogControlButtons = () => wrapper.find({ name: 'log-control-buttons-stub' }); const findLogControlButtons = () => wrapper.find({ name: 'log-control-buttons-stub' });
const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' });
const findLogTrace = () => wrapper.find('.js-log-trace'); const findLogTrace = () => wrapper.find('.js-log-trace');
const findLogFooter = () => wrapper.find({ ref: 'logFooter' });
const getInfiniteScrollAttr = attr => parseInt(findInfiniteScroll().attributes(attr), 10);
const mockSetInitData = () => { const mockSetInitData = () => {
state.pods.options = mockPods; state.pods.options = mockPods;
state.environments.current = mockEnvName; state.environments.current = mockEnvName;
[state.pods.current] = state.pods.options; [state.pods.current] = state.pods.options;
state.logs.isComplete = false; state.logs.lines = [];
state.logs.lines = mockLogsResult;
}; };
const mockShowPodLogs = podName => { const mockShowPodLogs = () => {
state.pods.options = mockPods; state.pods.options = mockPods;
[state.pods.current] = podName; [state.pods.current] = mockPods;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult; state.logs.lines = mockLogsResult;
}; };
...@@ -83,10 +85,21 @@ describe('EnvironmentLogs', () => { ...@@ -83,10 +85,21 @@ describe('EnvironmentLogs', () => {
methods: { methods: {
update: updateControlBtnsMock, update: updateControlBtnsMock,
}, },
props: {
scrollDownButtonDisabled: false,
},
}, },
}, GlInfiniteScroll: {
methods: { name: 'gl-infinite-scroll',
...actionMocks, template: `
<div>
<slot name="header"></slot>
<slot name="items"></slot>
<slot></slot>
</div>
`,
},
GlSprintf,
}, },
}); });
}; };
...@@ -95,12 +108,14 @@ describe('EnvironmentLogs', () => { ...@@ -95,12 +108,14 @@ describe('EnvironmentLogs', () => {
store = createStore(); store = createStore();
state = store.state.environmentLogs; state = store.state.environmentLogs;
EnvironmentLogsComponent = Vue.extend(EnvironmentLogs); EnvironmentLogsComponent = Vue.extend(EnvironmentLogs);
jest.spyOn(store, 'dispatch').mockResolvedValue();
dispatch = store.dispatch;
}); });
afterEach(() => { afterEach(() => {
actionMocks.setInitData.mockReset(); store.dispatch.mockReset();
actionMocks.showPodLogs.mockReset();
actionMocks.fetchEnvironments.mockReset();
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
...@@ -124,14 +139,14 @@ describe('EnvironmentLogs', () => { ...@@ -124,14 +139,14 @@ describe('EnvironmentLogs', () => {
expect(findTimeRangePicker().is(DateTimePicker)).toBe(true); expect(findTimeRangePicker().is(DateTimePicker)).toBe(true);
// log trace // log trace
expect(findLogTrace().isEmpty()).toBe(false); expect(findInfiniteScroll().exists()).toBe(true);
expect(findLogTrace().exists()).toBe(true);
}); });
it('mounted inits data', () => { it('mounted inits data', () => {
initWrapper(); initWrapper();
expect(actionMocks.setInitData).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, {
expect(actionMocks.setInitData).toHaveBeenLastCalledWith({
timeRange: expect.objectContaining({ timeRange: expect.objectContaining({
default: true, default: true,
}), }),
...@@ -139,18 +154,15 @@ describe('EnvironmentLogs', () => { ...@@ -139,18 +154,15 @@ describe('EnvironmentLogs', () => {
podName: null, podName: null,
}); });
expect(actionMocks.fetchEnvironments).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith(`${module}/fetchEnvironments`, mockEnvironmentsEndpoint);
expect(actionMocks.fetchEnvironments).toHaveBeenLastCalledWith(mockEnvironmentsEndpoint);
}); });
describe('loading state', () => { describe('loading state', () => {
beforeEach(() => { beforeEach(() => {
state.pods.options = []; state.pods.options = [];
state.logs = { state.logs.lines = [];
lines: [], state.logs.isLoading = true;
isLoading: true,
};
state.environments = { state.environments = {
options: [], options: [],
...@@ -183,6 +195,18 @@ describe('EnvironmentLogs', () => { ...@@ -183,6 +195,18 @@ describe('EnvironmentLogs', () => {
expect(updateControlBtnsMock).not.toHaveBeenCalled(); expect(updateControlBtnsMock).not.toHaveBeenCalled();
}); });
it('shows an infinite scroll with height and no content', () => {
expect(getInfiniteScrollAttr('max-list-height')).toBeGreaterThan(0);
expect(getInfiniteScrollAttr('fetched-items')).toBe(0);
});
it('shows an infinite scroll container with equal height and max-height ', () => {
const height = getInfiniteScrollAttr('max-list-height');
expect(height).toEqual(expect.any(Number));
expect(findInfiniteScroll().attributes('style')).toMatch(`height: ${height}px;`);
});
it('shows a logs trace', () => { it('shows a logs trace', () => {
expect(findLogTrace().text()).toBe(''); expect(findLogTrace().text()).toBe('');
expect( expect(
...@@ -193,14 +217,12 @@ describe('EnvironmentLogs', () => { ...@@ -193,14 +217,12 @@ describe('EnvironmentLogs', () => {
}); });
}); });
describe('legacy environment', () => { describe('k8s environment', () => {
beforeEach(() => { beforeEach(() => {
state.pods.options = []; state.pods.options = [];
state.logs = { state.logs.lines = [];
lines: [], state.logs.isLoading = false;
isLoading: false,
};
state.environments = { state.environments = {
options: mockEnvironments, options: mockEnvironments,
...@@ -226,9 +248,16 @@ describe('EnvironmentLogs', () => { ...@@ -226,9 +248,16 @@ describe('EnvironmentLogs', () => {
describe('state with data', () => { describe('state with data', () => {
beforeEach(() => { beforeEach(() => {
actionMocks.setInitData.mockImplementation(mockSetInitData); dispatch.mockImplementation(actionName => {
actionMocks.showPodLogs.mockImplementation(mockShowPodLogs); if (actionName === `${module}/setInitData`) {
actionMocks.fetchEnvironments.mockImplementation(mockFetchEnvs); mockSetInitData();
} else if (actionName === `${module}/showPodLogs`) {
mockShowPodLogs();
} else if (actionName === `${module}/fetchEnvironments`) {
mockFetchEnvs();
mockShowPodLogs();
}
});
initWrapper(); initWrapper();
}); });
...@@ -236,10 +265,6 @@ describe('EnvironmentLogs', () => { ...@@ -236,10 +265,6 @@ describe('EnvironmentLogs', () => {
afterEach(() => { afterEach(() => {
scrollDown.mockReset(); scrollDown.mockReset();
updateControlBtnsMock.mockReset(); updateControlBtnsMock.mockReset();
actionMocks.setInitData.mockReset();
actionMocks.showPodLogs.mockReset();
actionMocks.fetchEnvironments.mockReset();
}); });
it('displays an enabled search bar', () => { it('displays an enabled search bar', () => {
...@@ -249,8 +274,8 @@ describe('EnvironmentLogs', () => { ...@@ -249,8 +274,8 @@ describe('EnvironmentLogs', () => {
findSearchBar().vm.$emit('input', mockSearch); findSearchBar().vm.$emit('input', mockSearch);
findSearchBar().vm.$emit('submit'); findSearchBar().vm.$emit('submit');
expect(actionMocks.setSearch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, expect.any(Object));
expect(actionMocks.setSearch).toHaveBeenCalledWith(mockSearch); expect(dispatch).toHaveBeenCalledWith(`${module}/setSearch`, mockSearch);
}); });
it('displays an enabled time window dropdown', () => { it('displays an enabled time window dropdown', () => {
...@@ -282,18 +307,21 @@ describe('EnvironmentLogs', () => { ...@@ -282,18 +307,21 @@ describe('EnvironmentLogs', () => {
}); });
}); });
it('shows infinite scroll with height and no content', () => {
expect(getInfiniteScrollAttr('max-list-height')).toBeGreaterThan(0);
expect(getInfiniteScrollAttr('fetched-items')).toBe(mockTrace.length);
});
it('populates logs trace', () => { it('populates logs trace', () => {
const trace = findLogTrace(); const trace = findLogTrace();
expect(trace.text().split('\n').length).toBe(mockTrace.length); expect(trace.text().split('\n').length).toBe(mockTrace.length);
expect(trace.text().split('\n')).toEqual(mockTrace); expect(trace.text().split('\n')).toEqual(mockTrace);
}); });
it('update control buttons state', () => { it('populates footer', () => {
expect(updateControlBtnsMock).toHaveBeenCalledTimes(1); const footer = findLogFooter().text();
});
it('scrolls to bottom when loaded', () => { expect(footer).toContain(`${mockLogsResult.length} results`);
expect(scrollDown).toHaveBeenCalledTimes(1);
}); });
describe('when user clicks', () => { describe('when user clicks', () => {
...@@ -301,33 +329,99 @@ describe('EnvironmentLogs', () => { ...@@ -301,33 +329,99 @@ describe('EnvironmentLogs', () => {
const items = findEnvironmentsDropdown().findAll(GlDropdownItem); const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
const index = 1; // any env const index = 1; // any env
expect(actionMocks.showEnvironment).toHaveBeenCalledTimes(0); expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything());
items.at(index).vm.$emit('click'); items.at(index).vm.$emit('click');
expect(actionMocks.showEnvironment).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith(
expect(actionMocks.showEnvironment).toHaveBeenLastCalledWith(mockEnvironments[index].name); `${module}/showEnvironment`,
mockEnvironments[index].name,
);
}); });
it('pod name, trace is refreshed', () => { it('pod name, trace is refreshed', () => {
const items = findPodsDropdown().findAll(GlDropdownItem); const items = findPodsDropdown().findAll(GlDropdownItem);
const index = 2; // any pod const index = 2; // any pod
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(0); expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
items.at(index).vm.$emit('click'); items.at(index).vm.$emit('click');
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]);
expect(actionMocks.showPodLogs).toHaveBeenLastCalledWith(mockPods[index]);
}); });
it('refresh button, trace is refreshed', () => { it('refresh button, trace is refreshed', () => {
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(0); expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
findLogControlButtons().vm.$emit('refresh'); findLogControlButtons().vm.$emit('refresh');
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPodName);
expect(actionMocks.showPodLogs).toHaveBeenLastCalledWith(mockPodName); });
});
});
describe('listeners', () => {
beforeEach(() => {
initWrapper();
});
it('attaches listeners in components', () => {
expect(findInfiniteScroll().vm.$listeners).toEqual({
topReached: expect.any(Function),
scroll: expect.any(Function),
});
});
it('`topReached` when not loading', () => {
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`topReached` does not fetches more logs when already loading', () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`topReached` fetches more logs', () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`scroll` on a scrollable target results in enabled scroll buttons', () => {
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 21 };
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target });
return wrapper.vm.$nextTick(() => {
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(false);
});
});
it('`scroll` on a non-scrollable target in disabled scroll buttons', () => {
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 20 };
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target });
return wrapper.vm.$nextTick(() => {
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
});
});
it('`scroll` on no target results in disabled scroll buttons', () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target: undefined });
return wrapper.vm.$nextTick(() => {
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
}); });
}); });
}); });
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import LogControlButtons from '~/logs/components/log_control_buttons.vue'; import LogControlButtons from '~/logs/components/log_control_buttons.vue';
import {
canScroll,
isScrolledToTop,
isScrolledToBottom,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
jest.mock('~/lib/utils/scroll_utils');
describe('LogControlButtons', () => { describe('LogControlButtons', () => {
let wrapper; let wrapper;
...@@ -18,8 +9,14 @@ describe('LogControlButtons', () => { ...@@ -18,8 +9,14 @@ describe('LogControlButtons', () => {
const findScrollToBottom = () => wrapper.find('.js-scroll-to-bottom'); const findScrollToBottom = () => wrapper.find('.js-scroll-to-bottom');
const findRefreshBtn = () => wrapper.find('.js-refresh-log'); const findRefreshBtn = () => wrapper.find('.js-refresh-log');
const initWrapper = () => { const initWrapper = opts => {
wrapper = shallowMount(LogControlButtons); wrapper = shallowMount(LogControlButtons, {
listeners: {
scrollUp: () => {},
scrollDown: () => {},
},
...opts,
});
}; };
afterEach(() => { afterEach(() => {
...@@ -55,27 +52,16 @@ describe('LogControlButtons', () => { ...@@ -55,27 +52,16 @@ describe('LogControlButtons', () => {
describe('when scrolling actions are enabled', () => { describe('when scrolling actions are enabled', () => {
beforeEach(() => { beforeEach(() => {
// mock scrolled to the middle of a long page // mock scrolled to the middle of a long page
canScroll.mockReturnValue(true);
isScrolledToBottom.mockReturnValue(false);
isScrolledToTop.mockReturnValue(false);
initWrapper(); initWrapper();
wrapper.vm.update();
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
afterEach(() => {
canScroll.mockReset();
isScrolledToTop.mockReset();
isScrolledToBottom.mockReset();
});
it('click on "scroll to top" scrolls up', () => { it('click on "scroll to top" scrolls up', () => {
expect(findScrollToTop().is('[disabled]')).toBe(false); expect(findScrollToTop().is('[disabled]')).toBe(false);
findScrollToTop().vm.$emit('click'); findScrollToTop().vm.$emit('click');
expect(scrollUp).toHaveBeenCalledTimes(1); expect(wrapper.emitted('scrollUp')).toHaveLength(1);
}); });
it('click on "scroll to bottom" scrolls down', () => { it('click on "scroll to bottom" scrolls down', () => {
...@@ -83,25 +69,23 @@ describe('LogControlButtons', () => { ...@@ -83,25 +69,23 @@ describe('LogControlButtons', () => {
findScrollToBottom().vm.$emit('click'); findScrollToBottom().vm.$emit('click');
expect(scrollDown).toHaveBeenCalledTimes(1); // plus one time when trace was loaded expect(wrapper.emitted('scrollDown')).toHaveLength(1);
}); });
}); });
describe('when scrolling actions are disabled', () => { describe('when scrolling actions are disabled', () => {
beforeEach(() => { beforeEach(() => {
// mock a short page without a scrollbar initWrapper({ listeners: {} });
canScroll.mockReturnValue(false); return wrapper.vm.$nextTick();
isScrolledToBottom.mockReturnValue(true);
isScrolledToTop.mockReturnValue(true);
initWrapper();
}); });
it('buttons are disabled', () => { it('buttons are disabled', () => {
wrapper.vm.update();
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
expect(findScrollToTop().is('[disabled]')).toBe(true); expect(findScrollToTop().exists()).toBe(false);
expect(findScrollToBottom().is('[disabled]')).toBe(true); expect(findScrollToBottom().exists()).toBe(false);
// This should be enabled when gitlab-ui contains:
// https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1149
// expect(findScrollToBottom().is('[disabled]')).toBe(true);
}); });
}); });
}); });
......
export const mockProjectPath = 'root/autodevops-deploy'; const mockProjectPath = 'root/autodevops-deploy';
export const mockEnvName = 'production'; export const mockEnvName = 'production';
export const mockEnvironmentsEndpoint = `${mockProjectPath}/environments.json`; export const mockEnvironmentsEndpoint = `${mockProjectPath}/environments.json`;
export const mockEnvId = '99'; export const mockEnvId = '99';
export const mockDocumentationPath = '/documentation.md'; export const mockDocumentationPath = '/documentation.md';
export const mockLogsEndpoint = '/dummy_logs_path.json';
export const mockCursor = 'MOCK_CURSOR';
export const mockNextCursor = 'MOCK_NEXT_CURSOR';
const makeMockEnvironment = (id, name, advancedQuerying) => ({ const makeMockEnvironment = (id, name, advancedQuerying) => ({
id, id,
project_path: mockProjectPath, project_path: mockProjectPath,
name, name,
logs_api_path: '/dummy_logs_path.json', logs_api_path: mockLogsEndpoint,
enable_advanced_logs_querying: advancedQuerying, enable_advanced_logs_querying: advancedQuerying,
}); });
...@@ -28,58 +32,22 @@ export const mockPods = [ ...@@ -28,58 +32,22 @@ export const mockPods = [
]; ];
export const mockLogsResult = [ export const mockLogsResult = [
{ { timestamp: '2019-12-13T13:43:18.2760123Z', message: 'Log 1' },
timestamp: '2019-12-13T13:43:18.2760123Z', { timestamp: '2019-12-13T13:43:18.2760123Z', message: 'Log 2' },
message: '10.36.0.1 - - [16/Oct/2019:06:29:48 UTC] "GET / HTTP/1.1" 200 13', { timestamp: '2019-12-13T13:43:26.8420123Z', message: 'Log 3' },
},
{ timestamp: '2019-12-13T13:43:18.2760123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:26.8420123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:29:57 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:26.8420123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:28.3710123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:29:58 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:28.3710123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:36.8860123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:07 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:36.8860123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:38.4000123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:08 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:38.4000123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:46.8420123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:17 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:46.8430123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:48.3240123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:18 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:48.3250123Z', message: '- -> /' },
]; ];
export const mockTrace = [ export const mockTrace = [
'Dec 13 13:43:18.276Z | 10.36.0.1 - - [16/Oct/2019:06:29:48 UTC] "GET / HTTP/1.1" 200 13', 'Dec 13 13:43:18.276Z | Log 1',
'Dec 13 13:43:18.276Z | - -> /', 'Dec 13 13:43:18.276Z | Log 2',
'Dec 13 13:43:26.842Z | 10.36.0.1 - - [16/Oct/2019:06:29:57 UTC] "GET / HTTP/1.1" 200 13', 'Dec 13 13:43:26.842Z | Log 3',
'Dec 13 13:43:26.842Z | - -> /',
'Dec 13 13:43:28.371Z | 10.36.0.1 - - [16/Oct/2019:06:29:58 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:28.371Z | - -> /',
'Dec 13 13:43:36.886Z | 10.36.0.1 - - [16/Oct/2019:06:30:07 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:36.886Z | - -> /',
'Dec 13 13:43:38.400Z | 10.36.0.1 - - [16/Oct/2019:06:30:08 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:38.400Z | - -> /',
'Dec 13 13:43:46.842Z | 10.36.0.1 - - [16/Oct/2019:06:30:17 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:46.843Z | - -> /',
'Dec 13 13:43:48.324Z | 10.36.0.1 - - [16/Oct/2019:06:30:18 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:48.325Z | - -> /',
]; ];
export const mockResponse = {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
cursor: mockNextCursor,
};
export const mockSearch = 'foo +bar'; export const mockSearch = 'foo +bar';
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
showPodLogs, showPodLogs,
fetchEnvironments, fetchEnvironments,
fetchLogs, fetchLogs,
fetchMoreLogsPrepend,
} from '~/logs/stores/actions'; } from '~/logs/stores/actions';
import { defaultTimeRange } from '~/monitoring/constants'; import { defaultTimeRange } from '~/monitoring/constants';
...@@ -18,7 +19,6 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -18,7 +19,6 @@ import axios from '~/lib/utils/axios_utils';
import flash from '~/flash'; import flash from '~/flash';
import { import {
mockProjectPath,
mockPodName, mockPodName,
mockEnvironmentsEndpoint, mockEnvironmentsEndpoint,
mockEnvironments, mockEnvironments,
...@@ -26,6 +26,10 @@ import { ...@@ -26,6 +26,10 @@ import {
mockLogsResult, mockLogsResult,
mockEnvName, mockEnvName,
mockSearch, mockSearch,
mockLogsEndpoint,
mockResponse,
mockCursor,
mockNextCursor,
} from '../mock_data'; } from '../mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -52,6 +56,8 @@ describe('Logs Store actions', () => { ...@@ -52,6 +56,8 @@ describe('Logs Store actions', () => {
let state; let state;
let mock; let mock;
const latestGetParams = () => mock.history.get[mock.history.get.length - 1].params;
convertToFixedRange.mockImplementation(range => { convertToFixedRange.mockImplementation(range => {
if (range === defaultTimeRange) { if (range === defaultTimeRange) {
return { ...mockDefaultRange }; return { ...mockDefaultRange };
...@@ -75,10 +81,16 @@ describe('Logs Store actions', () => { ...@@ -75,10 +81,16 @@ describe('Logs Store actions', () => {
describe('setInitData', () => { describe('setInitData', () => {
it('should commit environment and pod name mutation', () => it('should commit environment and pod name mutation', () =>
testAction(setInitData, { environmentName: mockEnvName, podName: mockPodName }, state, [ testAction(
{ type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName }, setInitData,
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, { timeRange: mockFixedRange, environmentName: mockEnvName, podName: mockPodName },
])); state,
[
{ type: types.SET_TIME_RANGE, payload: mockFixedRange },
{ type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
],
));
}); });
describe('setSearch', () => { describe('setSearch', () => {
...@@ -140,183 +152,245 @@ describe('Logs Store actions', () => { ...@@ -140,183 +152,245 @@ describe('Logs Store actions', () => {
}); });
}); });
describe('fetchLogs', () => { describe('when the backend responds succesfully', () => {
let expectedMutations;
let expectedActions;
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
}); });
afterEach(() => { afterEach(() => {
mock.reset(); mock.reset();
}); });
it('should commit logs and pod data when there is pod name defined', () => { describe('fetchLogs', () => {
state.environments.options = mockEnvironments; beforeEach(() => {
state.environments.current = mockEnvName; expectedMutations = [
state.pods.current = mockPodName;
const endpoint = '/dummy_logs_path.json';
mock
.onGet(endpoint, {
params: {
pod_name: mockPodName,
...mockDefaultRange,
},
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
return testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA }, { type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA }, { type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, { type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods }, { type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult }, {
], type: types.RECEIVE_LOGS_DATA_SUCCESS,
[], payload: { logs: mockLogsResult, cursor: mockNextCursor },
); },
}); ];
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => { expectedActions = [];
state.projectPath = mockProjectPath; });
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
const endpoint = '/dummy_logs_path.json'; it('should commit logs and pod data when there is pod name defined', () => {
state.pods.current = mockPodName;
mock return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
.onGet(endpoint, { expect(latestGetParams()).toMatchObject({
params: {
pod_name: mockPodName, pod_name: mockPodName,
start: mockFixedRange.start, });
end: mockFixedRange.end,
},
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
}); });
});
return testAction( it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
fetchLogs, state.pods.current = mockPodName;
null, state.timeRange.current = mockFixedRange;
state, state.logs.cursor = mockCursor;
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
);
});
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
state.search = mockSearch;
state.timeRange.current = 'INVALID_TIME_RANGE';
const endpoint = '/dummy_logs_path.json';
mock return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
.onGet(endpoint, { expect(latestGetParams()).toEqual({
params: {
pod_name: mockPodName, pod_name: mockPodName,
search: mockSearch, start: mockFixedRange.start,
}, end: mockFixedRange.end,
}) cursor: mockCursor,
.reply(200, { });
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
}); });
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
state.pods.current = mockPodName;
state.search = mockSearch;
state.timeRange.current = 'INVALID_TIME_RANGE';
return testAction( return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
fetchLogs, expect(latestGetParams()).toEqual({
null, pod_name: mockPodName,
state, search: mockSearch,
[ });
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
() => {
// Warning about time ranges was issued // Warning about time ranges was issued
expect(flash).toHaveBeenCalledTimes(1); expect(flash).toHaveBeenCalledTimes(1);
expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning'); expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning');
}, });
); });
it('should commit logs and pod data when no pod name defined', () => {
state.timeRange.current = mockDefaultRange;
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toEqual({});
});
});
}); });
it('should commit logs and pod data when no pod name defined', done => { describe('fetchMoreLogsPrepend', () => {
state.environments.options = mockEnvironments; beforeEach(() => {
state.environments.current = mockEnvName; expectedMutations = [
{ type: types.REQUEST_LOGS_DATA_PREPEND },
{
type: types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS,
payload: { logs: mockLogsResult, cursor: mockNextCursor },
},
];
const endpoint = '/dummy_logs_path.json'; expectedActions = [];
});
mock.onGet(endpoint, { params: { ...mockDefaultRange } }).reply(200, { it('should commit logs and pod data when there is pod name defined', () => {
pod_name: mockPodName, state.pods.current = mockPodName;
pods: mockPods,
logs: mockLogsResult, expectedActions = [];
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toMatchObject({
pod_name: mockPodName,
});
},
);
}); });
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
testAction( it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
state.logs.cursor = mockCursor;
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
start: mockFixedRange.start,
end: mockFixedRange.end,
cursor: mockCursor,
});
},
);
});
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
state.pods.current = mockPodName;
state.search = mockSearch;
state.timeRange.current = 'INVALID_TIME_RANGE';
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
search: mockSearch,
});
// Warning about time ranges was issued
expect(flash).toHaveBeenCalledTimes(1);
expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning');
},
);
});
it('should commit logs and pod data when no pod name defined', () => {
state.timeRange.current = mockDefaultRange;
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toEqual({});
},
);
});
it('should not commit logs or pod data when it has reached the end', () => {
state.logs.isComplete = true;
state.logs.cursor = null;
return testAction(
fetchMoreLogsPrepend,
null,
state,
[], // no mutations done
[], // no actions dispatched
() => {
expect(mock.history.get).toHaveLength(0);
},
);
});
});
});
describe('when the backend responds with an error', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(mockLogsEndpoint).reply(500);
});
afterEach(() => {
mock.reset();
});
it('fetchLogs should commit logs and pod errors', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
return testAction(
fetchLogs, fetchLogs,
null, null,
state, state,
[ [
{ type: types.REQUEST_PODS_DATA }, { type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA }, { type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }, { type: types.RECEIVE_PODS_DATA_ERROR },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods }, { type: types.RECEIVE_LOGS_DATA_ERROR },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
], ],
[], [],
done, () => {
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
},
); );
}); });
it('should commit logs and pod errors when backend fails', () => { it('fetchMoreLogsPrepend should commit logs and pod errors', () => {
state.environments.options = mockEnvironments; state.environments.options = mockEnvironments;
state.environments.current = mockEnvName; state.environments.current = mockEnvName;
const endpoint = `/${mockProjectPath}/-/logs/elasticsearch.json?environment_name=${mockEnvName}`;
mock.onGet(endpoint).replyOnce(500);
return testAction( return testAction(
fetchLogs, fetchMoreLogsPrepend,
null, null,
state, state,
[ [
{ type: types.REQUEST_PODS_DATA }, { type: types.REQUEST_LOGS_DATA_PREPEND },
{ type: types.REQUEST_LOGS_DATA }, { type: types.RECEIVE_LOGS_DATA_PREPEND_ERROR },
{ type: types.RECEIVE_PODS_DATA_ERROR },
{ type: types.RECEIVE_LOGS_DATA_ERROR },
], ],
[], [],
() => { () => {
expect(flash).toHaveBeenCalledTimes(1); expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
}, },
); );
}); });
......
...@@ -9,6 +9,8 @@ import { ...@@ -9,6 +9,8 @@ import {
mockPodName, mockPodName,
mockLogsResult, mockLogsResult,
mockSearch, mockSearch,
mockCursor,
mockNextCursor,
} from '../mock_data'; } from '../mock_data';
describe('Logs Store Mutations', () => { describe('Logs Store Mutations', () => {
...@@ -73,27 +75,47 @@ describe('Logs Store Mutations', () => { ...@@ -73,27 +75,47 @@ describe('Logs Store Mutations', () => {
it('starts loading for logs', () => { it('starts loading for logs', () => {
mutations[types.REQUEST_LOGS_DATA](state); mutations[types.REQUEST_LOGS_DATA](state);
expect(state.logs).toEqual( expect(state.timeRange.current).toEqual({
expect.objectContaining({ start: expect.any(String),
lines: [], end: expect.any(String),
isLoading: true, });
isComplete: false,
}), expect(state.logs).toEqual({
); lines: [],
cursor: null,
isLoading: true,
isComplete: false,
});
}); });
}); });
describe('RECEIVE_LOGS_DATA_SUCCESS', () => { describe('RECEIVE_LOGS_DATA_SUCCESS', () => {
it('receives logs lines', () => { it('receives logs lines and cursor', () => {
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, mockLogsResult); mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, {
logs: mockLogsResult,
cursor: mockCursor,
});
expect(state.logs).toEqual( expect(state.logs).toEqual({
expect.objectContaining({ lines: mockLogsResult,
lines: mockLogsResult, isLoading: false,
isLoading: false, cursor: mockCursor,
isComplete: true, isComplete: false,
}), });
); });
it('receives logs lines and a null cursor to indicate the end', () => {
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, {
logs: mockLogsResult,
cursor: null,
});
expect(state.logs).toEqual({
lines: mockLogsResult,
isLoading: false,
cursor: null,
isComplete: true,
});
}); });
}); });
...@@ -101,13 +123,77 @@ describe('Logs Store Mutations', () => { ...@@ -101,13 +123,77 @@ describe('Logs Store Mutations', () => {
it('receives log data error and stops loading', () => { it('receives log data error and stops loading', () => {
mutations[types.RECEIVE_LOGS_DATA_ERROR](state); mutations[types.RECEIVE_LOGS_DATA_ERROR](state);
expect(state.logs).toEqual( expect(state.logs).toEqual({
expect.objectContaining({ lines: [],
lines: [], isLoading: false,
isLoading: false, cursor: null,
isComplete: true, isComplete: false,
}), });
); });
});
describe('REQUEST_LOGS_DATA_PREPEND', () => {
it('receives logs lines and cursor', () => {
mutations[types.REQUEST_LOGS_DATA_PREPEND](state);
expect(state.logs.isLoading).toBe(true);
});
});
describe('RECEIVE_LOGS_DATA_PREPEND_SUCCESS', () => {
it('receives logs lines and cursor', () => {
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
logs: mockLogsResult,
cursor: mockCursor,
});
expect(state.logs).toEqual({
lines: mockLogsResult,
isLoading: false,
cursor: mockCursor,
isComplete: false,
});
});
it('receives additional logs lines and a new cursor', () => {
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
logs: mockLogsResult,
cursor: mockCursor,
});
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
logs: mockLogsResult,
cursor: mockNextCursor,
});
expect(state.logs).toEqual({
lines: [...mockLogsResult, ...mockLogsResult],
isLoading: false,
cursor: mockNextCursor,
isComplete: false,
});
});
it('receives logs lines and a null cursor to indicate is complete', () => {
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
logs: mockLogsResult,
cursor: null,
});
expect(state.logs).toEqual({
lines: mockLogsResult,
isLoading: false,
cursor: null,
isComplete: true,
});
});
});
describe('RECEIVE_LOGS_DATA_PREPEND_ERROR', () => {
it('receives logs lines and cursor', () => {
mutations[types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state);
expect(state.logs.isLoading).toBe(false);
}); });
}); });
...@@ -121,6 +207,7 @@ describe('Logs Store Mutations', () => { ...@@ -121,6 +207,7 @@ describe('Logs Store Mutations', () => {
describe('SET_TIME_RANGE', () => { describe('SET_TIME_RANGE', () => {
it('sets a default range', () => { it('sets a default range', () => {
expect(state.timeRange.selected).toEqual(expect.any(Object));
expect(state.timeRange.current).toEqual(expect.any(Object)); expect(state.timeRange.current).toEqual(expect.any(Object));
}); });
...@@ -131,12 +218,13 @@ describe('Logs Store Mutations', () => { ...@@ -131,12 +218,13 @@ describe('Logs Store Mutations', () => {
}; };
mutations[types.SET_TIME_RANGE](state, mockRange); mutations[types.SET_TIME_RANGE](state, mockRange);
expect(state.timeRange.selected).toEqual(mockRange);
expect(state.timeRange.current).toEqual(mockRange); expect(state.timeRange.current).toEqual(mockRange);
}); });
}); });
describe('REQUEST_PODS_DATA', () => { describe('REQUEST_PODS_DATA', () => {
it('receives log data error and stops loading', () => { it('receives pods data', () => {
mutations[types.REQUEST_PODS_DATA](state); mutations[types.REQUEST_PODS_DATA](state);
expect(state.pods).toEqual( expect(state.pods).toEqual(
......
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