Browse Source

feat(monitors): Initial UI

- Add basic mocks for Monitor entries
- Fix project-based access on monitor list
- Add search to monitors list endpoint
- Add monitor details endpoint
- Add (unlinked, draft) monitor edit endpoint
- Fix monitor.id (should be guid)
- Fix monitor checkin status enums
- Fix monitor checkins list (invalid order_by)
David Cramer 6 years ago
parent
commit
39ffb503b3

+ 38 - 1
bin/load-mocks

@@ -26,7 +26,7 @@ from sentry.models import (
     Environment, File, Group, GroupMeta, GroupRelease, GroupTombstone, Organization,
     OrganizationAccessRequest, OrganizationMember, Project, Release,
     ReleaseCommit, ReleaseEnvironment, ReleaseProjectEnvironment, ReleaseFile, Repository,
-    Team, TOMBSTONE_FIELDS_FROM_GROUP, User, UserReport
+    Team, TOMBSTONE_FIELDS_FROM_GROUP, User, UserReport, Monitor, MonitorStatus, MonitorType, MonitorCheckIn, CheckInStatus
 )
 from sentry.signals import mocks_loaded
 from sentry.similarity import features
@@ -61,6 +61,14 @@ ENVIRONMENTS = itertools.cycle([
     ''
 ])
 
