1# Copyright 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Provides the web interface for a set of alerts and their graphs."""
6
7import json
8
9from google.appengine.ext import ndb
10
11from dashboard import alerts
12from dashboard import chart_handler
13from dashboard import list_tests
14from dashboard import request_handler
15from dashboard import update_test_suites
16from dashboard import utils
17from dashboard.models import anomaly
18from dashboard.models import stoppage_alert
19
20# This is the max number of alerts to query at once. This is used in cases
21# when we may want to query more many more alerts than actually get displayed.
22_QUERY_LIMIT = 2000
23
24# Maximum number of alerts that we might want to try display in one table.
25_DISPLAY_LIMIT = 500
26
27
28class GroupReportHandler(chart_handler.ChartHandler):
29  """Request handler for requests for group report page."""
30
31  def get(self):
32    """Renders the UI for the group report page."""
33    self.RenderStaticHtml('group_report.html')
34
35  def post(self):
36    """Returns dynamic data for /group_report with some set of alerts.
37
38    The set of alerts is determined by the keys, bug ID or revision given.
39
40    Request parameters:
41      keys: A comma-separated list of urlsafe Anomaly keys (optional).
42      bug_id: A bug number on the Chromium issue tracker (optional).
43      rev: A revision number (optional).
44
45    Outputs:
46      JSON for the /group_report page XHR request.
47    """
48    keys = self.request.get('keys')
49    bug_id = self.request.get('bug_id')
50    rev = self.request.get('rev')
51
52    try:
53      if bug_id:
54        self._ShowAlertsWithBugId(bug_id)
55      elif keys:
56        self._ShowAlertsForKeys(keys)
57      elif rev:
58        self._ShowAlertsAroundRevision(rev)
59      else:
60        # TODO(qyearsley): Instead of just showing an error here, show a form
61        # where the user can input a bug ID or revision.
62        raise request_handler.InvalidInputError('No anomalies specified.')
63    except request_handler.InvalidInputError as error:
64      self.response.out.write(json.dumps({'error': str(error)}))
65
66  def _ShowAlertsWithBugId(self, bug_id):
67    """Show alerts for |bug_id|.
68
69    Args:
70      bug_id: A bug ID (as an int or string). Could be also be a pseudo-bug ID,
71          such as -1 or -2 indicating invalid or ignored.
72    """
73    if not _IsInt(bug_id):
74      raise request_handler.InvalidInputError('Invalid bug ID "%s".' % bug_id)
75    bug_id = int(bug_id)
76    anomaly_query = anomaly.Anomaly.query(
77        anomaly.Anomaly.bug_id == bug_id)
78    anomalies = anomaly_query.fetch(limit=_DISPLAY_LIMIT)
79    stoppage_alert_query = stoppage_alert.StoppageAlert.query(
80        stoppage_alert.StoppageAlert.bug_id == bug_id)
81    stoppage_alerts = stoppage_alert_query.fetch(limit=_DISPLAY_LIMIT)
82    self._ShowAlerts(anomalies + stoppage_alerts, bug_id)
83
84  def _ShowAlertsAroundRevision(self, rev):
85    """Shows a alerts whose revision range includes the given revision.
86
87    Args:
88      rev: A revision number, as a string.
89    """
90    if not _IsInt(rev):
91      raise request_handler.InvalidInputError('Invalid rev "%s".' % rev)
92    rev = int(rev)
93
94    # We can't make a query that has two inequality filters on two different
95    # properties (start_revision and end_revision). Therefore we first query
96    # Anomaly entities based on one of these, then filter the resulting list.
97    anomaly_query = anomaly.Anomaly.query(anomaly.Anomaly.end_revision >= rev)
98    anomaly_query = anomaly_query.order(anomaly.Anomaly.end_revision)
99    anomalies = anomaly_query.fetch(limit=_QUERY_LIMIT)
100    anomalies = [a for a in anomalies if a.start_revision <= rev]
101    stoppage_alert_query = stoppage_alert.StoppageAlert.query(
102        stoppage_alert.StoppageAlert.end_revision == rev)
103    stoppage_alerts = stoppage_alert_query.fetch(limit=_DISPLAY_LIMIT)
104    self._ShowAlerts(anomalies + stoppage_alerts)
105
106  def _ShowAlertsForKeys(self, keys):
107    """Show alerts for |keys|.
108
109    Query for anomalies with overlapping revision. The |keys|
110    parameter for group_report is a comma-separated list of urlsafe strings
111    for Keys for Anomaly entities. (Each key corresponds to an alert)
112
113    Args:
114      keys: Comma-separated list of urlsafe strings for Anomaly keys.
115    """
116    urlsafe_keys = keys.split(',')
117    try:
118      keys = [ndb.Key(urlsafe=k) for k in urlsafe_keys]
119    # Errors that can be thrown here include ProtocolBufferDecodeError
120    # in google.net.proto.ProtocolBuffer. We want to catch any errors here
121    # because they're almost certainly urlsafe key decoding errors.
122    except Exception:
123      raise request_handler.InvalidInputError('Invalid Anomaly key given.')
124
125    requested_anomalies = utils.GetMulti(keys)
126
127    for i, anomaly_entity in enumerate(requested_anomalies):
128      if anomaly_entity is None:
129        raise request_handler.InvalidInputError(
130            'No Anomaly found for key %s.' % urlsafe_keys[i])
131
132    if not requested_anomalies:
133      raise request_handler.InvalidInputError('No anomalies found.')
134
135    sheriff_key = requested_anomalies[0].sheriff
136    min_range = utils.MinimumAlertRange(requested_anomalies)
137    if min_range:
138      query = anomaly.Anomaly.query(
139          anomaly.Anomaly.sheriff == sheriff_key)
140      query = query.order(-anomaly.Anomaly.timestamp)
141      anomalies = query.fetch(limit=_QUERY_LIMIT)
142
143      # Filter out anomalies that have been marked as invalid or ignore.
144      # Include all anomalies with an overlapping revision range that have
145      # been associated with a bug, or are not yet triaged.
146      anomalies = [a for a in anomalies if a.bug_id is None or a.bug_id > 0]
147      anomalies = _GetOverlaps(anomalies, min_range[0], min_range[1])
148
149      # Make sure alerts in specified param "keys" are included.
150      key_set = {a.key for a in anomalies}
151      for anomaly_entity in requested_anomalies:
152        if anomaly_entity.key not in key_set:
153          anomalies.append(anomaly_entity)
154    else:
155      anomalies = requested_anomalies
156    self._ShowAlerts(anomalies)
157
158  def _ShowAlerts(self, alert_list, bug_id=None):
159    """Responds to an XHR from /group_report page with a JSON list of alerts.
160
161    Args:
162      alert_list: A list of Anomaly and/or StoppageAlert entities.
163      bug_id: An integer bug ID.
164    """
165    anomaly_dicts = alerts.AnomalyDicts(
166        [a for a in alert_list if a.key.kind() == 'Anomaly'])
167    stoppage_alert_dicts = alerts.StoppageAlertDicts(
168        [a for a in alert_list if a.key.kind() == 'StoppageAlert'])
169    alert_dicts = anomaly_dicts + stoppage_alert_dicts
170
171    values = {
172        'alert_list': alert_dicts[:_DISPLAY_LIMIT],
173        'subtests': _GetSubTestsForAlerts(alert_dicts),
174        'bug_id': bug_id,
175        'test_suites': update_test_suites.FetchCachedTestSuites(),
176    }
177    self.GetDynamicVariables(values)
178
179    self.response.out.write(json.dumps(values))
180
181
182def _IsInt(x):
183  """Returns True if the input can be parsed as an int."""
184  try:
185    int(x)
186    return True
187  except ValueError:
188    return False
189
190
191def _GetSubTestsForAlerts(alert_list):
192  """Gets subtest dict for list of alerts."""
193  subtests = {}
194  for alert in alert_list:
195    bot_name = alert['master'] + '/' + alert['bot']
196    testsuite = alert['testsuite']
197    if bot_name not in subtests:
198      subtests[bot_name] = {}
199    if testsuite not in subtests[bot_name]:
200      subtests[bot_name][testsuite] = list_tests.GetSubTests(
201          testsuite, [bot_name])
202  return subtests
203
204
205def _GetOverlaps(anomalies, start, end):
206  """Gets the minimum range for the list of anomalies.
207
208  Args:
209    anomalies: The list of anomalies.
210    start: The start revision.
211    end: The end revision.
212
213  Returns:
214    A list of anomalies.
215  """
216  return [a for a in anomalies
217          if a.start_revision <= end and a.end_revision >= start]
218