Commit fcafa6a3 authored by Miguel Rincon's avatar Miguel Rincon Committed by Mayra Cabrera

Add "environment" dropdown to pod logs screen

- Add dropdown to select environment in page
- Display logs of first pod found if no pod is selected
- Add environment_logs_data to display data required on the page
- Update specs
parent 00b6311f
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
import { getParameterValues, redirectTo } from '~/lib/utils/url_utility';
import { isScrolledToBottom, scrollDown, toggleDisableButton } from '~/lib/utils/scroll_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import LogOutputBehaviours from '~/lib/utils/logoutput_behaviours';
import createFlash from '~/flash';
import { sprintf, __, s__ } from '~/locale';
import flash from '~/flash';
import { __, s__, sprintf } from '~/locale';
import _ from 'underscore';
import { backOff } from '~/lib/utils/common_utils';
const requestWithBackoff = (url, params) =>
backOff((next, stop) => {
axios
.get(url, {
params,
})
.then(res => {
if (!res.data) {
next();
return;
}
stop(res);
})
.catch(err => {
stop(err);
});
});
export default class KubernetesPodLogs extends LogOutputBehaviours {
constructor(container) {
super();
this.options = $(container).data();
const { currentEnvironmentName, environmentsPath, logsPath, logsPage } = this.options;
this.environmentName = currentEnvironmentName;
this.environmentsPath = environmentsPath;
this.logsPath = logsPath;
this.logsPage = logsPage;
[this.podName] = getParameterValues('pod_name');
this.podName = _.escape(this.podName);
this.$buildOutputContainer = $(container).find('.js-build-output');
if (this.podName) {
this.podName = _.escape(this.podName);
}
this.$window = $(window);
this.$buildOutputContainer = $(container).find('.js-build-output');
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.$envDropdown = $(container).find('.js-environment-dropdown');
this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
if (!this.podName) {
createFlash(s__('Environments|No pod name has been specified'));
return;
}
this.isLogComplete = false;
this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
this.$window.off('scroll').on('scroll', () => {
if (!isScrolledToBottom()) {
this.toggleScrollAnimation(false);
......@@ -36,7 +62,7 @@ export default class KubernetesPodLogs extends LogOutputBehaviours {
this.scrollThrottled();
});
this.$refreshLogBtn.off('click').on('click', this.getPodLogs.bind(this));
this.$refreshLogBtn.off('click').on('click', this.getData.bind(this));
}
scrollToBottom() {
......@@ -49,69 +75,122 @@ export default class KubernetesPodLogs extends LogOutputBehaviours {
this.toggleScroll();
}
getPodLogs() {
getData() {
this.scrollToTop();
this.$buildOutputContainer.empty();
this.$buildRefreshAnimation.show();
toggleDisableButton(this.$refreshLogBtn, 'true');
return Promise.all([this.getEnvironments(), this.getLogs()]);
}
getEnvironments() {
return axios
.get(this.options.logsPath, {
params: { pod_name: this.podName },
.get(this.environmentsPath)
.then(res => {
const { environments } = res.data;
this.setupEnvironmentsDropdown(environments);
})
.catch(() => flash(s__('Environments|An error occurred while fetching the environments.')));
}
getLogs() {
return requestWithBackoff(this.logsPath, { pod_name: this.podName })
.then(res => {
const { logs } = res.data;
this.populateDropdown(res.data.pods);
const formattedLogs = logs.map(logEntry => `${_.escape(logEntry)} <br />`);
this.$buildOutputContainer.append(formattedLogs);
scrollDown();
this.isLogComplete = true;
this.$buildRefreshAnimation.hide();
toggleDisableButton(this.$refreshLogBtn, false);
const { logs, pods } = res.data;
this.setupPodsDropdown(pods);
this.displayLogs(logs);
})
.catch(err => {
let message = '';
if (err.response) {
message = sprintf(`Error: %{message}`, { message: err.response.data.message });
const { response } = err;
if (response && response.status === httpStatusCodes.BAD_REQUEST) {
if (response.data && response.data.message) {
flash(
sprintf(
s__('Environments|An error occurred while fetching the logs - Error: %{message}'),
{
message: response.data.message,
},
),
'notice',
);
} else {
flash(
s__(
'Environments|An error occurred while fetching the logs for this environment or pod. Please try again',
),
'notice',
);
}
} else {
flash(__('Environments|An error occurred while fetching the logs'));
}
createFlash(
sprintf(__(`Something went wrong on our end. %{message}`), {
message,
}),
);
})
.finally(() => {
this.$buildRefreshAnimation.hide();
});
}
populateDropdown(pods) {
// set the selected element from the pod set on the url params
const $podDropdownMenu = this.$podDropdown.find('.dropdown-menu');
setupEnvironmentsDropdown(environments) {
this.setupDropdown(
this.$envDropdown,
this.environmentName,
environments.map(({ name, id }) => ({ name, value: id })),
el => {
const envId = el.currentTarget.value;
const envRegexp = /environments\/[0-9]+/gi;
const url = this.logsPage.replace(envRegexp, `environments/${envId}`);
redirectTo(url);
},
);
}
setupPodsDropdown(pods) {
// Show first pod, it is selected by default
this.podName = this.podName || pods[0];
this.setupDropdown(
this.$podDropdown,
this.podName,
pods.map(podName => ({ name: podName, value: podName })),
el => {
const selectedPodName = el.currentTarget.value;
if (selectedPodName !== this.podName) {
this.podName = selectedPodName;
this.getData();
}
},
);
}
this.$podDropdown
displayLogs(logs) {
const formattedLogs = logs.map(logEntry => `${_.escape(logEntry)} <br />`);
this.$buildOutputContainer.append(formattedLogs);
scrollDown();
this.isLogComplete = true;
toggleDisableButton(this.$refreshLogBtn, false);
}
setupDropdown($dropdown, activeOption = '', options, onSelect) {
const $dropdownMenu = $dropdown.find('.dropdown-menu');
$dropdown
.find('.dropdown-menu-toggle')
.html(
`<span class="dropdown-toggle-text text-truncate">${this.podName}</span><i class="fa fa-chevron-down"></i>`,
`<span class="dropdown-toggle-text text-truncate">${activeOption}</span><i class="fa fa-chevron-down"></i>`,
);
$podDropdownMenu.off('click');
$podDropdownMenu.empty();
pods.forEach(pod => {
$podDropdownMenu.append(`
<button class='dropdown-item'>
${_.escape(pod)}
$dropdownMenu.off('click');
$dropdownMenu.empty();
options.forEach(option => {
$dropdownMenu.append(`
<button class='dropdown-item' value='${option.value}'>
${_.escape(option.name)}
</button>
`);
});
$podDropdownMenu.find('button').on('click', this.changePodLog.bind(this));
}
changePodLog(el) {
const selectedPodName = el.currentTarget.textContent.trim();
if (selectedPodName !== this.podName) {
this.podName = selectedPodName;
this.getPodLogs();
}
$dropdownMenu.find('button').on('click', onSelect.bind(this));
}
}
......@@ -4,5 +4,5 @@ document.addEventListener('DOMContentLoaded', () => {
const kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
const kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog.getPodLogs();
kubernetesLog.getData();
});
......@@ -8,14 +8,7 @@
}
.top-bar {
@include build-trace-top-bar(48px);
display: flex;
.truncated-info {
display: flex;
justify-content: center;
align-items: center;
}
@include build-trace-top-bar($gl-line-height * 5);
.dropdown-menu-toggle {
width: 200px;
......
......@@ -30,6 +30,15 @@ module EE
project.feature_available?(:custom_prometheus_metrics) && can?(current_user, :admin_project, project)
end
def environment_logs_data(project, environment)
{
"current-environment-name": environment.name,
"environments-path": project_environments_path(project, format: :json),
"logs-path": logs_project_environment_path(project, environment, format: :json),
"logs-page": logs_project_environment_path(project, environment)
}
end
def metrics_data(project, environment)
ee_metrics_data = {
"custom-metrics-path" => project_prometheus_metrics_path(project),
......
.js-kubernetes-logs{ data: { logs_path: logs_project_environment_path(@project, @environment, format: :json) } }
.js-kubernetes-logs{ data: environment_logs_data(@project, @environment) }
.build-page-pod-logs
.build-trace-container.prepend-top-default
.top-bar.js-top-bar
.truncated-info.d-none.d-md-flex.append-right-8
= s_('Environments|Pod logs from')
.dropdown.js-pod-dropdown.d-flex
%button.dropdown-menu-toggle.d-flex.align-content-center.align-self-center{ type: 'button', data: { toggle: 'dropdown' }, 'aria-expanded': false }
= icon('chevron-down')
.dropdown-menu.dropdown-menu-selectable.dropdown-menu-drop-up
.controllers
.top-bar.js-top-bar.d-flex
.row
.form-group.col-6{ role: 'group' }
%label.d-block.pt-0.col-form-label-sm.col-form-label
= s_('Environments|Environment')
.dropdown.js-environment-dropdown.d-flex
%button.dropdown-menu-toggle.d-flex.align-content-center.align-self-center{ type: 'button', data: { toggle: 'dropdown' }, 'aria-expanded': false }
= icon('chevron-down')
.dropdown-toggle-text
= "&nbsp;".html_safe
.dropdown-menu.dropdown-menu-selectable.dropdown-menu-drop-up
.form-group.col-6{ role: 'group' }
%label.d-block.pt-0.col-form-label-sm.col-form-label
= s_('Environments|Pod logs from')
.dropdown.js-pod-dropdown.d-flex
%button.dropdown-menu-toggle.d-flex.align-content-center.align-self-center{ type: 'button', data: { toggle: 'dropdown' }, 'aria-expanded': false }
= icon('chevron-down')
.dropdown-toggle-text
= "&nbsp;".html_safe
.dropdown-menu.dropdown-menu-selectable.dropdown-menu-drop-up
.controllers.align-self-end
.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 }
= custom_icon('scroll_up')
......
---
title: Add environment dropdown to pod logs screen
merge_request: 17532
author:
type: changed
......@@ -31,6 +31,24 @@ describe EnvironmentsHelper do
end
end
describe '#environment_logs_data' do
subject { helper.environment_logs_data(project, environment) }
it 'returns environment parameters data' do
expect(subject).to include(
"current-environment-name": environment.name,
"environments-path": project_environments_path(project, format: :json)
)
end
it 'returns logs parameters data' do
expect(subject).to include(
"logs-path": logs_project_environment_path(project, environment, format: :json),
"logs-page": logs_project_environment_path(project, environment)
)
end
end
describe '#custom_metrics_available?' do
subject { helper.custom_metrics_available?(project) }
......
......@@ -2,46 +2,101 @@ import $ from 'jquery';
import KubernetesLogs from 'ee/kubernetes_logs';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { logMockData, podMockData } from './kubernetes_mock_data';
import { logMockData, podMockData, mockEnvironmentData } from './kubernetes_mock_data';
describe('Kubernetes Logs', () => {
const fixtureTemplate = 'static/environments_logs.html';
const mockPodName = 'production-tanuki-1';
const logMockPath = '/root/kubernetes-app/environments/1/logs';
let mockDataset;
let kubernetesLogContainer;
let kubernetesLog;
let mock;
let mockFlash;
preloadFixtures(fixtureTemplate);
describe('When data is requested correctly', () => {
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => [mockPodName]);
mock = new MockAdapter(axios);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => []);
mockFlash = spyOnDependency(KubernetesLogs, 'flash').and.callFake(() => []);
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
mock.onGet(logMockPath).reply(200, { logs: logMockData, pods: podMockData });
mockDataset = kubernetesLogContainer.dataset;
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
mock = new MockAdapter(axios);
mock.onGet(mockDataset.environmentsPath).reply(200, { environments: mockEnvironmentData });
mock.onGet(mockDataset.logsPath).reply(200, { logs: logMockData, pods: podMockData });
});
afterEach(() => {
mock.restore();
});
it('has the pod name placed on the dropdown', done => {
it('has the environment name placed on the dropdown', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const dropdown = document
.querySelector('.js-environment-dropdown')
.querySelector('.dropdown-menu-toggle');
expect(dropdown.textContent).toContain(mockDataset.currentEnvironmentName);
done();
})
.catch(done.fail);
});
it('loads all environments as options of their dropdown', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const options = document
.querySelector('.js-environment-dropdown')
.querySelectorAll('.dropdown-item');
expect(options.length).toEqual(mockEnvironmentData.length);
options.forEach((item, i) => {
expect(item.textContent.trim()).toBe(mockEnvironmentData[i].name);
});
done();
})
.catch(done.fail);
});
it('loads all pod names as options of their dropdown', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog.getPodLogs();
kubernetesLog
.getData()
.then(() => {
const options = document
.querySelector('.js-pod-dropdown')
.querySelectorAll('.dropdown-item');
setTimeout(() => {
const podDropdown = document
.querySelector('.js-pod-dropdown')
.querySelector('.dropdown-menu-toggle');
expect(options.length).toEqual(podMockData.length);
options.forEach((item, i) => {
expect(item.textContent.trim()).toBe(podMockData[i]);
});
done();
})
.catch(done.fail);
});
it('has the pod name placed on the dropdown', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const podDropdown = document
.querySelector('.js-pod-dropdown')
.querySelector('.dropdown-menu-toggle');
expect(podDropdown.textContent).toContain(mockPodName);
done();
}, 0);
expect(podDropdown.textContent).toContain(podMockData[0]);
done();
})
.catch(done.fail);
});
it('queries the pod log data and sets the dom elements', done => {
......@@ -49,41 +104,88 @@ describe('Kubernetes Logs', () => {
const toggleDisableSpy = spyOnDependency(KubernetesLogs, 'toggleDisableButton').and.stub();
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog.getPodLogs();
setTimeout(() => {
expect(kubernetesLog.isLogComplete).toEqual(true);
expect(kubernetesLog.$buildOutputContainer.text()).toContain(logMockData[0].trim());
expect(scrollSpy).toHaveBeenCalled();
expect(toggleDisableSpy).toHaveBeenCalled();
done();
}, 0);
expect(kubernetesLog.isLogComplete).toEqual(false);
kubernetesLog
.getData()
.then(() => {
expect(kubernetesLog.isLogComplete).toEqual(true);
expect(document.querySelector('.js-build-output').textContent).toContain(
logMockData[0].trim(),
);
expect(scrollSpy).toHaveBeenCalled();
expect(toggleDisableSpy).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('asks for the pod logs from another pod', done => {
const changePodLogSpy = spyOn(KubernetesLogs.prototype, 'getPodLogs').and.callThrough();
const changePodLogSpy = spyOn(KubernetesLogs.prototype, 'getData').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];
kubernetesLog
.getData()
.then(() => {
const podDropdown = document.querySelectorAll('.js-pod-dropdown .dropdown-menu button');
const anotherPod = podDropdown[podDropdown.length - 1];
anotherPod.click();
anotherPod.click();
expect(changePodLogSpy.calls.count()).toEqual(2);
done();
}, 0);
expect(changePodLogSpy.calls.count()).toEqual(2);
done();
})
.catch(done.fail);
});
it('clears the pod dropdown contents when pod logs are requested', done => {
const emptySpy = spyOn($.prototype, 'empty').and.callThrough();
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
// 3 elems should be emptied:
// 1. the environment dropdown items
// 2. the pods dropdown items
// 3. the job log contents
expect(emptySpy.calls.count()).toEqual(3);
done();
})
.catch(done.fail);
});
kubernetesLog.getPodLogs();
setTimeout(() => {
// This is because it clears both the job log contents and the dropdown
expect(emptySpy.calls.count()).toEqual(2);
done();
describe('shows an alert', () => {
it('with an error', done => {
mock.onGet(mockDataset.logsPath).reply(400);
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
expect(mockFlash.calls.count()).toEqual(1);
done();
})
.catch(done.fail);
});
it('with some explicit error', done => {
const errorMsg = 'Some k8s error';
mock.onGet(mockDataset.logsPath).reply(400, {
message: errorMsg,
});
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
expect(mockFlash.calls.count()).toEqual(1);
expect(mockFlash.calls.argsFor(0).join()).toContain(errorMsg);
done();
})
.catch(done.fail);
});
});
});
......@@ -92,14 +194,11 @@ describe('Kubernetes Logs', () => {
const hackyPodName = '">&lt;img src=x onerror=alert(document.domain)&gt; production';
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => [hackyPodName]);
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
mock = new MockAdapter(axios);
mock.onGet(logMockPath).reply(200, { logs: logMockData, pods: [hackyPodName] });
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
mock.onGet(mockDataset.logsPath).reply(200, { logs: logMockData, pods: [hackyPodName] });
});
afterEach(() => {
......@@ -115,18 +214,81 @@ describe('Kubernetes Logs', () => {
});
});
describe('When no pod name is available', () => {
describe('When data is not yet loaded into cache', () => {
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => [podMockData[1]]);
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
// override setTimeout, to simulate polling
const origSetTimeout = window.setTimeout;
spyOn(window, 'setTimeout').and.callFake(cb => origSetTimeout(cb, 0));
mockDataset = kubernetesLogContainer.dataset;
mock = new MockAdapter(axios);
mock.onGet(mockDataset.environmentsPath).reply(200, { environments: mockEnvironmentData });
// Simulate reactive cache, 2 tries needed
mock.onGet(`${mockDataset.logsPath}`, { pod_name: podMockData[1] }).replyOnce(202);
mock
.onGet(`${mockDataset.logsPath}`, { pod_name: podMockData[1] })
.reply(200, { logs: logMockData, pods: podMockData });
});
it('queries the pod log data polling for reactive cache', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
expect(kubernetesLog.isLogComplete).toEqual(false);
kubernetesLog
.getData()
.then(() => {
const calls = mock.history.get.filter(r => r.url === mockDataset.logsPath);
// expect 2 tries
expect(calls.length).toEqual(2);
expect(calls[0].params).toEqual({ pod_name: podMockData[1] });
expect(calls[1].params).toEqual({ pod_name: podMockData[1] });
expect(document.querySelector('.js-build-output').textContent).toContain(
logMockData[0].trim(),
);
done();
})
.catch(done.fail);
});
afterEach(() => {
mock.restore();
});
});
describe('When data is requested with a pod name', () => {
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => [podMockData[2]]);
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
mock = new MockAdapter(axios);
});
it('shows up a flash message when no pod name is specified', () => {
const createFlashSpy = spyOnDependency(KubernetesLogs, 'createFlash').and.stub();
it('logs are loaded with the correct pod_name parameter', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const logsCall = mock.history.get.filter(call => call.url === mockDataset.logsPath);
expect(createFlashSpy).toHaveBeenCalled();
expect(logsCall.length).toBe(1);
expect(logsCall[0].params.pod_name).toEqual(podMockData[2]);
done();
})
.catch(done.fail);
});
afterEach(() => {
mock.restore();
});
});
});
......@@ -104,6 +104,21 @@ export const logMockData = [
'- -> /',
];
export const podMockData = ['production-tanuki-1', 'production-tanuki-2'];
export const podMockData = ['production-tanuki-1', 'production-tanuki-2', 'production-tanuki-3'];
export const mockEnvironmentData = [
{
name: 'production',
id: 1,
},
{
name: 'stating',
id: 2,
},
{
name: 'review/my-new-branch',
id: 3,
},
];
export default {};
......@@ -6030,6 +6030,15 @@ msgstr ""
msgid "Environments|An error occurred while fetching the environments."
msgstr ""
msgid "Environments|An error occurred while fetching the logs"
msgstr ""
msgid "Environments|An error occurred while fetching the logs - Error: %{message}"
msgstr ""
msgid "Environments|An error occurred while fetching the logs for this environment or pod. Please try again"
msgstr ""
msgid "Environments|An error occurred while making the request."
msgstr ""
......@@ -6075,9 +6084,6 @@ msgstr ""
msgid "Environments|No deployments yet"
msgstr ""
msgid "Environments|No pod name has been specified"
msgstr ""
msgid "Environments|Note that this action will stop the environment, but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment due to no “stop environment action” being defined in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file."
msgstr ""
......@@ -14986,9 +14992,6 @@ msgstr ""
msgid "Something went wrong on our end."
msgstr ""
msgid "Something went wrong on our end. %{message}"
msgstr ""
msgid "Something went wrong on our end. Please try again!"
msgstr ""
......
<div class="js-kubernetes-logs" data-logs-path="/root/kubernetes-app/environments/1/logs">
<div class="build-page">
<div
class="js-kubernetes-logs"
data-current-environment-name="production"
data-environments-path="/root/my-project/environments.json"
data-logs-page="/root/my-project/environments/1/logs"
data-logs-path="/root/my-project/environments/1/logs.json"
>
<div class="build-page-pod-logs">
<div class="build-trace-container prepend-top-default">
<div class="top-bar js-top-bar">
<div class="truncated-info hidden-xs pull-left"></div>
<div class="dropdown prepend-left-10 js-pod-dropdown">
<button aria-expanded="false" class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
<i class="fa fa-chevron-down"></i>
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
<div class="top-bar js-top-bar d-flex">
<div class="row">
<div class="form-group col-6" role="group">
<label class="d-block col-form-label-sm col-form-label">
Environment
</label>
<div class="dropdown js-environment-dropdown d-flex">
<button
aria-expanded="false"
class="dropdown-menu-toggle d-flex align-content-center align-self-center"
data-toggle="dropdown"
type="button"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
<div class="dropdown-toggle-text">
&nbsp;
</div>
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
</div>
</div>
<div class="form-group col-6" role="group">
<label class="d-block col-form-label-sm col-form-label">
Pod logs from
</label>
<div class="dropdown js-pod-dropdown d-flex">
<button
aria-expanded="false"
class="dropdown-menu-toggle d-flex align-content-center align-self-center"
data-toggle="dropdown"
type="button"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
<div class="dropdown-toggle-text">
&nbsp;
</div>
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
</div>
</div>
</div>
<div class="controllers pull-right">
<div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Scroll to top">
<button class="js-scroll-up btn-scroll btn-transparent btn-blank" disabled type="button"></button>
<div class="controllers align-self-end">
<div
class="has-tooltip controllers-buttons"
data-container="body"
data-placement="top"
title="Scroll to top"
>
<button
class="js-scroll-up btn-scroll btn-transparent btn-blank"
disabled
type="button"
></button>
</div>
<div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Scroll to bottom">
<button class="js-scroll-down btn-scroll btn-transparent btn-blank" disabled type="button"></button>
<div
class="has-tooltip controllers-buttons"
data-container="body"
data-placement="top"
title="Scroll to bottom"
>
<button
class="js-scroll-down btn-scroll btn-transparent btn-blank"
disabled
type="button"
></button>
</div>
<div class="refresh-control pull-right">
<div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Refresh">
<button class="js-refresh-log btn-default btn-refresh" disabled type="button"></button>
<div class="refresh-control">
<div
class="has-tooltip controllers-buttons"
data-container="body"
data-placement="top"
title="Refresh"
>
<button
class="js-refresh-log btn btn-default btn-refresh h-32-px"
disabled
type="button"
></button>
</div>
</div>
</div>
</div>
<pre class="build-trace" id="build-trace"><code class="bash js-build-output"><div class="build-loader-animation js-build-refresh"></div></code></pre>
<pre class="build-trace" id="build-trace">
<code class="bash js-build-output"></code>
<div class="build-loader-animation js-build-refresh">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</pre>
</div>
</div>
</div>
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