Skip to content
Snippets Groups Projects
Commit a210c43e authored by GitLab Bot's avatar GitLab Bot
Browse files

Add latest changes from gitlab-org/gitlab@master

parent c9687bdf
No related branches found
No related tags found
No related merge requests found
Showing
with 804 additions and 7 deletions
Loading
Loading
@@ -492,6 +492,41 @@ const Api = {
buildUrl(url) {
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;
Loading
Loading
@@ -27,6 +27,10 @@ export default {
key: 'size',
label: __('Size'),
},
{
key: 'cpu',
label: __('Total cores (vCPUs)'),
},
{
key: 'memory',
label: __('Total memory (GB)'),
Loading
Loading
<script>
import { isNil } from 'lodash';
import $ from 'jquery';
import { GlIcon } from '@gitlab/ui';
import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
 
const toArray = value => [].concat(value);
const toArray = value => (isNil(value) ? [] : [].concat(value));
const itemsProp = (items, prop) => items.map(item => item[prop]);
const defaultSearchFn = (searchQuery, labelProp) => item =>
item[labelProp].toLowerCase().indexOf(searchQuery) > -1;
Loading
Loading
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlDropdown, GlDropdownItem, GlFormGroup, GlSearchBoxByClick, GlAlert } from '@gitlab/ui';
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 { timeRanges, defaultTimeRange } from '~/monitoring/constants';
import { timeRangeFromUrl } from '~/monitoring/utils';
export default {
components: {
GlAlert,
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlSearchBoxByClick,
DateTimePicker,
LogControlButtons,
},
props: {
environmentName: {
type: String,
required: false,
default: '',
},
currentPodName: {
type: [String, null],
required: false,
default: null,
},
environmentsPath: {
type: String,
required: false,
default: '',
},
clusterApplicationsDocumentationPath: {
type: String,
required: true,
},
},
data() {
return {
searchQuery: '',
timeRanges,
isElasticStackCalloutDismissed: false,
};
},
computed: {
...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']),
...mapGetters('environmentLogs', ['trace']),
timeRangeModel: {
get() {
return this.timeRange.current;
},
set(val) {
this.setTimeRange(val);
},
},
showLoader() {
return this.logs.isLoading || !this.logs.isComplete;
},
advancedFeaturesEnabled() {
const environment = this.environments.options.find(
({ name }) => name === this.environments.current,
);
return environment && environment.enable_advanced_logs_querying;
},
disableAdvancedControls() {
return this.environments.isLoading || !this.advancedFeaturesEnabled;
},
shouldShowElasticStackCallout() {
return !this.isElasticStackCalloutDismissed && this.disableAdvancedControls;
},
},
watch: {
trace(val) {
this.$nextTick(() => {
if (val) {
scrollDown();
}
this.$refs.scrollButtons.update();
});
},
},
mounted() {
this.setInitData({
timeRange: timeRangeFromUrl() || defaultTimeRange,
environmentName: this.environmentName,
podName: this.currentPodName,
});
this.fetchEnvironments(this.environmentsPath);
},
methods: {
...mapActions('environmentLogs', [
'setInitData',
'setSearch',
'setTimeRange',
'showPodLogs',
'showEnvironment',
'fetchEnvironments',
]),
},
};
</script>
<template>
<div class="build-page-pod-logs mt-3">
<gl-alert
v-if="shouldShowElasticStackCallout"
class="mb-3 js-elasticsearch-alert"
@dismiss="isElasticStackCalloutDismissed = true"
>
{{
s__(
'Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search.',
)
}}
<a :href="clusterApplicationsDocumentationPath">
<strong>
{{ s__('View Documentation') }}
</strong>
</a>
</gl-alert>
<div class="top-bar js-top-bar d-flex">
<div class="row mx-n1">
<gl-form-group
id="environments-dropdown-fg"
:label="s__('Environments|Environment')"
label-size="sm"
label-for="environments-dropdown"
class="col-3 px-1"
>
<gl-dropdown
id="environments-dropdown"
:text="environments.current"
:disabled="environments.isLoading"
class="d-flex gl-h-32 js-environments-dropdown"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="env in environments.options"
:key="env.id"
@click="showEnvironment(env.name)"
>
{{ env.name }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
id="pods-dropdown-fg"
:label="s__('Environments|Logs from')"
label-size="sm"
label-for="pods-dropdown"
class="col-3 px-1"
>
<gl-dropdown
id="pods-dropdown"
:text="pods.current || s__('Environments|No pods to display')"
:disabled="environments.isLoading"
class="d-flex gl-h-32 js-pods-dropdown"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="podName in pods.options"
:key="podName"
@click="showPodLogs(podName)"
>
{{ podName }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
id="dates-fg"
:label="s__('Environments|Show last')"
label-size="sm"
label-for="time-window-dropdown"
class="col-3 px-1"
>
<date-time-picker
ref="dateTimePicker"
v-model="timeRangeModel"
class="w-100 gl-h-32"
:disabled="disableAdvancedControls"
:options="timeRanges"
/>
</gl-form-group>
<gl-form-group
id="search-fg"
:label="s__('Environments|Search')"
label-size="sm"
label-for="search"
class="col-3 px-1"
>
<gl-search-box-by-click
v-model.trim="searchQuery"
:disabled="disableAdvancedControls"
:placeholder="s__('Environments|Search')"
class="js-logs-search"
type="search"
autofocus
@submit="setSearch(searchQuery)"
/>
</gl-form-group>
</div>
<log-control-buttons
ref="scrollButtons"
class="controllers align-self-end mb-1"
@refresh="showPodLogs(pods.current)"
/>
</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">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div></code></pre>
</div>
</template>
<script>
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';
export default {
components: {
Icon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
data() {
return {
scrollToTopEnabled: false,
scrollToBottomEnabled: false,
};
},
created() {
window.addEventListener('scroll', this.update);
},
destroyed() {
window.removeEventListener('scroll', this.update);
},
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() {
this.$emit('refresh');
},
scrollUp,
scrollDown,
},
};
</script>
<template>
<div>
<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="handleRefreshClick"
>
<icon name="retry" />
</gl-button>
</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 Api from '~/api';
import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import { s__ } from '~/locale';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import * as types from './mutation_types';
const flashTimeRangeWarning = () => {
flash(s__('Metrics|Invalid time range, please verify.'), 'warning');
};
const flashLogsError = () => {
flash(s__('Metrics|There was an error fetching the logs, please try again'));
};
const requestLogsUntilData = params =>
backOff((next, stop) => {
Api.getPodLogs(params)
.then(res => {
if (res.status === httpStatusCodes.ACCEPTED) {
next();
return;
}
stop(res);
})
.catch(err => {
stop(err);
});
});
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
if (timeRange) {
commit(types.SET_TIME_RANGE, timeRange);
}
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, podName);
};
export const showPodLogs = ({ dispatch, commit }, podName) => {
commit(types.SET_CURRENT_POD_NAME, podName);
dispatch('fetchLogs');
};
export const setSearch = ({ dispatch, commit }, searchQuery) => {
commit(types.SET_SEARCH, searchQuery);
dispatch('fetchLogs');
};
export const setTimeRange = ({ dispatch, commit }, timeRange) => {
commit(types.SET_TIME_RANGE, timeRange);
dispatch('fetchLogs');
};
export const showEnvironment = ({ dispatch, commit }, environmentName) => {
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, null);
dispatch('fetchLogs');
};
export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
commit(types.REQUEST_ENVIRONMENTS_DATA);
axios
.get(environmentsPath)
.then(({ data }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
dispatch('fetchLogs');
})
.catch(() => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
flash(s__('Metrics|There was an error fetching the environments data, please try again'));
});
};
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_LOGS_DATA);
return requestLogsUntilData(params)
.then(({ data }) => {
const { pod_name, pods, logs } = data;
commit(types.SET_CURRENT_POD_NAME, pod_name);
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
commit(types.RECEIVE_LOGS_DATA_SUCCESS, logs);
})
.catch(() => {
commit(types.RECEIVE_PODS_DATA_ERROR);
commit(types.RECEIVE_LOGS_DATA_ERROR);
flashLogsError();
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import dateFormat from 'dateformat';
export const trace = state =>
state.logs.lines
.map(item => [dateFormat(item.timestamp, 'UTC:mmm dd HH:MM:ss.l"Z"'), item.message].join(' | '))
.join('\n');
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
modules: {
environmentLogs: {
namespaced: true,
actions,
mutations,
state: state(),
getters,
},
},
});
export default createStore;
export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT';
export const SET_SEARCH = 'SET_SEARCH';
export const SET_TIME_RANGE = 'SET_TIME_RANGE';
export const SET_CURRENT_POD_NAME = 'SET_CURRENT_POD_NAME';
export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR';
export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
export const REQUEST_PODS_DATA = 'REQUEST_PODS_DATA';
export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
export const RECEIVE_PODS_DATA_ERROR = 'RECEIVE_PODS_DATA_ERROR';
import * as types from './mutation_types';
export default {
/** Search data */
[types.SET_SEARCH](state, searchQuery) {
state.search = searchQuery;
},
/** Time Range data */
[types.SET_TIME_RANGE](state, timeRange) {
state.timeRange.current = timeRange;
},
/** Environments data */
[types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
state.environments.current = environmentName;
},
[types.REQUEST_ENVIRONMENTS_DATA](state) {
state.environments.options = [];
state.environments.isLoading = true;
},
[types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environmentOptions) {
state.environments.options = environmentOptions;
state.environments.isLoading = false;
},
[types.RECEIVE_ENVIRONMENTS_DATA_ERROR](state) {
state.environments.options = [];
state.environments.isLoading = false;
},
/** Logs data */
[types.REQUEST_LOGS_DATA](state) {
state.logs.lines = [];
state.logs.isLoading = true;
state.logs.isComplete = false;
},
[types.RECEIVE_LOGS_DATA_SUCCESS](state, lines) {
state.logs.lines = lines;
state.logs.isLoading = false;
state.logs.isComplete = true;
},
[types.RECEIVE_LOGS_DATA_ERROR](state) {
state.logs.lines = [];
state.logs.isLoading = false;
state.logs.isComplete = true;
},
/** Pods data */
[types.SET_CURRENT_POD_NAME](state, podName) {
state.pods.current = podName;
},
[types.REQUEST_PODS_DATA](state) {
state.pods.options = [];
},
[types.RECEIVE_PODS_DATA_SUCCESS](state, podOptions) {
state.pods.options = podOptions;
},
[types.RECEIVE_PODS_DATA_ERROR](state) {
state.pods.options = [];
},
};
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
export default () => ({
/**
* Full text search
*/
search: '',
/**
* Time range (Show last)
*/
timeRange: {
options: timeRanges,
current: defaultTimeRange,
},
/**
* Environments list information
*/
environments: {
options: [],
isLoading: false,
current: null,
},
/**
* Logs including trace
*/
logs: {
lines: [],
isLoading: false,
isComplete: true,
},
/**
* Pods list information
*/
pods: {
options: [],
current: null,
},
});
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
/**
* Returns a time range (`start`, `end`) where `start` is the
* current time minus a given number of seconds and `end`
* is the current time (`now()`).
*
* @param {Number} seconds Seconds duration, defaults to 0.
* @returns {Object} range Time range
* @returns {String} range.start ISO String of current time minus given seconds
* @returns {String} range.end ISO String of current time
*/
export const getTimeRange = (seconds = 0) => {
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
const start = end - seconds;
return {
start: new Date(secondsToMilliseconds(start)).toISOString(),
end: new Date(secondsToMilliseconds(end)).toISOString(),
};
};
export default {};
import logsBundle from '~/logs';
document.addEventListener('DOMContentLoaded', logsBundle);
Loading
Loading
@@ -524,6 +524,8 @@ img.emoji {
cursor: pointer;
}
 
