Commit f85a287b authored by Miguel Rincon's avatar Miguel Rincon

Add filtered search to logs

Adds the filtered search and a first token for pods.
parent baee3dd3
......@@ -88,10 +88,9 @@ export default {
methods: {
...mapActions('environmentLogs', [
'setInitData',
'setSearch',
'showPodLogs',
'showEnvironment',
'fetchEnvironments',
'fetchLogs',
'fetchMoreLogsPrepend',
'dismissRequestEnvironmentsError',
'dismissInvalidTimeRangeWarning',
......@@ -189,13 +188,13 @@ export default {
<log-advanced-filters
v-if="showAdvancedFilters"
ref="log-advanced-filters"
class="d-md-flex flex-grow-1"
class="d-md-flex flex-grow-1 min-width-0"
:disabled="environments.isLoading"
/>
<log-simple-filters
v-else
ref="log-simple-filters"
class="d-md-flex flex-grow-1"
class="d-md-flex flex-grow-1 min-width-0"
:disabled="environments.isLoading"
/>
......@@ -203,7 +202,7 @@ export default {
ref="scrollButtons"
class="flex-grow-0 pr-2 mb-2 controllers"
:scroll-down-button-disabled="scrollDownButtonDisabled"
@refresh="showPodLogs(pods.current)"
@refresh="fetchLogs()"
@scrollDown="scrollDown"
/>
</div>
......
<script>
import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { mapActions, mapState } from 'vuex';
import {
GlIcon,
GlDropdown,
GlDropdownHeader,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByClick,
} from '@gitlab/ui';
import { GlFilteredSearch } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { timeRanges } from '~/vue_shared/constants';
import { TOKEN_TYPE_POD_NAME } from '../constants';
import TokenWithLoadingState from './tokens/token_with_loading_state.vue';
export default {
components: {
GlIcon,
GlDropdown,
GlDropdownHeader,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByClick,
GlFilteredSearch,
DateTimePicker,
},
props: {
......@@ -32,11 +22,10 @@ export default {
data() {
return {
timeRanges,
searchQuery: '',
};
},
computed: {
...mapState('environmentLogs', ['timeRange', 'pods']),
...mapState('environmentLogs', ['timeRange', 'pods', 'logs']),
timeRangeModel: {
get() {
......@@ -46,75 +35,56 @@ export default {
this.setTimeRange(val);
},
},
/**
* Token options.
*
* Returns null when no pods are present, so suggestions are displayed in the token
*/
podOptions() {
if (this.pods.options.length) {
return this.pods.options.map(podName => ({ value: podName, title: podName }));
}
return null;
},
podDropdownText() {
return this.pods.current || s__('Environments|All pods');
tokens() {
return [
{
icon: 'pod',
type: TOKEN_TYPE_POD_NAME,
title: s__('Environments|Pod name'),
token: TokenWithLoadingState,
operators: [{ value: '=', description: __('is'), default: 'true' }],
unique: true,
options: this.podOptions,
loading: this.logs.isLoading,
noOptionsText: s__('Environments|No pods to display'),
},
];
},
},
methods: {
...mapActions('environmentLogs', ['setSearch', 'showPodLogs', 'setTimeRange']),
isCurrentPod(podName) {
return podName === this.pods.current;
...mapActions('environmentLogs', ['showFilteredLogs', 'setTimeRange']),
filteredSearchSubmit(filters) {
this.showFilteredLogs(filters);
},
},
};
</script>
<template>
<div>
<gl-dropdown
ref="podsDropdown"
:text="podDropdownText"
:disabled="disabled"
class="mb-2 gl-h-32 pr-2 d-flex d-md-block flex-grow-0 qa-pods-dropdown"
>
<gl-dropdown-header class="text-center">
{{ s__('Environments|Filter by pod') }}
</gl-dropdown-header>
<gl-dropdown-item v-if="!pods.options.length" disabled>
<span ref="noPodsMsg" class="text-muted">
{{ s__('Environments|No pods to display') }}
</span>
</gl-dropdown-item>
<template v-else>
<gl-dropdown-item ref="allPodsOption" key="all-pods" @click="showPodLogs(null)">
<div class="d-flex">
<gl-icon
:class="{ invisible: pods.current !== null }"
name="status_success_borderless"
/>
<div class="flex-grow-1">{{ s__('Environments|All pods') }}</div>
</div>
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-item
v-for="podName in pods.options"
:key="podName"
class="text-nowrap"
@click="showPodLogs(podName)"
>
<div class="d-flex">
<gl-icon
:class="{ invisible: !isCurrentPod(podName) }"
name="status_success_borderless"
/>
<div class="flex-grow-1">{{ podName }}</div>
</div>
</gl-dropdown-item>
</template>
</gl-dropdown>
<gl-search-box-by-click
ref="searchBox"
v-model.trim="searchQuery"
:disabled="disabled"
:placeholder="s__('Environments|Search')"
class="mb-2 pr-2 flex-grow-1"
type="search"
autofocus
@submit="setSearch(searchQuery)"
/>
<div class="mb-2 pr-2 flex-grow-1 min-width-0">
<gl-filtered-search
:placeholder="__('Search')"
:clear-button-title="__('Clear')"
:close-button-title="__('Close')"
class="gl-h-32"
:disabled="disabled || logs.isLoading"
:available-tokens="tokens"
@submit="filteredSearchSubmit"
/>
</div>
<date-time-picker
ref="dateTimePicker"
......
<script>
import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlFilteredSearchToken,
GlLoadingIcon,
},
inheritAttrs: false,
props: {
config: {
type: Object,
required: true,
},
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$attrs }" v-on="$listeners">
<template #suggestions>
<div class="m-1">
<gl-loading-icon v-if="config.loading" />
<div v-else class="py-1 px-2 text-muted">
{{ config.noOptionsText }}
</div>
</div>
</template>
</gl-filtered-search-token>
</template>
export const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
......@@ -2,6 +2,7 @@ import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { TOKEN_TYPE_POD_NAME } from '../constants';
import * as types from './mutation_types';
......@@ -49,19 +50,42 @@ const requestLogsUntilData = ({ commit, state }) => {
return requestUntilData(logs_api_path, params);
};
/**
* Converts filters emitted by the component, e.g. a filterered-search
* to parameters to be applied to the filters of the store
* @param {Array} filters - List of strings or objects to filter by.
* @returns {Object} - An object with `search` and `podName` keys.
*/
const filtersToParams = (filters = []) => {
// Strings become part of the `search`
const search = filters
.filter(f => typeof f === 'string')
.join(' ')
.trim();
// null podName to show all pods
const podName = filters.find(f => f?.type === TOKEN_TYPE_POD_NAME)?.value?.data ?? null;
return { search, podName };
};
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
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) => {
export const showFilteredLogs = ({ dispatch, commit }, filters = []) => {
const { podName, search } = filtersToParams(filters);
commit(types.SET_CURRENT_POD_NAME, podName);
commit(types.SET_SEARCH, search);
dispatch('fetchLogs');
};
export const setSearch = ({ dispatch, commit }, searchQuery) => {
commit(types.SET_SEARCH, searchQuery);
export const showPodLogs = ({ dispatch, commit }, podName) => {
commit(types.SET_CURRENT_POD_NAME, podName);
dispatch('fetchLogs');
};
......
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import dateFormat from 'dateformat';
const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
import { dateFormatMask } from './constants';
/**
* Returns a time range (`start`, `end`) where `start` is the
......
......@@ -54,6 +54,11 @@
.mh-50vh { max-height: 50vh; }
.min-width-0 {
// By default flex items don't shrink below their minimum content size. To change this, set the item's min-width
min-width: 0;
}
.font-size-inherit { font-size: inherit; }
.gl-w-8 { width: px-to-rem($grid-size); }
.gl-w-16 { width: px-to-rem($grid-size * 2); }
......
---
title: Add filtered search for elastic search in logs
merge_request: 27654
author:
type: added
......@@ -14,7 +14,7 @@ Everything you need to build, test, deploy, and run your app at scale.
[Kubernetes](https://kubernetes.io) logs can be viewed directly within GitLab.
![Pod logs](img/kubernetes_pod_logs_v12_9.png)
![Pod logs](img/kubernetes_pod_logs_v12_10.png)
## Requirements
......@@ -32,7 +32,7 @@ You can access them in two ways.
Go to **{cloud-gear}** **Operations > Logs** on the sidebar menu.
![Sidebar menu](img/sidebar_menu_pod_logs_v12_5.png)
![Sidebar menu](img/sidebar_menu_pod_logs_v12_10.png)
### From Deploy Boards
......
......@@ -7860,9 +7860,6 @@ msgstr ""
msgid "EnvironmentsDashboard|This dashboard displays a maximum of 7 projects and 3 environments per project. %{readMoreLink}"
msgstr ""
msgid "Environments|All pods"
msgstr ""
msgid "Environments|An error occurred while canceling the auto stop, please try again"
msgstr ""
......@@ -7929,9 +7926,6 @@ msgstr ""
msgid "Environments|Environments are places where code gets deployed, such as staging or production."
msgstr ""
msgid "Environments|Filter by pod"
msgstr ""
msgid "Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search."
msgstr ""
......@@ -7971,6 +7965,9 @@ msgstr ""
msgid "Environments|Open live environment"
msgstr ""
msgid "Environments|Pod name"
msgstr ""
msgid "Environments|Re-deploy"
msgstr ""
......@@ -7998,9 +7995,6 @@ msgstr ""
msgid "Environments|Rollback environment %{name}?"
msgstr ""
msgid "Environments|Search"
msgstr ""
msgid "Environments|Select environment"
msgstr ""
......
......@@ -10,7 +10,6 @@ import {
mockPods,
mockLogsResult,
mockTrace,
mockPodName,
mockEnvironmentsEndpoint,
mockDocumentationPath,
} from '../mock_data';
......@@ -298,11 +297,11 @@ describe('EnvironmentLogs', () => {
});
it('refresh button, trace is refreshed', () => {
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
expect(dispatch).not.toHaveBeenCalledWith(`${module}/fetchLogs`, undefined);
findLogControlButtons().vm.$emit('refresh');
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPodName);
expect(dispatch).toHaveBeenCalledWith(`${module}/fetchLogs`, undefined);
});
});
});
......
import { GlIcon, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { defaultTimeRange } from '~/vue_shared/constants';
import { GlFilteredSearch } from '@gitlab/ui';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { createStore } from '~/logs/stores';
import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
import { mockPods, mockSearch } from '../mock_data';
import LogAdvancedFilters from '~/logs/components/log_advanced_filters.vue';
......@@ -15,26 +16,19 @@ describe('LogAdvancedFilters', () => {
let wrapper;
let state;
const findPodsDropdown = () => wrapper.find({ ref: 'podsDropdown' });
const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' });
const findPodsDropdownItems = () =>
findPodsDropdown()
.findAll(GlDropdownItem)
.filter(item => !item.is('[disabled]'));
const findPodsDropdownItemsSelected = () =>
findPodsDropdownItems()
.filter(item => {
return !item.find(GlIcon).classes('invisible');
})
.at(0);
const findSearchBox = () => wrapper.find({ ref: 'searchBox' });
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
const getSearchToken = type =>
findFilteredSearch()
.props('availableTokens')
.filter(token => token.type === type)[0];
const mockStateLoading = () => {
state.timeRange.selected = defaultTimeRange;
state.timeRange.current = convertToFixedRange(defaultTimeRange);
state.pods.options = [];
state.pods.current = null;
state.logs.isLoading = true;
};
const mockStateWithData = () => {
......@@ -42,6 +36,7 @@ describe('LogAdvancedFilters', () => {
state.timeRange.current = convertToFixedRange(defaultTimeRange);
state.pods.options = mockPods;
state.pods.current = null;
state.logs.isLoading = false;
};
const initWrapper = (propsData = {}) => {
......@@ -76,11 +71,18 @@ describe('LogAdvancedFilters', () => {
expect(wrapper.isVueInstance()).toBe(true);
expect(wrapper.isEmpty()).toBe(false);
expect(findPodsDropdown().exists()).toBe(true);
expect(findSearchBox().exists()).toBe(true);
expect(findFilteredSearch().exists()).toBe(true);
expect(findTimeRangePicker().exists()).toBe(true);
});
it('displays search tokens', () => {
expect(getSearchToken(TOKEN_TYPE_POD_NAME)).toMatchObject({
title: 'Pod name',
unique: true,
operators: [expect.objectContaining({ value: '=' })],
});
});
describe('disabled state', () => {
beforeEach(() => {
mockStateLoading();
......@@ -90,9 +92,7 @@ describe('LogAdvancedFilters', () => {
});
it('displays disabled filters', () => {
expect(findPodsDropdown().props('text')).toBe('All pods');
expect(findPodsDropdown().attributes('disabled')).toBeTruthy();
expect(findSearchBox().attributes('disabled')).toBeTruthy();
expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
expect(findTimeRangePicker().attributes('disabled')).toBeTruthy();
});
});
......@@ -103,16 +103,17 @@ describe('LogAdvancedFilters', () => {
initWrapper();
});
it('displays a enabled filters', () => {
expect(findPodsDropdown().props('text')).toBe('All pods');
expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
expect(findSearchBox().attributes('disabled')).toBeFalsy();
it('displays a disabled search', () => {
expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
});
it('displays an enable date filter', () => {
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
});
it('displays an empty pods dropdown', () => {
expect(findPodsNoPodsText().exists()).toBe(true);
expect(findPodsDropdownItems()).toHaveLength(0);
it('displays no pod options when no pods are available, so suggestions can be displayed', () => {
expect(getSearchToken(TOKEN_TYPE_POD_NAME).options).toBe(null);
expect(getSearchToken(TOKEN_TYPE_POD_NAME).loading).toBe(true);
});
});
......@@ -122,20 +123,24 @@ describe('LogAdvancedFilters', () => {
initWrapper();
});
it('displays an enabled pods dropdown', () => {
expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
expect(findPodsDropdown().props('text')).toBe('All pods');
it('displays a single token for pods', () => {
initWrapper();
const tokens = findFilteredSearch().props('availableTokens');
expect(tokens).toHaveLength(1);
expect(tokens[0].type).toBe(TOKEN_TYPE_POD_NAME);
});
it('displays options in a pods dropdown', () => {
const items = findPodsDropdownItems();
expect(items).toHaveLength(mockPods.length + 1);
it('displays a enabled filters', () => {
expect(findFilteredSearch().attributes('disabled')).toBeFalsy();
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
});
it('displays "all pods" selected in a pods dropdown', () => {
const selected = findPodsDropdownItemsSelected();
it('displays options in the pods token', () => {
const { options } = getSearchToken(TOKEN_TYPE_POD_NAME);
expect(selected.text()).toBe('All pods');
expect(options).toHaveLength(mockPods.length);
});
it('displays options in date time picker', () => {
......@@ -146,30 +151,16 @@ describe('LogAdvancedFilters', () => {
});
describe('when the user interacts', () => {
it('clicks on a all options, showPodLogs is dispatched with null', () => {
const items = findPodsDropdownItems();
items.at(0).vm.$emit('click');
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, null);
});
it('clicks on a pod name, showPodLogs is dispatched with pod name', () => {
const items = findPodsDropdownItems();
const index = 2; // any pod
it('clicks on the search button, showFilteredLogs is dispatched', () => {
findFilteredSearch().vm.$emit('submit', null);
items.at(index + 1).vm.$emit('click'); // skip "All pods" option
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]);
expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, null);
});
it('clicks on search, a serches is done', () => {
expect(findSearchBox().attributes('disabled')).toBeFalsy();
// input a query and click `search`
findSearchBox().vm.$emit('input', mockSearch);
findSearchBox().vm.$emit('submit');
it('clicks on the search button, showFilteredLogs is dispatched with null', () => {
findFilteredSearch().vm.$emit('submit', [mockSearch]);
expect(dispatch).toHaveBeenCalledWith(`${module}/setSearch`, mockSearch);
expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, [mockSearch]);
});
it('selects a new time range', () => {
......
import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TokenWithLoadingState from '~/logs/components/tokens/token_with_loading_state.vue';
describe('TokenWithLoadingState', () => {
let wrapper;
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const initWrapper = (props = {}, options) => {
wrapper = shallowMount(TokenWithLoadingState, {
propsData: props,
...options,
});
};
beforeEach(() => {});
it('passes entire config correctly', () => {
const config = {
icon: 'pod',
type: 'pod',
title: 'Pod name',
unique: true,
};
initWrapper({ config });
expect(findFilteredSearchToken().props('config')).toEqual(config);
});
describe('suggestions are replaced', () => {
let mockNoOptsText;
let config;
let stubs;
beforeEach(() => {
mockNoOptsText = 'No suggestions available';
config = {
loading: false,
noOptionsText: mockNoOptsText,
};
stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="suggestions"></slot></div>`,
},
};
});
it('renders a loading icon', () => {
config.loading = true;
initWrapper({ config }, { stubs });
expect(findLoadingIcon().exists()).toBe(true);
expect(wrapper.text()).toBe('');
});
it('renders an empty results message', () => {
initWrapper({ config }, { stubs });
expect(findLoadingIcon().exists()).toBe(false);
expect(wrapper.text()).toBe(mockNoOptsText);
});
});
});
......@@ -6,7 +6,7 @@ import { convertToFixedRange } from '~/lib/utils/datetime_range';
import logsPageState from '~/logs/stores/state';
import {
setInitData,
setSearch,
showFilteredLogs,
showPodLogs,
fetchEnvironments,
fetchLogs,
......@@ -31,6 +31,7 @@ import {
mockCursor,
mockNextCursor,
} from '../mock_data';
import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
jest.mock('~/flash');
jest.mock('~/lib/utils/datetime_range');
......@@ -93,13 +94,80 @@ describe('Logs Store actions', () => {
));
});
describe('setSearch', () => {
it('should commit search mutation', () =>
describe('showFilteredLogs', () => {
it('empty search should filter with defaults', () =>
testAction(
setSearch,
mockSearch,
showFilteredLogs,
undefined,
state,
[{ type: types.SET_SEARCH, payload: mockSearch }],
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: '' },
],
[{ type: 'fetchLogs' }],
));
it('text search should filter with a search term', () =>
testAction(
showFilteredLogs,
[mockSearch],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: mockSearch },
],
[{ type: 'fetchLogs' }],
));
it('pod search should filter with a search term', () =>
testAction(
showFilteredLogs,
[{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: '' },
],
[{ type: 'fetchLogs' }],
));
it('pod search should filter with a pod selection and a search term', () =>
testAction(
showFilteredLogs,
[{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, mockSearch],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: mockSearch },
],
[{ type: 'fetchLogs' }],
));
it('pod search should filter with a pod selection and two search terms', () =>
testAction(
showFilteredLogs,
['term1', 'term2'],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: `term1 term2` },
],
[{ type: 'fetchLogs' }],
));
it('pod search should filter with a pod selection and a search terms before and after', () =>
testAction(
showFilteredLogs,
[
'term1',
{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } },
'term2',
],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: `term1 term2` },
],
[{ type: 'fetchLogs' }],
));
});
......
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