diff --git a/bin/load-mocks b/bin/load-mocks index ffd7b5e89d89e0..0da53fa0310729 100755 --- a/bin/load-mocks +++ b/bin/load-mocks @@ -70,25 +70,50 @@ def create_system_time_series(): now = now - timedelta(hours=1) -def create_sample_time_series(event): +def create_sample_time_series(event, release=None): group = event.group + project = group.project now = datetime.utcnow().replace(tzinfo=utc) for _ in xrange(60): count = randint(1, 10) tsdb.incr_multi(( - (tsdb.models.project, group.project.id), + (tsdb.models.project, project.id), (tsdb.models.group, group.id), ), now, count) tsdb.incr_multi(( - (tsdb.models.organization_total_received, group.project.organization_id), - (tsdb.models.project_total_received, group.project.id), + (tsdb.models.organization_total_received, project.organization_id), + (tsdb.models.project_total_received, project.id), ), now, int(count * 1.1)) tsdb.incr_multi(( - (tsdb.models.organization_total_rejected, group.project.organization_id), - (tsdb.models.project_total_rejected, group.project.id), + (tsdb.models.organization_total_rejected, project.organization_id), + (tsdb.models.project_total_rejected, project.id), ), now, int(count * 0.1)) + + frequencies = [ + (tsdb.models.frequent_projects_by_organization, { + project.organization_id: { + project.id: count, + }, + }), + (tsdb.models.frequent_issues_by_project, { + project.id: { + group.id: count, + }, + }), + ] + if release: + frequencies.append( + (tsdb.models.frequent_releases_by_groups, { + group.id: { + release.id: count, + }, + }) + ) + + tsdb.record_frequency_multi(frequencies, now) + now = now - timedelta(seconds=1) for _ in xrange(24 * 30): @@ -105,6 +130,30 @@ def create_sample_time_series(event): (tsdb.models.organization_total_rejected, group.project.organization_id), (tsdb.models.project_total_rejected, group.project.id), ), now, int(count * 0.1)) + + frequencies = [ + (tsdb.models.frequent_projects_by_organization, { + project.organization_id: { + project.id: count, + }, + }), + (tsdb.models.frequent_issues_by_project, { + project.id: { + group.id: count, + }, + }), + ] + if release: + frequencies.append( + (tsdb.models.frequent_releases_by_groups, { + group.id: { + release.id: count, + }, + }) + ) + + tsdb.record_frequency_multi(frequencies, now) + now = now - timedelta(hours=1) @@ -371,11 +420,11 @@ def main(num_events=1): print(' > Loading time series data'.format(project_name)) - create_sample_time_series(event1) - create_sample_time_series(event2) + create_sample_time_series(event1, release=release) + create_sample_time_series(event2, release=release) create_sample_time_series(event3) - create_sample_time_series(event4) - create_sample_time_series(event5) + create_sample_time_series(event4, release=release) + create_sample_time_series(event5, release=release) if hasattr(buffer, 'process_pending'): print(' > Processing pending buffers') diff --git a/src/sentry/api/endpoints/group_release_details.py b/src/sentry/api/endpoints/group_release_details.py new file mode 100644 index 00000000000000..dcb36e30de2855 --- /dev/null +++ b/src/sentry/api/endpoints/group_release_details.py @@ -0,0 +1,48 @@ +from __future__ import absolute_import + +from rest_framework.response import Response + +from sentry.app import tsdb +from sentry.api.base import StatsMixin +from sentry.api.bases.group import GroupEndpoint +from sentry.api.exceptions import ResourceDoesNotExist +from sentry.api.serializers import serialize +from sentry.models import Release + + +class GroupReleaseDetailsEndpoint(GroupEndpoint, StatsMixin): + def get(self, request, group, version): + try: + if version == ':latest': + release = Release.objects.filter( + project=group.project_id, + ).order_by('-date_added')[0:1].get() + else: + release = Release.objects.get( + project=group.project_id, + version=version, + ) + except Release.DoesNotExist: + raise ResourceDoesNotExist + + try: + data = tsdb.get_frequency_series( + model=tsdb.models.frequent_releases_by_groups, + items={ + group.id: [release.id], + }, + **self._parse_args(request) + )[group.id] + except NotImplementedError: + # TODO(dcramer): probably should log this, but not worth + # erring out + data = [] + else: + data = [ + (k, v[release.id]) + for k, v in data + ] + + context = serialize(release, request.user) + context['stats'] = data + return Response(context) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index d8b4934272b860..9cefd450d6fcd7 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -15,6 +15,7 @@ from .endpoints.group_notes import GroupNotesEndpoint from .endpoints.group_notes_details import GroupNotesDetailsEndpoint from .endpoints.group_participants import GroupParticipantsEndpoint +from .endpoints.group_release_details import GroupReleaseDetailsEndpoint from .endpoints.group_stats import GroupStatsEndpoint from .endpoints.group_tags import GroupTagsEndpoint from .endpoints.group_tagkey_details import GroupTagKeyDetailsEndpoint @@ -307,6 +308,9 @@ url(r'^(?:issues|groups)/(?P\d+)/stats/$', GroupStatsEndpoint.as_view(), name='sentry-api-0-group-stats'), + url(r'^(?:issues|groups)/(?P\d+)/releases/(?P[^/]+)/$', + GroupReleaseDetailsEndpoint.as_view(), + name='sentry-api-0-group-release-details'), url(r'^(?:issues|groups)/(?P\d+)/tags/$', GroupTagsEndpoint.as_view(), name='sentry-api-0-group-tags'), diff --git a/src/sentry/static/sentry/app/components/group/chart.jsx b/src/sentry/static/sentry/app/components/group/chart.jsx index bd9eaa30024a64..9c202681085864 100644 --- a/src/sentry/static/sentry/app/components/group/chart.jsx +++ b/src/sentry/static/sentry/app/components/group/chart.jsx @@ -4,7 +4,6 @@ import PropTypes from '../../proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import {t} from '../../locale'; - const GroupChart = React.createClass({ propTypes: { group: PropTypes.Group.isRequired, @@ -57,4 +56,3 @@ const GroupChart = React.createClass({ }); export default GroupChart; - diff --git a/src/sentry/static/sentry/app/components/group/releaseChart.jsx b/src/sentry/static/sentry/app/components/group/releaseChart.jsx new file mode 100644 index 00000000000000..8ce936b09c0a50 --- /dev/null +++ b/src/sentry/static/sentry/app/components/group/releaseChart.jsx @@ -0,0 +1,164 @@ +import React from 'react'; + +import ApiMixin from '../../mixins/apiMixin'; +import LoadingError from '../loadingError'; +import StackedBarChart from '../stackedBarChart'; +import PropTypes from '../../proptypes'; +import {t} from '../../locale'; +import {intcomma} from '../../utils'; + +const GroupReleaseChart = React.createClass({ + propTypes: { + group: PropTypes.Group.isRequired, + release: React.PropTypes.string, + statsPeriod: React.PropTypes.string.isRequired, + firstSeen: React.PropTypes.string.isRequired, + lastSeen: React.PropTypes.string.isRequired, + title: React.PropTypes.string + }, + + mixins: [ApiMixin], + + getInitialState() { + let stats = this.props.group.stats[this.props.statsPeriod]; + return { + loading: stats && stats.length, + error: false, + release: null, + }; + }, + + componentWillMount() { + if (this.state.loading) { + this.fetchData(); + } + }, + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.state.loading !== nextState.loading || + this.state.error !== nextState.error + ); + }, + + fetchData() { + let group = this.props.group; + let stats = this.props.group.stats[this.props.statsPeriod]; + + let since = stats[0][0]; + let until = stats[stats.length - 1][0]; + let resolution = this.props.statsPeriod === '24h' ? '1h' : '1d'; + + this.api.request(`/issues/${group.id}/releases/:latest/`, { + query: { + resolution: resolution, + since: since, + until: until, + }, + success: (data) => { + this.setState({ + release: data, + loading: false, + error: false, + }); + }, + error: () => { + this.setState({ + release: null, + loading: false, + error: true, + }); + } + }); + }, + + renderTooltip(point, pointIdx, chart) { + let timeLabel = chart.getTimeLabel(point); + let totalY = 0; + for (let i = 0; i < point.y.length; i++) { + totalY += point.y[i]; + } + let title = ( + '
' + + intcomma(totalY) + ' events
' + + '' + intcomma(point.y[0]) + ' in latest release
' + + timeLabel + + '
' + ); + return title; + }, + + render() { + let className = 'bar-chart group-chart ' + (this.props.className || ''); + + if (this.state.error) { + return ( +
+
{this.props.title}
+ +
+ ); + } + + if (this.state.loading) { + return null; + } + + let group = this.props.group; + let stats = group.stats[this.props.statsPeriod]; + if (!stats || !stats.length) return null; + + let release = this.state.release; + let releasePoints = {}; + if (release) { + release.stats.forEach((point) => { + releasePoints[point[0]] = point[1]; + }); + } + + let points = stats.map((point) => { + let rData = releasePoints[point[0]] || 0; + let remaining = point[1] - rData; + return { + x: point[0], + y: [ + rData, + remaining >= 0 ? remaining : 0, + ], + }; + }); + + let markers = []; + let firstSeenX = new Date(this.props.firstSeen).getTime() / 1000; + let lastSeenX = new Date(this.props.lastSeen).getTime() / 1000; + if (firstSeenX >= points[0].x) { + markers.push({ + label: t('First seen'), + x: firstSeenX, + className: 'first-seen' + }); + } + if (lastSeenX >= points[0].x) { + markers.push({ + label: t('Last seen'), + x: lastSeenX, + className: 'last-seen' + }); + } + + return ( +
+
{this.props.title}
+ +
+ ); + } +}); + +export default GroupReleaseChart; diff --git a/src/sentry/static/sentry/app/components/group/sidebar.jsx b/src/sentry/static/sentry/app/components/group/sidebar.jsx index 27c9c86cb68654..7f46961181bb43 100644 --- a/src/sentry/static/sentry/app/components/group/sidebar.jsx +++ b/src/sentry/static/sentry/app/components/group/sidebar.jsx @@ -2,7 +2,7 @@ import React from 'react'; import ApiMixin from '../../mixins/apiMixin'; import Avatar from '../avatar'; -import GroupChart from './chart'; +import GroupReleaseChart from './releaseChart'; import GroupState from '../../mixins/groupState'; import IndicatorStore from '../../stores/indicatorStore'; import SeenInfo from './seenInfo'; @@ -83,15 +83,19 @@ const GroupSidebar = React.createClass({ return (
- - + +
{t('First seen')}
' + - totalY + ' ' + this.props.label + '
' + + intcomma(totalY) + ' ' + this.props.label + '
' + timeLabel + '
' ); diff --git a/src/sentry/static/sentry/app/utils.jsx b/src/sentry/static/sentry/app/utils.jsx index 64ac11ce4ddffd..f7c7f83bc15fa2 100644 --- a/src/sentry/static/sentry/app/utils.jsx +++ b/src/sentry/static/sentry/app/utils.jsx @@ -105,6 +105,10 @@ const compareArrays = function(arr1, arr2, compFunc) { return true; }; +const intcomma = function(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +}; + export default { getQueryParams() { let hashes, hash; @@ -196,6 +200,7 @@ export default { arrayIsEqual: arrayIsEqual, objectMatchesSubset: objectMatchesSubset, compareArrays: compareArrays, + intcomma: intcomma, modelsEqual: modelsEqual, valueIsEqual: valueIsEqual, parseLinkHeader: require('./utils/parseLinkHeader'), diff --git a/src/sentry/static/sentry/less/group-detail.less b/src/sentry/static/sentry/less/group-detail.less index 9f244b21732242..81a99ffb532c60 100644 --- a/src/sentry/static/sentry/less/group-detail.less +++ b/src/sentry/static/sentry/less/group-detail.less @@ -304,19 +304,6 @@ // Group Overview -.group-overview { - .bar-chart { - figure a { - height: 70px; - } - } - .bar-chart-small { - figure a { - height: 40px; - } - } -} - .group-stats-column { float: right; } @@ -377,6 +364,11 @@ span { background: @purple-light; #gradient > .vertical(@purple-light, @purple); + + &.inactive { + background: @gray-lightest; + #gradient > .vertical(@gray-lightest, @gray-lightest); + } } &:hover span { diff --git a/src/sentry/static/sentry/less/shared-components.less b/src/sentry/static/sentry/less/shared-components.less index b831a7ee20ac3d..7802b433105a99 100644 --- a/src/sentry/static/sentry/less/shared-components.less +++ b/src/sentry/static/sentry/less/shared-components.less @@ -3049,7 +3049,6 @@ ul.radio-inputs { color: @tooltip-color; } - /** * Tag List * ============================================================================ diff --git a/tests/sentry/api/endpoints/test_group_release_details.py b/tests/sentry/api/endpoints/test_group_release_details.py new file mode 100644 index 00000000000000..ef8fbaed4410a6 --- /dev/null +++ b/tests/sentry/api/endpoints/test_group_release_details.py @@ -0,0 +1,55 @@ +from __future__ import absolute_import + +from sentry.app import tsdb +from sentry.models import Release +from sentry.testutils import APITestCase + + +class GroupReleaseDetailsTest(APITestCase): + def test_simple(self): + self.login_as(user=self.user) + + group1 = self.create_group() + + release1 = Release.objects.create(project=group1.project, version='abc') + release2 = Release.objects.create(project=group1.project, version='def') + + url = '/api/0/issues/{}/releases/abc/'.format(group1.id) + response = self.client.get(url, format='json') + + assert response.status_code == 200, response.content + assert response.data['version'] == release1.version + for point in response.data['stats']: + assert point[1] == 0 + assert len(response.data['stats']) == 24 + + tsdb.record_frequency_multi([ + (tsdb.models.frequent_releases_by_groups, { + group1.id: { + release1.id: 3, + release2.id: 5, + }, + }) + ]) + + response = self.client.get(url, format='json') + + assert response.status_code == 200, response.content + assert response.data['version'] == release1.version + assert response.data['stats'][-1][1] == 3, response.data + for point in response.data['stats'][:-1]: + assert point[1] == 0 + assert len(response.data['stats']) == 24 + + def test_latest(self): + self.login_as(user=self.user) + + group1 = self.create_group() + + release1 = Release.objects.create(project=group1.project, version='abc') + + url = '/api/0/issues/{}/releases/:latest/'.format(group1.id) + response = self.client.get(url, format='json') + + assert response.status_code == 200, response.content + assert response.data['version'] == release1.version