.cursor-not-allowed { cursor: not-allowed; }
// this needs to use "!important" due to some very specific styles
// around buttons
.cursor-default {
Loading
Loading
Loading
Loading
@@ -357,3 +357,42 @@
}
}
}
.build-page-pod-logs {
.build-trace-container {
position: relative;
}
.build-trace {
@include build-trace();
}
.top-bar {
@include build-trace-top-bar($gl-line-height * 5);
.dropdown-menu-toggle {
width: 200px;
@include media-breakpoint-up(sm) {
width: 300px;
}
}
.controllers {
@include build-controllers(16px, flex-end, true, 2);
}
.refresh-control {
@include build-controllers(16px, flex-end, true, 0);
margin-left: 2px;
}
}
.btn-refresh svg {
top: 0;
}
.build-loader-animation {
@include build-loader-animation;
}
}
# frozen_string_literal: true
module Projects
class LogsController < Projects::ApplicationController
before_action :authorize_read_pod_logs!
before_action :environment
before_action :ensure_deployments, only: %i(k8s elasticsearch)
def index
if environment.nil?
render :empty_logs
else
render :index
end
end
def k8s
render_logs(::PodLogs::KubernetesService, k8s_params)
end
def elasticsearch
render_logs(::PodLogs::ElasticsearchService, elasticsearch_params)
end
private
def render_logs(service, permitted_params)
::Gitlab::UsageCounters::PodLogs.increment(project.id)
::Gitlab::PollingInterval.set_header(response, interval: 3_000)
result = service.new(cluster, namespace, params: permitted_params).execute
if result.nil?
head :accepted
elsif result[:status] == :success
render json: result
else
render status: :bad_request, json: result
end
end
def index_params
params.permit(:environment_name)
end
def k8s_params
params.permit(:container_name, :pod_name)
end
def elasticsearch_params
params.permit(:container_name, :pod_name, :search, :start, :end)
end
def environment
@environment ||= if index_params.key?(:environment_name)
EnvironmentsFinder.new(project, current_user, name: index_params[:environment_name]).find.first
else
project.default_environment
end
end
def cluster
environment.deployment_platform&.cluster
end
def namespace
environment.deployment_namespace
end
def ensure_deployments
return if cluster && namespace.present?
render status: :bad_request, json: {
status: :error,
message: _('Environment does not have deployments')
}
end
end
end
Loading
Loading
@@ -41,4 +41,13 @@ module EnvironmentsHelper
"external-dashboard-url" => project.metrics_setting_external_dashboard_url
}
end
def environment_logs_data(project, environment)
{
"environment-name": environment.name,
"environments-path": project_environments_path(project, format: :json),
"environment-id": environment.id,
"cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack')
}
end
end
Loading
Loading
@@ -330,6 +330,10 @@ class Environment < ApplicationRecord
self.auto_stop_at = parsed_result.seconds.from_now
end
 
def elastic_stack_available?
!!deployment_platform&.cluster&.application_elastic_stack&.available?
end
private
 
def has_metrics_and_can_query?
Loading
Loading
Loading
Loading
@@ -18,12 +18,6 @@ class SnippetRepository < ApplicationRecord
end
end
 
def create_file(user, path, content, **options)
options[:actions] = transform_file_entries([{ file_path: path, content: content }])
capture_git_error { repository.multi_action(user, **options) }
end
def multi_files_action(user, files = [], **options)
return if files.nil? || files.empty?
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment