Commit 198cf31f authored by Miguel Rincon's avatar Miguel Rincon

Add infinite scrolling to env logs

Integrates logs infinite scroll using pagination.
parent c9f50b1d
...@@ -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';
......
...@@ -7648,6 +7648,12 @@ msgstr "" ...@@ -7648,6 +7648,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 ""
...@@ -7681,6 +7687,9 @@ msgstr "" ...@@ -7681,6 +7687,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 { 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';
This diff is collapsed.
...@@ -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