Commit 6b675004 authored by Miguel Rincon's avatar Miguel Rincon Committed by Fatih Acet

Refactor pod logs to use vue instead of HAML

- Add new logs component
- Add scroll controls
- Uses backoff for requests to retry
- Makes use of vuex
- Files are present in ee/
- Added specs
- Swtch between new and old component
parent ae2fb443
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlDropdown, GlDropdownItem, GlFormGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
import flash from '~/flash';
import {
canScroll,
isScrolledToTop,
isScrolledToBottom,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
export default {
components: {
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlButton,
Icon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
currentEnvironmentName: {
type: String,
required: false,
default: '',
},
currentPodName: {
type: [String, null],
required: false,
default: null,
},
environmentsPath: {
type: String,
required: false,
default: '',
},
logsEndpoint: {
type: String,
required: false,
default: '',
},
},
data() {
return {
scrollToTopEnabled: false,
scrollToBottomEnabled: false,
};
},
computed: {
...mapState('environmentLogs', ['environments', 'logs', 'pods']),
...mapGetters('environmentLogs', ['trace']),
showLoader() {
return this.logs.isLoading || !this.logs.isComplete;
},
},
watch: {
trace(val) {
this.$nextTick(() => {
if (val) {
this.scrollDown();
} else {
this.updateScrollState();
}
});
},
},
created() {
window.addEventListener('scroll', this.updateScrollState);
},
mounted() {
this.fetchEnvironments(this.environmentsPath);
this.setLogsEndpoint(this.logsEndpoint)
.then(() => {
this.fetchLogs(this.currentPodName);
})
.catch(() => {
flash(__('Something went wrong on our end. Please try again!'));
});
},
destroyed() {
window.removeEventListener('scroll', this.updateScrollState);
},
methods: {
...mapActions('environmentLogs', ['setLogsEndpoint', 'fetchEnvironments', 'fetchLogs']),
showPod(podName) {
this.fetchLogs(podName);
},
updateScrollState() {
this.scrollToTopEnabled = canScroll() && !isScrolledToTop();
this.scrollToBottomEnabled = canScroll() && !isScrolledToBottom();
},
scrollUp,
scrollDown,
},
};
</script>
<template>
<div class="build-page-pod-logs mt-3">
<div class="top-bar d-flex">
<div class="row">
<gl-form-group
id="environments-dropdown-fg"
:label="s__('Environments|Environment')"
label-size="sm"
label-for="environments-dropdown"
class="col-6"
>
<gl-dropdown
id="environments-dropdown"
:text="currentEnvironmentName"
:disabled="environments.isLoading"
class="d-flex js-environments-dropdown"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="env in environments.options"
:key="env.id"
:href="env.logs_path"
>
{{ env.name }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
id="environments-dropdown-fg"
:label="s__('Environments|Pod logs from')"
label-size="sm"
label-for="pods-dropdown"
class="col-6"
>
<gl-dropdown
id="pods-dropdown"
:text="pods.current"
:disabled="logs.isLoading"
class="d-flex js-pods-dropdown"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="podName in pods.options"
:key="podName"
@click="showPod(podName)"
>
{{ podName }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</div>
<div class="controllers align-self-end">
<div
v-gl-tooltip
class="controllers-buttons"
:title="__('Scroll to top')"
aria-labelledby="scroll-to-top"
>
<gl-button
id="scroll-to-top"
class="btn-blank js-scroll-to-top"
:aria-label="__('Scroll to top')"
:disabled="!scrollToTopEnabled"
@click="scrollUp()"
><icon name="scroll_up"
/></gl-button>
</div>
<div
v-gl-tooltip
class="controllers-buttons"
:title="__('Scroll to bottom')"
aria-labelledby="scroll-to-bottom"
>
<gl-button
id="scroll-to-bottom"
class="btn-blank js-scroll-to-bottom"
:aria-label="__('Scroll to bottom')"
:disabled="!scrollToBottomEnabled"
@click="scrollDown()"
><icon name="scroll_down"
/></gl-button>
</div>
<gl-button
id="refresh-log"
v-gl-tooltip
class="ml-1 px-2 js-refresh-log"
:title="__('Refresh')"
:aria-label="__('Refresh')"
@click="showPod(pods.current)"
>
<icon name="retry" />
</gl-button>
</div>
</div>
<pre class="build-trace js-log-trace"><code class="bash">{{trace}}
<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></code></pre>
</div>
</template>
import Vue from 'vue';
import { getParameterValues } from '~/lib/utils/url_utility';
import LogViewer from './components/environment_logs.vue';
import store from './stores';
export default (props = {}) => {
const el = document.getElementById('environment-logs');
const [currentPodName] = getParameterValues('pod_name');
// eslint-disable-next-line no-new
new Vue({
el,
store,
render(createElement) {
return createElement(LogViewer, {
props: {
...el.dataset,
currentPodName,
...props,
},
});
},
});
};
import logsBundle from 'ee/logs/logs_bundle';
import logsBundle from 'ee/logs';
import KubernetesLogs from '../../../../kubernetes_logs';
if (gon.features.environmentLogsUseVueUi) {
......
- if Feature.enabled?('environment_logs_use_vue_ui')
#js-environment-logs{ data: environment_logs_data(@project, @environment) }
#environment-logs{ data: environment_logs_data(@project, @environment) }
- else
.js-kubernetes-logs{ data: environment_logs_data(@project, @environment) }
.build-page-pod-logs
......
---
title: Implement pod logs page using Vue
merge_request: 18567
author:
type: changed
import Vue from 'vue';
import { GlDropdown, GlButton, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import {
canScroll,
isScrolledToTop,
isScrolledToBottom,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
import EnvironmentLogs from 'ee/logs/components/environment_logs.vue';
import { createStore } from 'ee/logs/stores';
import {
mockEnvironment,
mockEnvironments,
mockPods,
mockEnvironmentsEndpoint,
mockLines,
mockLogsEndpoint,
} from '../mock_data';
jest.mock('~/lib/utils/scroll_utils');
describe('EnvironmentLogs', () => {
let EnvironmentLogsComponent;
let store;
let wrapper;
let state;
const propsData = {
currentEnvironmentName: mockEnvironment.name,
environmentsPath: mockEnvironmentsEndpoint,
logsEndpoint: mockLogsEndpoint,
};
const actionMocks = {
fetchEnvironments: jest.fn(),
fetchLogs: jest.fn(),
};
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
const findPodsDropdown = () => wrapper.find('.js-pods-dropdown');
const findScrollToTop = () => wrapper.find('.js-scroll-to-top');
const findScrollToBottom = () => wrapper.find('.js-scroll-to-bottom');
const findRefreshLog = () => wrapper.find('.js-refresh-log');
const findLogTrace = () => wrapper.find('.js-log-trace');
const initWrapper = () => {
wrapper = shallowMount(EnvironmentLogsComponent, {
propsData,
store,
methods: {
...actionMocks,
},
});
};
beforeEach(() => {
store = createStore();
state = store.state.environmentLogs;
EnvironmentLogsComponent = Vue.extend(EnvironmentLogs);
});
it('displays UI elements', () => {
initWrapper();
expect(wrapper.isVueInstance()).toBe(true);
expect(wrapper.isEmpty()).toBe(false);
expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true);
expect(findPodsDropdown().is(GlDropdown)).toBe(true);
expect(findScrollToTop().is(GlButton)).toBe(true);
expect(findScrollToBottom().is(GlButton)).toBe(true);
expect(findRefreshLog().is(GlButton)).toBe(true);
expect(findLogTrace().isEmpty()).toBe(false);
});
describe('loading state', () => {
beforeEach(() => {
actionMocks.fetchEnvironments.mockImplementation(() => {
state.environments.options = [];
state.environments.isLoading = true;
});
actionMocks.fetchLogs.mockImplementation(() => {
state.pods.options = [];
state.logs.lines = [];
state.logs.isLoading = true;
});
initWrapper();
});
it('displays a disabled environments dropdown', () => {
expect(findEnvironmentsDropdown().attributes('disabled')).toEqual('true');
expect(findEnvironmentsDropdown().findAll(GlDropdownItem).length).toBe(0);
});
it('displays a disabled pods dropdown', () => {
expect(findPodsDropdown().attributes('disabled')).toEqual('true');
expect(findPodsDropdown().findAll(GlDropdownItem).length).toBe(0);
});
it('shows a logs trace', () => {
const trace = findLogTrace();
expect(trace.text()).toBe('');
expect(trace.find('.js-build-loader-animation').isVisible()).toBe(true);
});
});
describe('state with data', () => {
beforeEach(() => {
actionMocks.fetchEnvironments.mockImplementation(() => {
state.environments.options = mockEnvironments;
});
actionMocks.fetchLogs.mockImplementation(() => {
state.pods.options = mockPods;
[state.pods.current] = state.pods.options;
state.logs.isComplete = false;
state.logs.lines = mockLines;
});
initWrapper();
});
afterEach(() => {
scrollDown.mockReset();
scrollUp.mockReset();
actionMocks.fetchEnvironments.mockReset();
actionMocks.fetchLogs.mockReset();
});
it('populates environments dropdown', () => {
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
expect(items.length).toBe(mockEnvironments.length);
mockEnvironments.forEach((env, i) => {
const item = items.at(i);
expect(item.text()).toBe(env.name);
expect(item.attributes('href')).toBe(env.logs_path);
});
});
it('populates pods dropdown', () => {
const items = findPodsDropdown().findAll(GlDropdownItem);
expect(items.length).toBe(mockPods.length);
mockPods.forEach((pod, i) => {
const item = items.at(i);
expect(item.text()).toBe(pod);
});
});
it('populates logs trace', () => {
const trace = findLogTrace();
expect(trace.text().split('\n').length).toBe(mockLines.length);
expect(trace.text().split('\n')).toEqual(mockLines);
});
it('scrolls to bottom when loaded', () => {
expect(scrollDown).toHaveBeenCalledTimes(1);
});
describe('when user clicks', () => {
it('pod name, trace is refreshed', () => {
const items = findPodsDropdown().findAll(GlDropdownItem);
const index = 2; // any pod
expect(actionMocks.fetchLogs).toHaveBeenCalledTimes(1);
expect(actionMocks.fetchLogs).toHaveBeenLastCalledWith(null);
items.at(index).vm.$emit('click');
expect(actionMocks.fetchLogs).toHaveBeenCalledTimes(2);
expect(actionMocks.fetchLogs).toHaveBeenLastCalledWith(mockPods[index]);
});
it('refresh button, trace is refreshed', () => {
expect(actionMocks.fetchLogs).toHaveBeenCalledTimes(1);
expect(actionMocks.fetchLogs).toHaveBeenLastCalledWith(null);
findRefreshLog().vm.$emit('click'); // works
expect(actionMocks.fetchLogs).toHaveBeenCalledTimes(2);
expect(actionMocks.fetchLogs).toHaveBeenLastCalledWith(mockPods[0]);
});
describe('when scrolling actions are enabled', () => {
beforeEach(done => {
// simulate being in the middle of a long page
canScroll.mockReturnValue(true);
isScrolledToBottom.mockReturnValue(false);
isScrolledToTop.mockReturnValue(false);
initWrapper();
wrapper.vm.updateScrollState();
wrapper.vm.$nextTick(done);
});
afterEach(() => {
canScroll.mockReset();
isScrolledToTop.mockReset();
isScrolledToBottom.mockReset();
});
it('click on "scroll to top" scrolls up', () => {
expect(findScrollToTop().is('[disabled]')).toBe(false);
findScrollToTop().vm.$emit('click');
expect(scrollUp).toHaveBeenCalledTimes(1);
});
it('click on "scroll to bottom" scrolls down', () => {
expect(findScrollToBottom().is('[disabled]')).toBe(false);
findScrollToBottom().vm.$emit('click');
expect(scrollDown).toHaveBeenCalledTimes(2); // plus one time when loaded
});
});
describe('when scrolling actions are disabled', () => {
beforeEach(() => {
// a short page, without a scrollbar
canScroll.mockReturnValue(false);
isScrolledToBottom.mockReturnValue(true);
isScrolledToTop.mockReturnValue(true);
initWrapper();
});
it('buttons are disabled', done => {
wrapper.vm.updateScrollState();
wrapper.vm.$nextTick(() => {
expect(findScrollToTop().is('[disabled]')).toBe(true);
expect(findScrollToBottom().is('[disabled]')).toBe(true);
done();
});
});
});
});
});
});
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