Commit c9c7e7cb authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'filter-sentry-error-list' into 'master'

Filter sentry error list by status (resolved/ignored/unresolved)

See merge request gitlab-org/gitlab!26205
parents b75dc6b9 eccddcb1
...@@ -16,7 +16,6 @@ import { ...@@ -16,7 +16,6 @@ import {
GlButtonGroup, GlButtonGroup,
} from '@gitlab/ui'; } from '@gitlab/ui';
import AccessorUtils from '~/lib/utils/accessor'; import AccessorUtils from '~/lib/utils/accessor';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
...@@ -59,7 +58,7 @@ export default { ...@@ -59,7 +58,7 @@ export default {
{ {
key: 'status', key: 'status',
label: '', label: '',
tdClass: `${tableDataClass} text-right`, tdClass: `${tableDataClass} text-center`,
}, },
{ {
key: 'details', key: 'details',
...@@ -67,6 +66,11 @@ export default { ...@@ -67,6 +66,11 @@ export default {
thClass: 'invisible w-0', thClass: 'invisible w-0',
}, },
], ],
statusFilters: {
unresolved: __('Unresolved'),
ignored: __('Ignored'),
resolved: __('Resolved'),
},
sortFields: { sortFields: {
last_seen: __('Last Seen'), last_seen: __('Last Seen'),
first_seen: __('First Seen'), first_seen: __('First Seen'),
...@@ -83,7 +87,6 @@ export default { ...@@ -83,7 +87,6 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlFormInput, GlFormInput,
Icon,
GlPagination, GlPagination,
TimeAgo, TimeAgo,
GlButtonGroup, GlButtonGroup,
...@@ -136,6 +139,7 @@ export default { ...@@ -136,6 +139,7 @@ export default {
'sortField', 'sortField',
'recentSearches', 'recentSearches',
'pagination', 'pagination',
'statusFilter',
'cursor', 'cursor',
]), ]),
paginationRequired() { paginationRequired() {
...@@ -169,6 +173,7 @@ export default { ...@@ -169,6 +173,7 @@ export default {
'fetchPaginatedResults', 'fetchPaginatedResults',
'updateStatus', 'updateStatus',
'removeIgnoredResolvedErrors', 'removeIgnoredResolvedErrors',
'filterByStatus',
]), ]),
setSearchText(text) { setSearchText(text) {
this.errorSearchQuery = text; this.errorSearchQuery = text;
...@@ -191,9 +196,16 @@ export default { ...@@ -191,9 +196,16 @@ export default {
isCurrentSortField(field) { isCurrentSortField(field) {
return field === this.sortField; return field === this.sortField;
}, },
isCurrentStatusFilter(filter) {
return filter === this.statusFilter;
},
getIssueUpdatePath(errorId) { getIssueUpdatePath(errorId) {
return `/${this.projectPath}/-/error_tracking/${errorId}.json`; return `/${this.projectPath}/-/error_tracking/${errorId}.json`;
}, },
filterErrors(status, label) {
this.filterValue = label;
return this.filterByStatus(status);
},
updateIssueStatus(errorId, status) { updateIssueStatus(errorId, status) {
this.updateStatus({ this.updateStatus({
endpoint: this.getIssueUpdatePath(errorId), endpoint: this.getIssueUpdatePath(errorId),
...@@ -260,11 +272,32 @@ export default { ...@@ -260,11 +272,32 @@ export default {
</div> </div>
<gl-dropdown <gl-dropdown
class="sort-control" :text="$options.statusFilters[statusFilter]"
class="status-dropdown mr-2"
menu-class="dropdown"
:disabled="loading"
>
<gl-dropdown-item
v-for="(label, status) in $options.statusFilters"
:key="status"
@click="filterErrors(status, label)"
>
<span class="d-flex">
<gl-icon
class="flex-shrink-0 append-right-4"
:class="{ invisible: !isCurrentStatusFilter(status) }"
name="mobile-issue-close"
/>
{{ label }}
</span>
</gl-dropdown-item>
</gl-dropdown>
<gl-dropdown
:text="$options.sortFields[sortField]" :text="$options.sortFields[sortField]"
left left
:disabled="loading" :disabled="loading"
menu-class="sort-dropdown" menu-class="dropdown"
> >
<gl-dropdown-item <gl-dropdown-item
v-for="(label, field) in $options.sortFields" v-for="(label, field) in $options.sortFields"
...@@ -272,7 +305,7 @@ export default { ...@@ -272,7 +305,7 @@ export default {
@click="sortByField(field)" @click="sortByField(field)"
> >
<span class="d-flex"> <span class="d-flex">
<icon <gl-icon
class="flex-shrink-0 append-right-4" class="flex-shrink-0 append-right-4"
:class="{ invisible: !isCurrentSortField(field) }" :class="{ invisible: !isCurrentSortField(field) }"
name="mobile-issue-close" name="mobile-issue-close"
......
...@@ -18,6 +18,7 @@ export function startPolling({ state, commit, dispatch }) { ...@@ -18,6 +18,7 @@ export function startPolling({ state, commit, dispatch }) {
search_term: state.searchQuery, search_term: state.searchQuery,
sort: state.sortField, sort: state.sortField,
cursor: state.cursor, cursor: state.cursor,
issue_status: state.statusFilter,
}, },
}, },
successCallback: ({ data }) => { successCallback: ({ data }) => {
...@@ -83,6 +84,12 @@ export const searchByQuery = ({ commit, dispatch }, query) => { ...@@ -83,6 +84,12 @@ export const searchByQuery = ({ commit, dispatch }, query) => {
dispatch('startPolling'); dispatch('startPolling');
}; };
export const filterByStatus = ({ commit, dispatch }, status) => {
commit(types.SET_STATUS_FILTER, status);
dispatch('stopPolling');
dispatch('startPolling');
};
export const sortByField = ({ commit, dispatch }, field) => { export const sortByField = ({ commit, dispatch }, field) => {
commit(types.SET_CURSOR, null); commit(types.SET_CURSOR, null);
commit(types.SET_SORT_FIELD, field); commit(types.SET_SORT_FIELD, field);
......
...@@ -10,3 +10,4 @@ export const SET_SORT_FIELD = 'SET_SORT_FIELD'; ...@@ -10,3 +10,4 @@ export const SET_SORT_FIELD = 'SET_SORT_FIELD';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const SET_CURSOR = 'SET_CURSOR'; export const SET_CURSOR = 'SET_CURSOR';
export const REMOVE_IGNORED_RESOLVED_ERRORS = 'REMOVE_IGNORED_RESOLVED_ERRORS'; export const REMOVE_IGNORED_RESOLVED_ERRORS = 'REMOVE_IGNORED_RESOLVED_ERRORS';
export const SET_STATUS_FILTER = 'SET_STATUS_FILTER';
...@@ -62,4 +62,7 @@ export default { ...@@ -62,4 +62,7 @@ export default {
[types.REMOVE_IGNORED_RESOLVED_ERRORS](state, error) { [types.REMOVE_IGNORED_RESOLVED_ERRORS](state, error) {
state.errors = state.errors.filter(err => err.id !== error); state.errors = state.errors.filter(err => err.id !== error);
}, },
[types.SET_STATUS_FILTER](state, query) {
state.statusFilter = query;
},
}; };
...@@ -3,6 +3,7 @@ export default () => ({ ...@@ -3,6 +3,7 @@ export default () => ({
loading: true, loading: true,
endpoint: null, endpoint: null,
sortField: 'last_seen', sortField: 'last_seen',
statusFilter: 'unresolved',
searchQuery: null, searchQuery: null,
indexPath: '', indexPath: '',
recentSearches: [], recentSearches: [],
......
.error-list { .error-list {
.sort-dropdown { .dropdown {
min-width: auto; min-width: auto;
} }
} }
---
title: Filter sentry error list by status (unresolved/ignored/resolved)
merge_request: 26205
author:
type: added
...@@ -41,8 +41,8 @@ You may also want to enable Sentry's GitLab integration by following the steps i ...@@ -41,8 +41,8 @@ You may also want to enable Sentry's GitLab integration by following the steps i
NOTE: **Note:** NOTE: **Note:**
You will need at least Reporter [permissions](../../permissions.md) to view the Error Tracking list. You will need at least Reporter [permissions](../../permissions.md) to view the Error Tracking list.
The Error Tracking list may be found at **Operations > Error Tracking** in your project's sidebar. You can find the Error Tracking list at **Operations > Error Tracking** in your project's sidebar.
Errors can be filtered by title or sorted by Frequency, First Seen or Last Seen. Errors are always sorted in descending order by the field specified. Here, you can filter errors by title or by status (one of Ignored , Resolved, or Unresolved) and sort in descending order by Frequency, First Seen, or Last Seen. By default, the error list is ordered by Last Seen and filtered to Unresolved errors.
![Error Tracking list](img/error_tracking_list_v12_6.png) ![Error Tracking list](img/error_tracking_list_v12_6.png)
......
...@@ -10533,6 +10533,9 @@ msgstr "" ...@@ -10533,6 +10533,9 @@ msgstr ""
msgid "Ignore" msgid "Ignore"
msgstr "" msgstr ""
msgid "Ignored"
msgstr ""
msgid "Image %{imageName} was scheduled for deletion from the registry." msgid "Image %{imageName} was scheduled for deletion from the registry."
msgstr "" msgstr ""
...@@ -21104,6 +21107,9 @@ msgstr "" ...@@ -21104,6 +21107,9 @@ msgstr ""
msgid "Unresolve thread" msgid "Unresolve thread"
msgstr "" msgstr ""
msgid "Unresolved"
msgstr ""
msgid "UnscannedProjects|15 or more days" msgid "UnscannedProjects|15 or more days"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe 'When a user filters Sentry errors by status', :js, :use_clean_rails_memory_store_caching, :sidekiq_inline do
include_context 'sentry error tracking context feature'
let_it_be(:issues_response_body) { fixture_file('sentry/issues_sample_response.json') }
let_it_be(:filtered_errors_by_status_response) { JSON.parse(issues_response_body).filter { |error| error['status'] == 'ignored' }.to_json }
let(:issues_api_url) { "#{sentry_api_urls.issues_url}?limit=20&query=is:unresolved" }
let(:issues_api_url_filter) { "#{sentry_api_urls.issues_url}?limit=20&query=is:ignored" }
let(:auth_token) {{ 'Authorization' => 'Bearer access_token_123' }}
let(:return_header) {{ 'Content-Type' => 'application/json' }}
before do
stub_request(:get, issues_api_url).with(headers: auth_token)
.to_return(status: 200, body: issues_response_body, headers: return_header)
stub_request(:get, issues_api_url_filter).with(headers: auth_token)
.to_return(status: 200, body: filtered_errors_by_status_response, headers: return_header)
end
it 'displays the results' do
sign_in(project.owner)
visit project_error_tracking_index_path(project)
page.within(find('.gl-table')) do
results = page.all('.table-row')
expect(results.count).to be(3)
end
find('.status-dropdown .dropdown-toggle').click
find('.dropdown-item', text: 'Ignored').click
page.within(find('.gl-table')) do
results = page.all('.table-row')
expect(results.count).to be(1)
expect(results.first).to have_content(filtered_errors_by_status_response[0]['title'])
end
end
end
...@@ -26,7 +26,7 @@ describe 'When a user searches for Sentry errors', :js, :use_clean_rails_memory_ ...@@ -26,7 +26,7 @@ describe 'When a user searches for Sentry errors', :js, :use_clean_rails_memory_
page.within(find('.gl-table')) do page.within(find('.gl-table')) do
results = page.all('.table-row') results = page.all('.table-row')
expect(results.count).to be(2) expect(results.count).to be(3)
end end
find('.gl-form-input').set('NotFound').native.send_keys(:return) find('.gl-form-input').set('NotFound').native.send_keys(:return)
......
...@@ -82,5 +82,47 @@ ...@@ -82,5 +82,47 @@
"name": "Internal" "name": "Internal"
}, },
"statusDetails": {} "statusDetails": {}
},
{
"lastSeen": "2018-12-31T12:00:11Z",
"numComments": 0,
"userCount": 0,
"stats": {
"24h": [
[
1546437600,
0
]
]
},
"culprit": "sentry.tasks.reports.deliver_organization_user_report",
"title": "Service unknown",
"id": "12",
"assignedTo": null,
"logger": null,
"type": "error",
"annotations": [],
"metadata": {
"type": "gaierror",
"value": "Service unknown"
},
"status": "ignored",
"subscriptionDetails": null,
"isPublic": false,
"hasSeen": false,
"shortId": "INTERNAL-4",
"shareId": null,
"firstSeen": "2018-12-17T12:00:14Z",
"count": "70",
"permalink": "35.228.54.90/sentry/internal/issues/12/",
"level": "error",
"isSubscribed": true,
"isBookmarked": false,
"project": {
"slug": "internal",
"id": "1",
"name": "Internal"
},
"statusDetails": {}
} }
] ]
import { createLocalVue, mount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination, GlDropdown } from '@gitlab/ui';
import stubChildren from 'helpers/stub_children'; import stubChildren from 'helpers/stub_children';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
import errorsList from './list_mock.json'; import errorsList from './list_mock.json';
...@@ -15,9 +15,19 @@ describe('ErrorTrackingList', () => { ...@@ -15,9 +15,19 @@ describe('ErrorTrackingList', () => {
const findErrorListTable = () => wrapper.find('table'); const findErrorListTable = () => wrapper.find('table');
const findErrorListRows = () => wrapper.findAll('tbody tr'); const findErrorListRows = () => wrapper.findAll('tbody tr');
const findSortDropdown = () => wrapper.find('.sort-dropdown'); const dropdownsArray = () => wrapper.findAll(GlDropdown);
const findRecentSearchesDropdown = () => const findRecentSearchesDropdown = () =>
wrapper.find('.filtered-search-history-dropdown-wrapper'); dropdownsArray()
.at(0)
.find(GlDropdown);
const findStatusFilterDropdown = () =>
dropdownsArray()
.at(1)
.find(GlDropdown);
const findSortDropdown = () =>
dropdownsArray()
.at(2)
.find(GlDropdown);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findPagination = () => wrapper.find(GlPagination); const findPagination = () => wrapper.find(GlPagination);
...@@ -60,6 +70,7 @@ describe('ErrorTrackingList', () => { ...@@ -60,6 +70,7 @@ describe('ErrorTrackingList', () => {
fetchPaginatedResults: jest.fn(), fetchPaginatedResults: jest.fn(),
updateStatus: jest.fn(), updateStatus: jest.fn(),
removeIgnoredResolvedErrors: jest.fn(), removeIgnoredResolvedErrors: jest.fn(),
filterByStatus: jest.fn(),
}; };
const state = { const state = {
...@@ -167,10 +178,16 @@ describe('ErrorTrackingList', () => { ...@@ -167,10 +178,16 @@ describe('ErrorTrackingList', () => {
}); });
it('it sorts by fields', () => { it('it sorts by fields', () => {
const findSortItem = () => wrapper.find('.dropdown-item'); const findSortItem = () => findSortDropdown().find('.dropdown-item');
findSortItem().trigger('click'); findSortItem().trigger('click');
expect(actions.sortByField).toHaveBeenCalled(); expect(actions.sortByField).toHaveBeenCalled();
}); });
it('it filters by status', () => {
const findStatusFilter = () => findStatusFilterDropdown().find('.dropdown-item');
findStatusFilter().trigger('click');
expect(actions.filterByStatus).toHaveBeenCalled();
});
}); });
}); });
...@@ -215,7 +232,7 @@ describe('ErrorTrackingList', () => { ...@@ -215,7 +232,7 @@ describe('ErrorTrackingList', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(true); expect(wrapper.find(GlEmptyState).exists()).toBe(true);
expect(findLoadingIcon().exists()).toBe(false); expect(findLoadingIcon().exists()).toBe(false);
expect(findErrorListTable().exists()).toBe(false); expect(findErrorListTable().exists()).toBe(false);
expect(findSortDropdown().exists()).toBe(false); expect(dropdownsArray().length).toBe(0);
}); });
}); });
......
...@@ -88,6 +88,20 @@ describe('error tracking actions', () => { ...@@ -88,6 +88,20 @@ describe('error tracking actions', () => {
}); });
}); });
describe('filterByStatus', () => {
it('should search errors by status', () => {
const status = 'ignored';
testAction(
actions.filterByStatus,
status,
{},
[{ type: types.SET_STATUS_FILTER, payload: status }],
[{ type: 'stopPolling' }, { type: 'startPolling' }],
);
});
});
describe('sortByField', () => { describe('sortByField', () => {
it('should search by query', () => { it('should search by query', () => {
const field = 'frequency'; const field = 'frequency';
......
...@@ -6,6 +6,7 @@ const ADD_RECENT_SEARCH = mutations[types.ADD_RECENT_SEARCH]; ...@@ -6,6 +6,7 @@ const ADD_RECENT_SEARCH = mutations[types.ADD_RECENT_SEARCH];
const CLEAR_RECENT_SEARCHES = mutations[types.CLEAR_RECENT_SEARCHES]; const CLEAR_RECENT_SEARCHES = mutations[types.CLEAR_RECENT_SEARCHES];
const LOAD_RECENT_SEARCHES = mutations[types.LOAD_RECENT_SEARCHES]; const LOAD_RECENT_SEARCHES = mutations[types.LOAD_RECENT_SEARCHES];
const REMOVE_IGNORED_RESOLVED_ERRORS = mutations[types.REMOVE_IGNORED_RESOLVED_ERRORS]; const REMOVE_IGNORED_RESOLVED_ERRORS = mutations[types.REMOVE_IGNORED_RESOLVED_ERRORS];
const SET_STATUS_FILTER = mutations[types.SET_STATUS_FILTER];
describe('Error tracking mutations', () => { describe('Error tracking mutations', () => {
describe('SET_ERRORS', () => { describe('SET_ERRORS', () => {
...@@ -139,5 +140,15 @@ describe('Error tracking mutations', () => { ...@@ -139,5 +140,15 @@ describe('Error tracking mutations', () => {
expect(state.errors).not.toContain(ignoredError); expect(state.errors).not.toContain(ignoredError);
}); });
}); });
describe('SET_STATUS_FILTER', () => {
it('sets the filter to ignored, resolved or unresolved', () => {
state.statusFilter = 'unresolved';
SET_STATUS_FILTER(state, 'ignored');
expect(state.statusFilter).toBe('ignored');
});
});
}); });
}); });
...@@ -49,7 +49,7 @@ describe Sentry::Client::Issue do ...@@ -49,7 +49,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api' it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 2 it_behaves_like 'issues have correct length', 3
shared_examples 'has correct external_url' do shared_examples 'has correct external_url' do
context 'external_url' do context 'external_url' do
...@@ -184,7 +184,7 @@ describe Sentry::Client::Issue do ...@@ -184,7 +184,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api' it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 2 it_behaves_like 'issues have correct length', 3
end end
context 'when cursor is present' do context 'when cursor is present' do
...@@ -194,7 +194,7 @@ describe Sentry::Client::Issue do ...@@ -194,7 +194,7 @@ describe Sentry::Client::Issue do
it_behaves_like 'calls sentry api' it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 2 it_behaves_like 'issues have correct length', 3
end end
end end
......
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