Browse Source

feat(settings): Add service hook settings

- Make 'events' optional for service hook endpoints
- Add service hook stats endpoint
- Add service hook list
- Add service hook creation
- Add service hook details (and edit)
David Cramer 7 years ago
parent
commit
fe1a695404

+ 6 - 1
src/sentry/api/endpoints/project_servicehook_details.py

@@ -8,6 +8,7 @@ from sentry.api.bases.project import ProjectEndpoint
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.serializers import serialize
 from sentry.api.validators import ServiceHookValidator
+from sentry.constants import ObjectStatus
 from sentry.models import AuditLogEntryEvent, ServiceHook
 
 
@@ -67,12 +68,16 @@ class ProjectServiceHookDetailsEndpoint(ProjectEndpoint):
         result = validator.object
 
         updates = {}
-        if result.get('events'):
+        if result.get('events') is not None:
             updates['events'] = result['events']
         if result.get('url'):
             updates['url'] = result['url']
         if result.get('version') is not None:
             updates['version'] = result['version']
+        if result.get('isActive') is True:
+            updates['status'] = ObjectStatus.ACTIVE
+        elif result.get('isActive') is False:
+            updates['status'] = ObjectStatus.DISABLED
 
         with transaction.atomic():
             hook.update(**updates)

+ 39 - 0
src/sentry/api/endpoints/project_servicehook_stats.py

@@ -0,0 +1,39 @@
+from __future__ import absolute_import
+
+import six
+
+from collections import OrderedDict
+
+from sentry import tsdb
+from sentry.api.base import StatsMixin
+from sentry.api.bases.project import ProjectEndpoint
+from sentry.api.exceptions import ResourceDoesNotExist
+from sentry.models import ServiceHook
+
+
+class ProjectServiceHookStatsEndpoint(ProjectEndpoint, StatsMixin):
+    def get(self, request, project, hook_id):
+        try:
+            hook = ServiceHook.objects.get(
+                project_id=project.id,
+                guid=hook_id,
+            )
+        except ServiceHook.DoesNotExist:
+            raise ResourceDoesNotExist
+
+        stat_args = self._parse_args(request)
+
+        stats = OrderedDict()
+        for model, name in (
+            (tsdb.models.servicehook_fired, 'total'),
+        ):
+            result = tsdb.get_range(model=model, keys=[hook.id], **stat_args)[hook.id]
+            for ts, count in result:
+                stats.setdefault(int(ts), {})[name] = count
+
+        return self.respond([
+            {
+                'ts': ts,
+                'total': data['total'],
+            } for ts, data in six.iteritems(stats)
+        ])

+ 1 - 0
src/sentry/api/serializers/models/servicehook.py

@@ -12,5 +12,6 @@ class ServiceHookSerializer(Serializer):
             'url': obj.url,
             'secret': obj.secret,
             'status': obj.get_status_display(),
+            'events': sorted(obj.events),
             'dateCreated': obj.date_added,
         }

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

@@ -121,6 +121,7 @@ from .endpoints.project_processingissues import ProjectProcessingIssuesEndpoint,
 from .endpoints.project_reprocessing import ProjectReprocessingEndpoint
 from .endpoints.project_servicehooks import ProjectServiceHooksEndpoint
 from .endpoints.project_servicehook_details import ProjectServiceHookDetailsEndpoint
+from .endpoints.project_servicehook_stats import ProjectServiceHookStatsEndpoint
 from .endpoints.project_user_details import ProjectUserDetailsEndpoint
 from .endpoints.project_user_reports import ProjectUserReportsEndpoint
 from .endpoints.project_user_stats import ProjectUserStatsEndpoint
@@ -639,6 +640,10 @@ urlpatterns = patterns(
         r'^projects/(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/hooks/(?P<hook_id>[^\/]+)/$',
         ProjectServiceHookDetailsEndpoint.as_view(),
     ),
