Commit 4d9536a1 authored by Tiago Botelho's avatar Tiago Botelho Committed by Rémy Coutable

Add `pod` dropdown to pod logs screen

parent 6e8d50d9
......@@ -232,3 +232,102 @@
word-break: break-word;
max-width: 100%;
}
/*
* Mixin that handles the container for the job logs (CI/CD and kubernetes pod logs)
*/
@mixin build-trace {
background: $black;
color: $gray-darkest;
white-space: pre;
overflow-x: auto;
font-size: 12px;
border-radius: 0;
border: 0;
padding: $grid-size;
.bash {
display: block;
}
&.build-trace-rounded {
border-radius: $border-radius-base;
}
}
@mixin build-trace-top-bar($height, $top_position) {
height: $height;
min-height: $height;
background: $gray-light;
border: 1px solid $border-color;
color: $gl-text-color;
position: sticky;
position: -webkit-sticky;
top: $top_position;
padding: $grid-size;
}
/*
* Mixin that handles the position of the controls placed on the top bar
*/
@mixin build-controllers($control_font_size, $flex_direction, $with_grow, $flex_grow_size) {
display: flex;
font-size: $control_font_size;
justify-content: $flex_direction;
align-items: center;
align-self: baseline;
@if $with_grow {
flex-grow: $flex_grow_size;
}
svg {
width: 15px;
height: 15px;
display: block;
fill: $gl-text-color;
}
.controllers-buttons {
color: $gl-text-color;
margin: 0 $grid-size;
&:last-child {
margin-right: 0;
}
}
.btn-scroll.animate {
.first-triangle {
animation: blinking-scroll-button 1s ease infinite;
animation-delay: 0.3s;
}
.second-triangle {
animation: blinking-scroll-button 1s ease infinite;
animation-delay: 0.2s;
}
.third-triangle {
animation: blinking-scroll-button 1s ease infinite;
}
&:disabled {
opacity: 1;
}
}
.btn-scroll:disabled,
.btn-refresh:disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
@mixin build-loader-animation() {
position: relative;
width: 6px;
height: 6px;
margin: auto auto 12px 2px;
border-radius: 50%;
animation: blinking-dots 1s linear infinite;
}
......@@ -264,6 +264,7 @@ $row-hover: $blue-50;
$row-hover-border: $blue-200;
$progress-color: #c0392b;
$header-height: 40px;
$header-height-pod-logs: 75px;
$ide-statusbar-height: 25px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
......
......@@ -55,34 +55,11 @@
}
.build-trace {
background: $black;
color: $gray-darkest;
white-space: pre;
overflow-x: auto;
font-size: 12px;
border-radius: 0;
border: 0;
padding: $grid-size;
.bash {
display: block;
}
&.build-trace-rounded {
border-radius: $border-radius-base;
}
@include build-trace();
}
.top-bar {
height: 35px;
min-height: 35px;
background: $gray-light;
border: 1px solid $border-color;
color: $gl-text-color;
position: sticky;
position: -webkit-sticky;
top: $header-height;
padding: $grid-size;
@include build-trace-top-bar(35px, $header-height);
&.affix {
top: $header-height;
......@@ -120,56 +97,7 @@
}
.controllers {
display: flex;
font-size: 15px;
justify-content: center;
align-items: center;
svg {
width: 15px;
height: 15px;
display: block;
fill: $gl-text-color;
}
.controllers-buttons {
color: $gl-text-color;
margin: 0 $grid-size;
&:last-child {
margin-right: 0;
}
}
.btn-scroll.animate {
.first-triangle {
animation: blinking-scroll-button 1s ease infinite;
animation-delay: 0.3s;
}
.second-triangle {
animation: blinking-scroll-button 1s ease infinite;
animation-delay: 0.2s;
}
.third-triangle {
animation: blinking-scroll-button 1s ease infinite;
}
&:disabled {
opacity: 1;
}
}
.btn-refresh {
border-radius: 4px;
}
.btn-scroll:disabled,
.btn-refresh:disabled {
opacity: 0.35;
cursor: not-allowed;
}
@include build-controllers(15px, center, false, 0);
}
}
......@@ -188,12 +116,7 @@
}
.build-loader-animation {
position: relative;
width: 6px;
height: 6px;
margin: auto auto 12px 2px;
border-radius: 50%;
animation: blinking-dots 1s linear infinite;
@include build-loader-animation();
}
}
......@@ -207,6 +130,66 @@
}
}
.build-page-pod-logs {
.build-trace-container {
position: relative;
}
.build-trace {
@include build-trace();
}
.top-bar {
@include build-trace-top-bar(48px, $header-height-pod-logs);
display: flex;
border-top-left-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
.truncated-info {
display: flex;
justify-content: center;
align-items: center;
}
.dropdown {
display: flex;
> .dropdown-menu-toggle {
display: flex;
align-content: center;
align-self: center;
width: 300px;
}
> .dropdown-menu {
width: 300px;
}
}
.controllers {
@include build-controllers(16px, flex-end, true, 2);
}
.refresh-control {
@include build-controllers(16px, flex-end, true, 0);
margin-left: 2px;
.controllers-buttons {
.btn-refresh {
border-radius: 4px;
width: 32px;
height: 32px;
vertical-align: middle;
}
}
}
}
.build-loader-animation {
@include build-loader-animation();
}
}
.build-header {
.ci-header-container,
.header-action-buttons {
......
class Environment < ActiveRecord::Base
prepend EE::Environment
# Used to generate random suffixes for the slug
LETTERS = 'a'..'z'
NUMBERS = '0'..'9'
......
......@@ -4,20 +4,21 @@ import { getParameterValues } from '~/lib/utils/url_utility';
import { isScrolledToBottom, scrollDown, toggleDisableButton } from '~/lib/utils/scroll_utils';
import LogOutputBehaviours from '~/lib/utils/logoutput_behaviours';
import createFlash from '~/flash';
import { __, s__, sprintf } from '~/locale';
import { __, s__ } from '~/locale';
import _ from 'underscore';
export default class KubernetesPodLogs extends LogOutputBehaviours {
constructor(container) {
super();
this.options = $(container).data();
this.podNameContainer = $(container).find('.js-pod-name');
[this.podName] = getParameterValues('pod_name');
this.podName = _.escape(this.podName);
this.$buildOutputContainer = $(container).find('.js-build-output');
this.$window = $(window);
this.$refreshLogBtn = $(container).find('.js-refresh-log');
this.$buildRefreshAnimation = $(container).find('.js-build-refresh');
this.isLogComplete = false;
this.$podDropdown = $(container).find('.js-pod-dropdown');
this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
......@@ -26,16 +27,6 @@ export default class KubernetesPodLogs extends LogOutputBehaviours {
return;
}
const podTitle = sprintf(
s__('Environments|Pod logs from %{podName}'),
{
podName: `<strong>${_.escape(this.podName)}</strong>`,
},
false,
);
this.podNameContainer.empty();
this.podNameContainer.append(podTitle);
this.$window.off('scroll').on('scroll', () => {
if (!isScrolledToBottom()) {
this.toggleScrollAnimation(false);
......@@ -70,6 +61,7 @@ export default class KubernetesPodLogs extends LogOutputBehaviours {
})
.then(res => {
const { logs } = res.data;
this.populateDropdown(res.data.pods);
const formattedLogs = logs.map(logEntry => `${_.escape(logEntry)} <br />`);
this.$buildOutputContainer.append(formattedLogs);
scrollDown();
......@@ -79,4 +71,34 @@ export default class KubernetesPodLogs extends LogOutputBehaviours {
})
.catch(() => createFlash(__('Something went wrong on our end')));
}
populateDropdown(pods) {
// set the selected element from the pod set on the url params
const $podDropdownMenu = this.$podDropdown.find('.dropdown-menu');
this.$podDropdown
.find('.dropdown-menu-toggle')
.html(`${this.podName}<i class="fa fa-chevron-down"></i>`);
$podDropdownMenu.off('click');
$podDropdownMenu.empty();
pods.forEach((pod) => {
$podDropdownMenu.append(`
<button class='dropdown-item'>
${_.escape(pod)}
</button>
`);
});
$podDropdownMenu.find('li').on('click', this.changePodLog.bind(this));
}
changePodLog(el) {
const selectedPodName = el.currentTarget.textContent.trim();
if (selectedPodName !== this.podName) {
this.podName = selectedPodName;
this.getPodLogs();
}
}
}
......@@ -15,7 +15,8 @@ module EE
::Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: {
logs: pod_logs.strip.split("\n").as_json
logs: pod_logs.strip.split("\n").as_json,
pods: environment.pod_names
}
end
end
......@@ -28,7 +29,7 @@ module EE
end
def pod_logs
@pod_logs ||= environment.deployment_platform.read_pod_logs(params[:pod_name])
environment.deployment_platform.read_pod_logs(params[:pod_name])
end
end
end
......
module EE
module Environment
def pod_names
return [] unless rollout_status
rollout_status.instances.map do |instance|
instance[:pod_name]
end
end
end
end
.js-kubernetes-logs{ data: { logs_path: logs_project_environment_path(@project, @environment, format: :json) } }
.build-page
.build-page-pod-logs
.build-trace-container.prepend-top-default
.top-bar.js-top-bar
.truncated-info.hidden-xs.pull-left.js-pod-name
.truncated-info.hidden-xs.pull-left
= s_('Environments|Pod logs from')
.dropdown.prepend-left-10.js-pod-dropdown
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' }, 'aria-expanded': false }
= icon('chevron-down')
.dropdown-menu.dropdown-menu-selectable.dropdown-menu-drop-up
.controllers.pull-right
.has-tooltip.controllers-buttons{ title: _('Scroll to top'), data: { placement: 'top', container: 'body'} }
%button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
......@@ -11,8 +15,9 @@
.has-tooltip.controllers-buttons{ title: _('Scroll to bottom'), data: { placement: 'top', container: 'body'} }
%button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
= custom_icon('scroll_down')
.has-tooltip.controllers-buttons{ title: _('Refresh'), data: { placement: 'top', container: 'body'} }
%button.js-refresh-log.btn-default.btn-refresh{ type: 'button', disabled: true }
= sprite_icon('retry')
.refresh-control.pull-right
.has-tooltip.controllers-buttons{ title: _('Refresh'), data: { placement: 'top', container: 'body'} }
%button.js-refresh-log.btn-default.btn-refresh{ type: 'button', disabled: true }
= sprite_icon('retry')
= render 'shared/builds/build_output'
---
title: Adds pod selection dropdown to pod logs screen
merge_request: 6111
author:
type: added
......@@ -75,16 +75,17 @@ describe Projects::EnvironmentsController do
end
describe 'GET logs' do
let(:logs) { "Log 1\nLog 2\nLog 3" }
let(:pod_name) { 'foo' }
let(:pod_name) { "foo" }
before do
stub_licensed_features(pod_logs: true)
create(:cluster, :provided_by_gcp,
environment_scope: '*', projects: [project])
create(:deployment, environment: environment)
allow_any_instance_of(EE::KubernetesService).to receive(:read_pod_logs).with(pod_name).and_return(logs)
allow_any_instance_of(EE::KubernetesService).to receive(:read_pod_logs).with(pod_name).and_return(kube_logs_body)
allow_any_instance_of(Gitlab::Kubernetes::RolloutStatus).to receive(:instances).and_return([{ pod_name: pod_name }])
end
context 'when unlicensed' do
......@@ -114,6 +115,7 @@ describe Projects::EnvironmentsController do
expect(response).to be_ok
expect(json_response["logs"]).to match_array(["Log 1", "Log 2", "Log 3"])
expect(json_response["pods"]).to match_array([pod_name])
end
end
end
......
require 'spec_helper'
feature 'Environment > Pod Logs', :js do
include KubernetesHelpers
given(:pod_names) { %w(foo bar) }
given(:pod_name) { pod_names.first }
given(:project) { create(:project, :repository) }
given(:environment) { create(:environment, project: project) }
background do
stub_licensed_features(pod_logs: true)
create(:cluster, :provided_by_gcp, environment_scope: '*', projects: [project])
create(:deployment, environment: environment)
allow_any_instance_of(EE::KubernetesService).to receive(:read_pod_logs).with(pod_name).and_return(kube_logs_body)
allow_any_instance_of(EE::Environment).to receive(:pod_names).and_return(pod_names)
sign_in(project.owner)
end
context 'with logs' do
scenario "shows pod logs" do
visit logs_project_environment_path(environment.project, environment, pod_name: pod_name)
wait_for_requests
page.within('.js-pod-dropdown') do
find(".dropdown-menu-toggle").click
dropdown_items = find(".dropdown-menu").all(".dropdown-item")
expect(dropdown_items.size).to eq(2)
dropdown_items.each_with_index do |item, i|
expect(item.text).to eq(pod_names[i])
end
end
expect(page).to have_content("Log 1\nLog 2\nLog 3")
end
end
end
require 'spec_helper'
describe Environment do
let(:project) { create(:project, :stubbed_repository) }
let(:environment) { create(:environment, project: project) }
describe '#pod_names' do
context 'when environment does not have a rollout status' do
it 'returns an empty array' do
expect(environment.pod_names).to eq([])
end
end
context 'when environment has a rollout status' do
it 'returns the pod_names' do
pod_name = "pod_1"
create(:cluster, :provided_by_gcp, environment_scope: '*', projects: [project])
create(:deployment, environment: environment)
allow_any_instance_of(Gitlab::Kubernetes::RolloutStatus).to receive(:instances)
.and_return([{ pod_name: pod_name }])
expect(environment.pod_names).to eq([pod_name])
end
end
end
end
......@@ -104,4 +104,6 @@ export const logMockData = [
'- -> /',
];
export const podMockData = ['production-tanuki-1', 'production-tanuki-2'];
export default {};
......@@ -2,16 +2,19 @@
.build-page
.build-trace-container.prepend-top-default
.top-bar.js-top-bar
.truncated-info.hidden-xs.pull-left.js-pod-name
Pod logs from pod name
.truncated-info.hidden-xs.pull-left
.dropdown.prepend-left-10.js-pod-dropdown
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' }, 'aria-expanded': false }
%i.fa.fa-chevron-down
.dropdown-menu.dropdown-menu-selectable.dropdown-menu-drop-up
.controllers.pull-right
.has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} }
%button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
.has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} }
%button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
.has-tooltip.controllers-buttons{ title: 'Refresh', data: { placement: 'top', container: 'body'} }
%button.js-refresh-log.btn-default.btn-refresh{ type: 'button', disabled: true }
.refresh-control.pull-right
.has-tooltip.controllers-buttons{ title: _('Refresh'), data: { placement: 'top', container: 'body'} }
%button.js-refresh-log.btn-default.btn-refresh{ type: 'button', disabled: true }
%pre.build-trace#build-trace
%code.bash.js-build-output
......
import $ from 'jquery';
import KubernetesLogs from 'ee/kubernetes_logs';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { logMockData } from './ee/kubernetes_mock_data';
import { logMockData, podMockData } from './ee/kubernetes_mock_data';
describe('Kubernetes Logs', () => {
const fixtureTemplate = 'static/environments_logs.html.raw';
......@@ -20,7 +21,7 @@ describe('Kubernetes Logs', () => {
mock = new MockAdapter(axios);
mock.onGet(logMockPath).reply(200, { logs: logMockData });
mock.onGet(logMockPath).reply(200, { logs: logMockData, pods: podMockData });
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
});
......@@ -29,11 +30,18 @@ describe('Kubernetes Logs', () => {
mock.restore();
});
it('has the pod name placed on the top bar', () => {
it('has the pod name placed on the dropdown', (done) => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
const topBar = document.querySelector('.js-pod-name');
kubernetesLog.getPodLogs();
setTimeout(() => {
const podDropdown = document
.querySelector('.js-pod-dropdown')
.querySelector('.dropdown-menu-toggle');
expect(topBar.textContent).toContain(kubernetesLog.podName);
expect(podDropdown.textContent).toContain(mockPodName);
done();
}, 0);
});
it('queries the pod log data and sets the dom elements', (done) => {
......@@ -50,6 +58,58 @@ describe('Kubernetes Logs', () => {
done();
}, 0);
});
it('asks for the pod logs from another pod', (done) => {
const changePodLogSpy = spyOn(KubernetesLogs.prototype, 'getPodLogs').and.callThrough();
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog.getPodLogs();
setTimeout(() => {
const podDropdown = document.querySelectorAll('.js-pod-dropdown .dropdown-menu button');
const anotherPod = podDropdown[podDropdown.length - 1];
anotherPod.click();
expect(changePodLogSpy).toHaveBeenCalled();
done();
}, 0);
});
it('clears the pod dropdown contents when pod logs are requested', (done) => {
const emptySpy = spyOn($.prototype, 'empty').and.callThrough();
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog.getPodLogs();
setTimeout(() => {
// This is because it clears both the job log contents and the dropdown
expect(emptySpy.calls.count()).toEqual(2);
done();
});
});
});
describe('XSS Protection', () => {
const hackyPodName = '">&lt;img src=x onerror=alert(document.domain)&gt; production';
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => [hackyPodName]);
mock = new MockAdapter(axios);
mock.onGet(logMockPath).reply(200, { logs: logMockData, pods: [hackyPodName] });
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
});
afterEach(() => {
mock.restore();
});
it('escapes the pod name', () => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
expect(kubernetesLog.podName).toContain('&quot;&gt;&amp;lt;img src=x onerror=alert(document.domain)&amp;gt; production');
});
});
describe('When no pod name is available', () => {
......
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