+MONITOR_NAMES = itertools.cycle(settings.CELERYBEAT_SCHEDULE.keys())
+
+MONITOR_SCHEDULES = itertools.cycle([
+    '* * * * *',
+    '0 * * * *',
+    '0 0 * * *',
+])
+
 LONG_MESSAGE = """Code: 0.
 DB::Exception: String is too long for DateTime: 2018-10-26T19:14:18+00:00. Stack trace:
 
@@ -390,6 +398,35 @@ def main(num_events=1, extra_events=False):
                     flags=F('flags').bitor(Project.flags.has_releases),
                 )
 
+            monitor, created = Monitor.objects.get_or_create(
+                name=next(MONITOR_NAMES),
+                project_id=project.id,
+                organization_id=org.id,
+                type=MonitorType.CRON_JOB,
+                defaults={
+                    'config': {
+                        'schedule': next(MONITOR_SCHEDULES),
+                    },
+                    'next_checkin': timezone.now() + timedelta(minutes=60),
+                    'last_checkin': timezone.now(),
+                }
+            )
+            if not created:
+                if not (monitor.config or {}).get('schedule'):
+                    monitor.config = {'schedule': next(MONITOR_SCHEDULES)}
+                monitor.update(
+                    config=monitor.config,
+                    status=MonitorStatus.ACTIVE if randint(0, 10) < 7 else MonitorStatus.ERROR,
+                    last_checkin=timezone.now(),
+                    next_checkin=monitor.get_next_scheduled_checkin(timezone.now()),
+                )
+
+            MonitorCheckIn.objects.create(
+                project_id=monitor.project_id,
+                monitor=monitor,
+                status=CheckInStatus.OK if monitor.status == MonitorStatus.ACTIVE else CheckInStatus.ERROR,
+            )
+
             with transaction.atomic():
                 has_release = Release.objects.filter(
                     version=sha1(uuid4().bytes).hexdigest(),

+ 3 - 0
src/sentry/api/base.py

@@ -315,6 +315,9 @@ class StatsMixin(object):
         else:
             start = end - timedelta(days=1, seconds=-1)
 
+        if not resolution:
+            resolution = tsdb.get_optimal_rollup(start, end)
+
         return {
             'start': start,
             'end': end,

+ 45 - 0
src/sentry/api/bases/monitor.py

@@ -0,0 +1,45 @@
+from __future__ import absolute_import
+
+from sentry import features
+from sentry.api.base import Endpoint
+from sentry.api.exceptions import ResourceDoesNotExist
+from sentry.api.bases.project import ProjectPermission
+from sentry.models import Monitor, Project, ProjectStatus
+from sentry.utils.sdk import configure_scope
+
+
+class MonitorEndpoint(Endpoint):
+    permission_classes = (ProjectPermission,)
+
+    def convert_args(self, request, monitor_id, *args, **kwargs):
+        try:
+            monitor = Monitor.objects.get(
+                guid=monitor_id,
+            )
+        except Monitor.DoesNotExist:
+            raise ResourceDoesNotExist
+
+        project = Project.objects.get_from_cache(id=monitor.project_id)
+        if project.status != ProjectStatus.VISIBLE:
+            raise ResourceDoesNotExist
+
+        if hasattr(request.auth, 'project_id') and project.id != request.auth.project_id:
+            return self.respond(status=400)
+
+        if not features.has('organizations:monitors',
+                            project.organization, actor=request.user):
+            raise ResourceDoesNotExist
+
+        self.check_object_permissions(request, project)
+
+        with configure_scope() as scope:
+            scope.set_tag("organization", project.organization_id)
+            scope.set_tag("project", project.id)
+
+        request._request.organization = project.organization
+
+        kwargs.update({
+            'monitor': monitor,
+            'project': project,
+        })
+        return (args, kwargs)

+ 5 - 45
src/sentry/api/endpoints/monitor_checkins.py

@@ -3,15 +3,11 @@ from __future__ import absolute_import
 from django.db import transaction
 from rest_framework import serializers
 
-from sentry import features
 from sentry.api.authentication import DSNAuthentication
-from sentry.api.base import Endpoint
-from sentry.api.exceptions import ResourceDoesNotExist
+from sentry.api.bases.monitor import MonitorEndpoint
 from sentry.api.paginator import OffsetPaginator
-from sentry.api.bases.project import ProjectPermission
 from sentry.api.serializers import serialize
-from sentry.models import Monitor, MonitorCheckIn, MonitorStatus, CheckInStatus, Project, ProjectKey, ProjectStatus
-from sentry.utils.sdk import configure_scope
+from sentry.models import Monitor, MonitorCheckIn, MonitorStatus, CheckInStatus, ProjectKey
 
 
 class CheckInSerializer(serializers.Serializer):
@@ -25,44 +21,8 @@ class CheckInSerializer(serializers.Serializer):
     duration = serializers.IntegerField(required=False)
 
 
-class MonitorCheckInsEndpoint(Endpoint):
-    authentication_classes = Endpoint.authentication_classes + (DSNAuthentication,)
-    permission_classes = (ProjectPermission,)
-
-    # TODO(dcramer): this code needs shared with other endpoints as its security focused
-    # TODO(dcramer): this doesnt handle is_global roles
-    def convert_args(self, request, monitor_id, *args, **kwargs):
-        try:
-            monitor = Monitor.objects.get(
-                guid=monitor_id,
-            )
-        except Monitor.DoesNotExist:
-            raise ResourceDoesNotExist
-
-        project = Project.objects.get_from_cache(id=monitor.project_id)
-        if project.status != ProjectStatus.VISIBLE:
-            raise ResourceDoesNotExist
-
-        if hasattr(request.auth, 'project_id') and project.id != request.auth.project_id:
-            return self.respond(status=400)
-
-        if not features.has('organizations:monitors',
-                            project.organization, actor=request.user):
-            raise ResourceDoesNotExist
-
-        self.check_object_permissions(request, project)
-
-        with configure_scope() as scope:
-            scope.set_tag("organization", project.organization_id)
-            scope.set_tag("project", project.id)
-
-        request._request.organization = project.organization
-
-        kwargs.update({
-            'monitor': monitor,
-            'project': project,
-        })
-        return (args, kwargs)
+class MonitorCheckInsEndpoint(MonitorEndpoint):
+    authentication_classes = MonitorEndpoint.authentication_classes + (DSNAuthentication,)
 
     def get(self, request, project, monitor):
         """
@@ -83,7 +43,7 @@ class MonitorCheckInsEndpoint(Endpoint):
         return self.paginate(
             request=request,
             queryset=queryset,
-            order_by='name',
+            order_by='-date_added',
             on_results=lambda x: serialize(x, request.user),
             paginator_cls=OffsetPaginator,
         )