+    url(
+        r'^projects/(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/hooks/(?P<hook_id>[^\/]+)/stats/$',
+        ProjectServiceHookStatsEndpoint.as_view(),
+    ),
     url(
         r'^projects/(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/(?:issues|groups)/$',
         ProjectGroupIndexEndpoint.as_view(),

+ 2 - 1
src/sentry/api/validators/servicehook.py

@@ -11,11 +11,12 @@ class ServiceHookValidator(serializers.Serializer):
     url = serializers.URLField(required=True)
     events = ListField(
         child=serializers.CharField(max_length=255),
-        required=True,
+        required=False,
     )
     version = serializers.ChoiceField(choices=(
         (0, '0'),
     ), required=False, default=0)
+    isActive = serializers.BooleanField(required=False, default=True)
 
     def validate_events(self, attrs, source):
         value = attrs[source]

+ 24 - 2
src/sentry/static/sentry/app/routes.jsx

@@ -248,7 +248,6 @@ const projectSettingsRoutes = (
         import(/*webpackChunkName: "ProjectTeams"*/ './views/settings/project/projectTeams')}
       component={errorHandler(LazyLoad)}
     />
-
     <Route name="Alerts" path="alerts/">
       <IndexRoute component={errorHandler(ProjectAlertSettings)} />
       <Route path="rules/" name="Rules" component={null}>
@@ -261,7 +260,6 @@ const projectSettingsRoutes = (
         />
       </Route>
     </Route>
-
     <Route
       name="Environments"
       path="environments/"
@@ -325,6 +323,30 @@ const projectSettingsRoutes = (
       <IndexRedirect to="data-filters/" />
       <Route path=":filterType/" />
     </Route>
+    <Route
+      key="hooks/"
+      path="hooks/"
+      name="Service Hooks"
+      componentPromise={() =>
+        import(/*webpackChunkName: "ProjectServiceHooks"*/ './views/settings/project/projectServiceHooks')}
+      component={errorHandler(LazyLoad)}
+    />
+    <Route
+      key="hooks/new/"
+      path="hooks/new/"
+      name="Create Service Hook"
+      componentPromise={() =>
+        import(/*webpackChunkName: "ProjectCreateServiceHook"*/ './views/settings/project/projectCreateServiceHook')}
+      component={errorHandler(LazyLoad)}
+    />
+    <Route
+      key="hooks/:hookId/"
+      path="hooks/:hookId/"
+      name="Service Hook Details"
+      componentPromise={() =>
+        import(/*webpackChunkName: "ProjectServiceHookDetails"*/ './views/settings/project/projectServiceHookDetails')}
+      component={errorHandler(LazyLoad)}
+    />
     <Route path="keys/" name="Client Keys">
       <IndexRoute
         componentPromise={() =>

+ 1 - 1
src/sentry/static/sentry/app/views/settings/account/apiNewToken.jsx

@@ -13,7 +13,7 @@ import TextBlock from '../components/text/textBlock';
 
 const SORTED_DEFAULT_API_SCOPES = DEFAULT_API_SCOPES.sort();
 const API_CHOICES = API_SCOPES.map(s => [s, s]);
-const API_INDEX_ROUTE = '/settings/account/api/auth-tokens';
+const API_INDEX_ROUTE = '/settings/account/api/auth-tokens/';
 
 export default class ApiNewToken extends React.Component {
   onCancel = () => {

+ 1 - 0
src/sentry/static/sentry/app/views/settings/components/forms/fieldFromConfig.jsx

@@ -23,6 +23,7 @@ export default class FieldFromConfig extends React.Component {
         'radio',
         'choice',
         'select',
+        'multichoice',
         'range',
       ]),
       required: PropTypes.bool,

+ 24 - 0
src/sentry/static/sentry/app/views/settings/project/projectCreateServiceHook.jsx

@@ -0,0 +1,24 @@
+import React from 'react';
+
+import {t} from '../../../locale';
+import AsyncView from '../../asyncView';
+import SettingsPageHeader from '../components/settingsPageHeader';
+
+import ServiceHookSettingsForm from './serviceHookSettingsForm';
+
+export default class ProjectCreateServiceHook extends AsyncView {
+  renderBody() {
+    let {orgId, projectId} = this.props.params;
+    return (
+      <div className="ref-project-create-service-hook">
+        <SettingsPageHeader title={t('Create Service Hook')} />
+        <ServiceHookSettingsForm
+          {...this.props}
+          orgId={orgId}
+          projectId={projectId}
+          initialData={{events: []}}
+        />
+      </div>
+    );
+  }
+}

+ 189 - 0
src/sentry/static/sentry/app/views/settings/project/projectServiceHookDetails.jsx

@@ -0,0 +1,189 @@
+import {browserHistory} from 'react-router';
+import React from 'react';
+import styled from 'react-emotion';
+
+import {t} from '../../../locale';
+import AsyncComponent from '../../../components/asyncComponent';
+import AsyncView from '../../asyncView';
+import {Panel, PanelAlert, PanelBody, PanelHeader} from '../../../components/panels';
+import Button from '../../../components/buttons/button';
+import EmptyMessage from '../components/emptyMessage';
+import ErrorBoundary from '../../../components/errorBoundary';
+import Field from '../components/forms/field';
+import getDynamicText from '../../../utils/getDynamicText';
+import IndicatorStore from '../../../stores/indicatorStore';
+import SettingsPageHeader from '../components/settingsPageHeader';
+import StackedBarChart from '../../../components/stackedBarChart';
+import TextBlock from '../components/text/textBlock';
+import TextCopyInput from '../components/forms/textCopyInput';
+
+import ServiceHookSettingsForm from './serviceHookSettingsForm';
+
+// TODO(dcramer): this is duplicated in ProjectKeyDetails
+const EmptyHeader = styled.div`
+  font-size: 1.3em;
+`;
+
+class HookStats extends AsyncComponent {
+  getEndpoints() {
+    let until = Math.floor(new Date().getTime() / 1000);
+    let since = until - 3600 * 24 * 30;
+    let {hookId, orgId, projectId} = this.props.params;
+    return [
+      [
+        'stats',
+        `/projects/${orgId}/${projectId}/hooks/${hookId}/stats/`,
+        {
+          query: {
+            since,
+            until,
+            resolution: '1d',
+          },
+        },
+      ],
+    ];
+  }
+
+  renderTooltip(point, pointIdx, chart) {
+    let timeLabel = chart.getTimeLabel(point);
+    let [total] = point.y;
+
+    let value = `${total.toLocaleString()} events`;
+
+    return (
+      '<div style="width:150px">' +
+      `<div class="time-label">${timeLabel}</div>` +
+      `<div class="value-label">${value}</div>` +
+      '</div>'
+    );
+  }
+
+  renderBody() {
+    let emptyStats = true;
+    let stats = this.state.stats.map(p => {
+      if (p.total) emptyStats = false;
+      return {
+        x: p.ts,
+        y: [p.accepted, p.dropped],
+      };
+    });
+
+    return (
+      <Panel>
+        <PanelHeader>{t('Events in the last 30 days (by day)')}</PanelHeader>
+        <PanelBody>
+          {!emptyStats ? (
+            <StackedBarChart
+              points={stats}
+              height={150}
+              label="events"
+              barClasses={['total']}
+              className="standard-barchart"
+              style={{border: 'none'}}
+              tooltip={this.renderTooltip}
+            />
+          ) : (
+            <EmptyMessage css={{flexDirection: 'column', alignItems: 'center'}}>
+              <EmptyHeader>{t('Nothing recorded in the last 30 days.')}</EmptyHeader>
+              <TextBlock css={{marginBottom: 0}}>
+                {t('Total webhooks fired for this configuration.')}
+              </TextBlock>
+            </EmptyMessage>
+          )}
+        </PanelBody>
+      </Panel>
+    );
+  }
+}
+
+export default class ProjectServiceHookDetails extends AsyncView {
+  getEndpoints() {
+    let {orgId, projectId, hookId} = this.props.params;
+    return [['hook', `/projects/${orgId}/${projectId}/hooks/${hookId}/`]];
+  }
+
+  onDelete = () => {
+    let {orgId, projectId, hookId} = this.props.params;
+    let loadingIndicator = IndicatorStore.add(t('Saving changes..'));
+    this.api.request(`/projects/${orgId}/${projectId}/hooks/${hookId}/`, {
+      method: 'DELETE',
+      success: () => {
+        IndicatorStore.remove(loadingIndicator);
+        browserHistory.push(`/settings/${orgId}/${projectId}/hooks/`);
+      },
+      error: () => {
+        IndicatorStore.remove(loadingIndicator);
+        IndicatorStore.add(
+          t('Unable to remove application. Please try again.'),
+          'error',
+          {
+            duration: 3000,
+          }
+        );
+      },
+    });
+  };
+
+  renderBody() {
+    let {orgId, projectId, hookId} = this.props.params;
+    let {hook} = this.state;
+    return (
+      <div>
+        <SettingsPageHeader title={t('Service Hook Details')} />
+
+        <ErrorBoundary>
+          <HookStats params={this.props.params} />
+        </ErrorBoundary>
+
+        <ServiceHookSettingsForm
+          {...this.props}
+          orgId={orgId}
+          projectId={projectId}
+          hookId={hookId}
+          initialData={{
+            ...hook,
+            isActive: hook.status != 'disabled',
+          }}
+        />
+        <Panel>
+          <PanelHeader>{t('Event Validation')}</PanelHeader>
+          <PanelBody>
+            <PanelAlert type="info" icon="icon-circle-exclamation" m={0} mb={0}>
+              Sentry will send the <code>X-ServiceHook-Signature</code> header built using{' '}
+              <code>HMAC(SHA256, [secret], [payload])</code>. You should always verify
+              this signature before trusting the information provided in the webhook.
+            </PanelAlert>
+            <Field
+              label={t('Secret')}
+              flexibleControlStateSize
+              inline={false}
+              help={t('The shared secret used for generating event HMAC signatures.')}
+            >
+              <TextCopyInput>
+                {getDynamicText({
+                  value: hook.secret,
+                  fixed: 'a dynamic secret value',
+                })}
+              </TextCopyInput>
+            </Field>
+          </PanelBody>
+        </Panel>
+        <Panel>
+          <PanelHeader>{t('Delete Hook')}</PanelHeader>
+          <PanelBody>
+            <Field
+              label={t('Delete Hook')}
+              help={t('Removing this hook is immediate and permanent.')}
+            >
+              <div>
+                <Button priority="danger" onClick={this.onDelete}>
+                  Delete Hook
+                </Button>
+              </div>
+            </Field>
+          </PanelBody>
+        </Panel>
+      </div>
+    );
+  }
+}

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