Commit 3c6e0560 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '39979-grapql-for-error-details' into 'master'

Use GraphQL to load error tracking detail page content

See merge request gitlab-org/gitlab!22422
parents afa09780 fe22a870
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import createFlash from '~/flash';
import { GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui'; import { GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui';
import { __, sprintf, n__ } from '~/locale'; import { __, sprintf, n__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue';
...@@ -11,6 +12,8 @@ import TrackEventDirective from '~/vue_shared/directives/track_event'; ...@@ -11,6 +12,8 @@ import TrackEventDirective from '~/vue_shared/directives/track_event';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import { trackClickErrorLinkToSentryOptions } from '../utils'; import { trackClickErrorLinkToSentryOptions } from '../utils';
import query from '../queries/details.query.graphql';
export default { export default {
components: { components: {
LoadingButton, LoadingButton,
...@@ -27,6 +30,14 @@ export default { ...@@ -27,6 +30,14 @@ export default {
}, },
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
issueId: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
issueDetailsPath: { issueDetailsPath: {
type: String, type: String,
required: true, required: true,
...@@ -44,8 +55,28 @@ export default { ...@@ -44,8 +55,28 @@ export default {
required: true, required: true,
}, },
}, },
apollo: {
GQLerror: {
query,
variables() {
return {
fullPath: this.projectPath,
errorId: `gid://gitlab/Gitlab::ErrorTracking::DetailedError/${this.issueId}`,
};
},
pollInterval: 2000,
update: data => data.project.sentryDetailedError,
error: () => createFlash(__('Failed to load error details from Sentry.')),
result(res) {
if (res.data.project?.sentryDetailedError) {
this.$apollo.queries.GQLerror.stopPolling();
}
},
},
},
data() { data() {
return { return {
GQLerror: null,
issueCreationInProgress: false, issueCreationInProgress: false,
}; };
}, },
...@@ -56,26 +87,28 @@ export default { ...@@ -56,26 +87,28 @@ export default {
return sprintf( return sprintf(
__('Reported %{timeAgo} by %{reportedBy}'), __('Reported %{timeAgo} by %{reportedBy}'),
{ {
reportedBy: `<strong>${this.error.culprit}</strong>`, reportedBy: `<strong>${this.GQLerror.culprit}</strong>`,
timeAgo: this.timeFormatted(this.stacktraceData.date_received), timeAgo: this.timeFormatted(this.stacktraceData.date_received),
}, },
false, false,
); );
}, },
firstReleaseLink() { firstReleaseLink() {
return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`; return `${this.error.external_base_url}/releases/${this.GQLerror.firstReleaseShortVersion}`;
}, },
lastReleaseLink() { lastReleaseLink() {
return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`; return `${this.error.external_base_url}releases/${this.GQLerror.lastReleaseShortVersion}`;
}, },
showDetails() { showDetails() {
return Boolean(!this.loading && this.error && this.error.id); return Boolean(
!this.loading && !this.$apollo.queries.GQLerror.loading && this.error && this.GQLerror,
);
}, },
showStacktrace() { showStacktrace() {
return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length);
}, },
issueTitle() { issueTitle() {
return this.error.title; return this.GQLerror.title;
}, },
issueDescription() { issueDescription() {
return sprintf( return sprintf(
...@@ -84,13 +117,13 @@ export default { ...@@ -84,13 +117,13 @@ export default {
), ),
{ {
description: '# Error Details:\n', description: '# Error Details:\n',
errorUrl: `${this.error.external_url}\n`, errorUrl: `${this.GQLerror.externalUrl}\n`,
firstSeen: `\n${this.error.first_seen}\n`, firstSeen: `\n${this.GQLerror.firstSeen}\n`,
lastSeen: `${this.error.last_seen}\n`, lastSeen: `${this.GQLerror.lastSeen}\n`,
countLabel: n__('- Event', '- Events', this.error.count), countLabel: n__('- Event', '- Events', this.GQLerror.count),
count: `${this.error.count}\n`, count: `${this.GQLerror.count}\n`,
userCountLabel: n__('- User', '- Users', this.error.user_count), userCountLabel: n__('- User', '- Users', this.GQLerror.userCount),
userCount: `${this.error.user_count}\n`, userCount: `${this.GQLerror.userCount}\n`,
}, },
false, false,
); );
...@@ -119,7 +152,7 @@ export default { ...@@ -119,7 +152,7 @@ export default {
<template> <template>
<div> <div>
<div v-if="loading" class="py-3"> <div v-if="$apollo.queries.GQLerror.loading || loading" class="py-3">
<gl-loading-icon :size="3" /> <gl-loading-icon :size="3" />
</div> </div>
<div v-else-if="showDetails" class="error-details"> <div v-else-if="showDetails" class="error-details">
...@@ -129,7 +162,7 @@ export default { ...@@ -129,7 +162,7 @@ export default {
<gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
<input name="issue[description]" :value="issueDescription" type="hidden" /> <input name="issue[description]" :value="issueDescription" type="hidden" />
<gl-form-input <gl-form-input
:value="error.id" :value="GQLerror.id"
class="hidden" class="hidden"
name="issue[sentry_issue_attributes][sentry_issue_identifier]" name="issue[sentry_issue_attributes][sentry_issue_identifier]"
/> />
...@@ -145,16 +178,16 @@ export default { ...@@ -145,16 +178,16 @@ export default {
</form> </form>
</div> </div>
<div> <div>
<tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> <tooltip-on-truncate :title="GQLerror.title" truncate-target="child" placement="top">
<h2 class="text-truncate">{{ error.title }}</h2> <h2 class="text-truncate">{{ GQLerror.title }}</h2>
</tooltip-on-truncate> </tooltip-on-truncate>
<template v-if="error.tags"> <template v-if="error.tags">
<gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2">{{ <gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2"
errorLevel >{{ errorLevel }}
}}</gl-badge> </gl-badge>
<gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill">{{ <gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill"
error.tags.logger >{{ error.tags.logger }}
}}</gl-badge> </gl-badge>
</template> </template>
<h3>{{ __('Error details') }}</h3> <h3>{{ __('Error details') }}</h3>
...@@ -168,35 +201,35 @@ export default { ...@@ -168,35 +201,35 @@ export default {
<li> <li>
<span class="bold">{{ __('Sentry event') }}:</span> <span class="bold">{{ __('Sentry event') }}:</span>
<gl-link <gl-link
v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)" v-track-event="trackClickErrorLinkToSentryOptions(GQLerror.externalUrl)"
:href="error.external_url" :href="GQLerror.externalUrl"
target="_blank" target="_blank"
> >
<span class="text-truncate">{{ error.external_url }}</span> <span class="text-truncate">{{ GQLerror.externalUrl }}</span>
<icon name="external-link" class="ml-1 flex-shrink-0" /> <icon name="external-link" class="ml-1 flex-shrink-0" />
</gl-link> </gl-link>
</li> </li>
<li v-if="error.first_release_short_version"> <li v-if="GQLerror.firstReleaseShortVersion">
<span class="bold">{{ __('First seen') }}:</span> <span class="bold">{{ __('First seen') }}:</span>
{{ formatDate(error.first_seen) }} {{ formatDate(GQLerror.firstSeen) }}
<gl-link :href="firstReleaseLink" target="_blank"> <gl-link :href="firstReleaseLink" target="_blank">
<span>{{ __('Release') }}: {{ error.first_release_short_version }}</span> <span>{{ __('Release') }}: {{ GQLerror.firstReleaseShortVersion }}</span>
</gl-link> </gl-link>
</li> </li>
<li v-if="error.last_release_short_version"> <li v-if="GQLerror.lastReleaseShortVersion">
<span class="bold">{{ __('Last seen') }}:</span> <span class="bold">{{ __('Last seen') }}:</span>
{{ formatDate(error.last_seen) }} {{ formatDate(GQLerror.lastSeen) }}
<gl-link :href="lastReleaseLink" target="_blank"> <gl-link :href="lastReleaseLink" target="_blank">
<span>{{ __('Release') }}: {{ error.last_release_short_version }}</span> <span>{{ __('Release') }}: {{ GQLerror.lastReleaseShortVersion }}</span>
</gl-link> </gl-link>
</li> </li>
<li> <li>
<span class="bold">{{ __('Events') }}:</span> <span class="bold">{{ __('Events') }}:</span>
<span>{{ error.count }}</span> <span>{{ GQLerror.count }}</span>
</li> </li>
<li> <li>
<span class="bold">{{ __('Users') }}:</span> <span class="bold">{{ __('Users') }}:</span>
<span>{{ error.user_count }}</span> <span>{{ GQLerror.userCount }}</span>
</li> </li>
</ul> </ul>
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import store from './store'; import store from './store';
import ErrorDetails from './components/error_details.vue'; import ErrorDetails from './components/error_details.vue';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
Vue.use(VueApollo);
export default () => { export default () => {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: '#js-error_details', el: '#js-error_details',
apolloProvider,
components: { components: {
ErrorDetails, ErrorDetails,
}, },
store, store,
render(createElement) { render(createElement) {
const domEl = document.querySelector(this.$options.el); const domEl = document.querySelector(this.$options.el);
const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset; const {
issueId,
projectPath,
issueDetailsPath,
issueStackTracePath,
projectIssuesPath,
} = domEl.dataset;
return createElement('error-details', { return createElement('error-details', {
props: { props: {
issueId,
projectPath,
issueDetailsPath, issueDetailsPath,
issueStackTracePath, issueStackTracePath,
projectIssuesPath, projectIssuesPath,
......
query errorDetails($fullPath: ID!, $errorId: ID!) {
project(fullPath: $fullPath) {
sentryDetailedError(id: $errorId) {
id
sentryId
title
userCount
count
firstSeen
lastSeen
message
culprit
externalUrl
firstReleaseShortVersion
lastReleaseShortVersion
}
}
}
...@@ -18,9 +18,11 @@ module Projects::ErrorTrackingHelper ...@@ -18,9 +18,11 @@ module Projects::ErrorTrackingHelper
opts = [project, issue_id, { format: :json }] opts = [project, issue_id, { format: :json }]
{ {
'project-issues-path' => project_issues_path(project), 'issue-id' => issue_id,
'project-path' => project.full_path,
'issue-details-path' => details_project_error_tracking_index_path(*opts), 'issue-details-path' => details_project_error_tracking_index_path(*opts),
'issue-update-path' => update_project_error_tracking_index_path(*opts), 'issue-update-path' => update_project_error_tracking_index_path(*opts),
'project-issues-path' => project_issues_path(project),
'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts) 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts)
} }
end end
......
---
title: Use GraphQL to load error tracking detail page content
merge_request: 22422
author:
type: performance
...@@ -13,6 +13,7 @@ describe('ErrorDetails', () => { ...@@ -13,6 +13,7 @@ describe('ErrorDetails', () => {
let wrapper; let wrapper;
let actions; let actions;
let getters; let getters;
let mocks;
const findInput = name => { const findInput = name => {
const inputs = wrapper.findAll(GlFormInput).filter(c => c.attributes('name') === name); const inputs = wrapper.findAll(GlFormInput).filter(c => c.attributes('name') === name);
...@@ -24,13 +25,27 @@ describe('ErrorDetails', () => { ...@@ -24,13 +25,27 @@ describe('ErrorDetails', () => {
stubs: { LoadingButton }, stubs: { LoadingButton },
localVue, localVue,
store, store,
mocks,
propsData: { propsData: {
issueId: '123',
projectPath: '/root/gitlab-test',
issueDetailsPath: '/123/details', issueDetailsPath: '/123/details',
issueStackTracePath: '/stacktrace', issueStackTracePath: '/stacktrace',
projectIssuesPath: '/test-project/issues/', projectIssuesPath: '/test-project/issues/',
csrfToken: 'fakeToken', csrfToken: 'fakeToken',
}, },
}); });
wrapper.setData({
GQLerror: {
id: 129381,
title: 'Issue title',
externalUrl: 'http://sentry.gitlab.net/gitlab',
firstSeen: '2017-05-26T13:32:48Z',
lastSeen: '2018-05-26T13:32:48Z',
count: 12,
userCount: 2,
},
});
} }
beforeEach(() => { beforeEach(() => {
...@@ -61,6 +76,19 @@ describe('ErrorDetails', () => { ...@@ -61,6 +76,19 @@ describe('ErrorDetails', () => {
}, },
}, },
}); });
const query = jest.fn();
mocks = {
$apollo: {
query,
queries: {
GQLerror: {
loading: true,
stopPolling: jest.fn(),
},
},
},
};
}); });
afterEach(() => { afterEach(() => {
...@@ -85,10 +113,11 @@ describe('ErrorDetails', () => { ...@@ -85,10 +113,11 @@ describe('ErrorDetails', () => {
beforeEach(() => { beforeEach(() => {
store.state.details.loading = false; store.state.details.loading = false;
store.state.details.error.id = 1; store.state.details.error.id = 1;
mocks.$apollo.queries.GQLerror.loading = false;
mountComponent();
}); });
it('should show Sentry error details without stacktrace', () => { it('should show Sentry error details without stacktrace', () => {
mountComponent();
expect(wrapper.find(GlLink).exists()).toBe(true); expect(wrapper.find(GlLink).exists()).toBe(true);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find(Stacktrace).exists()).toBe(false); expect(wrapper.find(Stacktrace).exists()).toBe(false);
...@@ -99,23 +128,29 @@ describe('ErrorDetails', () => { ...@@ -99,23 +128,29 @@ describe('ErrorDetails', () => {
it('should show language and error level badges', () => { it('should show language and error level badges', () => {
store.state.details.error.tags = { level: 'error', logger: 'ruby' }; store.state.details.error.tags = { level: 'error', logger: 'ruby' };
mountComponent(); mountComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(GlBadge).length).toBe(2); expect(wrapper.findAll(GlBadge).length).toBe(2);
}); });
});
it('should NOT show the badge if the tag is not present', () => { it('should NOT show the badge if the tag is not present', () => {
store.state.details.error.tags = { level: 'error' }; store.state.details.error.tags = { level: 'error' };
mountComponent(); mountComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(GlBadge).length).toBe(1); expect(wrapper.findAll(GlBadge).length).toBe(1);
}); });
}); });
});
describe('Stacktrace', () => { describe('Stacktrace', () => {
it('should show stacktrace', () => { it('should show stacktrace', () => {
store.state.details.loadingStacktrace = false; store.state.details.loadingStacktrace = false;
mountComponent(); mountComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(Stacktrace).exists()).toBe(true); expect(wrapper.find(Stacktrace).exists()).toBe(true);
}); });
});
it('should NOT show stacktrace if no entries', () => { it('should NOT show stacktrace if no entries', () => {
store.state.details.loadingStacktrace = false; store.state.details.loadingStacktrace = false;
...@@ -128,15 +163,6 @@ describe('ErrorDetails', () => { ...@@ -128,15 +163,6 @@ describe('ErrorDetails', () => {
describe('When a user clicks the create issue button', () => { describe('When a user clicks the create issue button', () => {
beforeEach(() => { beforeEach(() => {
store.state.details.error = {
id: 129381,
title: 'Issue title',
external_url: 'http://sentry.gitlab.net/gitlab',
first_seen: '2017-05-26T13:32:48Z',
last_seen: '2018-05-26T13:32:48Z',
count: 12,
user_count: 2,
};
mountComponent(); mountComponent();
}); });
......
...@@ -80,11 +80,20 @@ describe Projects::ErrorTrackingHelper do ...@@ -80,11 +80,20 @@ describe Projects::ErrorTrackingHelper do
let(:issue_id) { 1234 } let(:issue_id) { 1234 }
let(:route_params) { [project.owner, project, issue_id, { format: :json }] } let(:route_params) { [project.owner, project, issue_id, { format: :json }] }
let(:details_path) { details_namespace_project_error_tracking_index_path(*route_params) } let(:details_path) { details_namespace_project_error_tracking_index_path(*route_params) }
let(:project_path) { project.full_path }
let(:stack_trace_path) { stack_trace_namespace_project_error_tracking_index_path(*route_params) } let(:stack_trace_path) { stack_trace_namespace_project_error_tracking_index_path(*route_params) }
let(:issues_path) { project_issues_path(project) } let(:issues_path) { project_issues_path(project) }
let(:result) { helper.error_details_data(project, issue_id) } let(:result) { helper.error_details_data(project, issue_id) }
it 'returns the correct issue id' do
expect(result['issue-id']).to eq issue_id
end
it 'returns the correct project path' do
expect(result['project-path']).to eq project_path
end
it 'returns the correct details path' do it 'returns the correct details path' do
expect(result['issue-details-path']).to eq details_path expect(result['issue-details-path']).to eq details_path
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