+ 16 - 0
src/sentry/api/endpoints/monitor_details.py

@@ -0,0 +1,16 @@
+from __future__ import absolute_import
+
+from sentry.api.bases.monitor import MonitorEndpoint
+from sentry.api.serializers import serialize
+
+
+class MonitorDetailsEndpoint(MonitorEndpoint):
+    def get(self, request, project, monitor):
+        """
+        Retrieve a monitor
+        ``````````````````
+
+        :pparam string monitor_id: the id of the monitor.
+        :auth: required
+        """
+        return self.respond(serialize(monitor, request.user))

+ 43 - 0
src/sentry/api/endpoints/monitor_stats.py

@@ -0,0 +1,43 @@
+from __future__ import absolute_import
+
+import six
+
+from collections import OrderedDict
+from rest_framework.response import Response
+
+from sentry import tsdb
+from sentry.api.base import StatsMixin
+from sentry.api.bases.monitor import MonitorEndpoint
+from sentry.models import MonitorCheckIn, CheckInStatus
+
+
+class MonitorStatsEndpoint(MonitorEndpoint, StatsMixin):
+    # TODO(dcramer): probably convert to tsdb
+    def get(self, request, project, monitor):
+        args = self._parse_args(request)
+
+        stats = OrderedDict()
+        current = tsdb.normalize_to_epoch(args['start'], args['rollup'])
+        end = tsdb.normalize_to_epoch(args['end'], args['rollup'])
+        while current <= end:
+            stats[current] = {CheckInStatus.OK: 0, CheckInStatus.ERROR: 0}
+            current += args['rollup']
+
+        history = MonitorCheckIn.objects.filter(
+            monitor=monitor,
+            status__in=[CheckInStatus.OK, CheckInStatus.ERROR],
+            date_added__gt=args['start'],
+            date_added__lte=args['end'],
+        ).values_list('date_added', 'status')
+        for datetime, status in history.iterator():
+            stats[tsdb.normalize_to_epoch(datetime, args['rollup'])][status] += 1
+
+        return Response(
+            [
+                {
+                    'ts': ts,
+                    'ok': data[CheckInStatus.OK],
+                    'error': data[CheckInStatus.ERROR],
+                } for ts, data in six.iteritems(stats)
+            ]
+        )

+ 66 - 2
src/sentry/api/endpoints/organization_monitors.py

@@ -1,11 +1,27 @@
 from __future__ import absolute_import
 
+import six
+
+from django.db.models import Q
+
 from sentry import features
+from sentry.api.bases import NoProjects, OrganizationEventsError
 from sentry.api.bases.organization import OrganizationEndpoint
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.paginator import OffsetPaginator
 from sentry.api.serializers import serialize
-from sentry.models import Monitor
+from sentry.models import Monitor, MonitorStatus, MonitorType
+from sentry.search.utils import tokenize_query
+from sentry.db.models.query import in_iexact
+
+
+def map_value_to_constant(constant, value):
+    value = value.upper()
+    if value == 'OK':
+        value = 'ACTIVE'
+    if not hasattr(constant, value):
+        raise ValueError(value)
+    return getattr(constant, value)
 
 
 class OrganizationMonitorsEndpoint(OrganizationEndpoint):
@@ -21,14 +37,62 @@ class OrganizationMonitorsEndpoint(OrganizationEndpoint):
                             organization, actor=request.user):
             raise ResourceDoesNotExist
 
+        try:
+            filter_params = self.get_filter_params(
+                request,
+                organization,
+                date_filter_optional=True,
+            )
+        except NoProjects:
+            return self.respond([])
+        except OrganizationEventsError as exc:
+            return self.respond({'detail': exc.message}, status=400)
+
         queryset = Monitor.objects.filter(
             organization_id=organization.id,
+            project_id__in=filter_params['project_id'],
+        )
+        query = request.GET.get('query')
+        if query:
+            tokens = tokenize_query(query)
+            for key, value in six.iteritems(tokens):
+                if key == 'query':
+                    value = ' '.join(value)
+                    queryset = queryset.filter(Q(name__icontains=value) | Q(id__iexact=value))
+                elif key == 'id':
+                    queryset = queryset.filter(in_iexact('id', value))
+                elif key == 'name':
+                    queryset = queryset.filter(in_iexact('name', value))
+                elif key == 'status':
+                    try:
+                        queryset = queryset.filter(
+                            status__in=map_value_to_constant(
+                                MonitorStatus, value))
+                    except ValueError:
+                        queryset = queryset.none()
+                elif key == 'type':
+                    try:
+                        queryset = queryset.filter(
+                            status__in=map_value_to_constant(
+                                MonitorType, value))
+                    except ValueError:
+                        queryset = queryset.none()
+
+                elif key == 'id':
+                    queryset = queryset.filter(id__in=value)
+                else:
+                    queryset = queryset.none()
+
+        queryset = queryset.extra(
+            select={
+                'is_error': 'sentry_monitor.status = %s' % (MonitorStatus.ERROR,),
+            },
         )
 
         return self.paginate(
             request=request,
             queryset=queryset,
-            order_by='name',
+            order_by=('-is_error', '-name'),
             on_results=lambda x: serialize(x, request.user),
             paginator_cls=OffsetPaginator,
         )

+ 3 - 0
src/sentry/api/serializers/models/monitor.py

@@ -12,6 +12,9 @@ class MonitorSerializer(Serializer):
         return {
             'id': six.text_type(obj.guid),
             'status': obj.get_status_display(),
+            'type': obj.get_type_display(),
             'name': obj.name,
+            'lastCheckIn': obj.last_checkin,
+            'nextCheckIn': obj.next_checkin,
             'dateCreated': obj.date_added,
         }

+ 4 - 0
src/sentry/api/urls.py

@@ -51,6 +51,8 @@ from .endpoints.internal_quotas import InternalQuotasEndpoint
 from .endpoints.internal_stats import InternalStatsEndpoint
 from .endpoints.monitor_checkins import MonitorCheckInsEndpoint
 from .endpoints.monitor_checkin_details import MonitorCheckInDetailsEndpoint
+from .endpoints.monitor_details import MonitorDetailsEndpoint
+from .endpoints.monitor_stats import MonitorStatsEndpoint
 from .endpoints.organization_access_request_details import OrganizationAccessRequestDetailsEndpoint
 from .endpoints.organization_activity import OrganizationActivityEndpoint
 from .endpoints.organization_auditlogs import OrganizationAuditLogsEndpoint
@@ -299,9 +301,11 @@ urlpatterns = patterns(
         name='sentry-api-0-accept-project-transfer'),
 
     # Monitors
+    url(r'^monitors/(?P<monitor_id>[^\/]+)/$', MonitorDetailsEndpoint.as_view()),
     url(r'^monitors/(?P<monitor_id>[^\/]+)/checkins/$', MonitorCheckInsEndpoint.as_view()),
     url(r'^monitors/(?P<monitor_id>[^\/]+)/checkins/(?P<checkin_id>[^\/]+)/$',
         MonitorCheckInDetailsEndpoint.as_view()),
+    url(r'^monitors/(?P<monitor_id>[^\/]+)/stats/$', MonitorStatsEndpoint.as_view()),
 
     # Users
     url(r'^users/$', UserIndexEndpoint.as_view(), name='sentry-api-0-user-index'),

+ 2 - 1
src/sentry/models/monitor.py

@@ -1,6 +1,7 @@
 from __future__ import absolute_import, print_function
 
 import pytz
+import six
 
 from croniter import croniter
 from datetime import datetime, timedelta
@@ -124,7 +125,7 @@ class Monitor(Model):
                 },
                 'contexts': {
                     'monitor': {
-                        'id': self.id,
+                        'id': six.text_type(self.guid),
                     },
                 },
             },

Some files were not shown because too many files changed in this diff