diff --git a/treeherder/webapp/api/push.py b/treeherder/webapp/api/push.py index fa917abee..69adcd841 100644 --- a/treeherder/webapp/api/push.py +++ b/treeherder/webapp/api/push.py @@ -6,21 +6,16 @@ from cache_memoize import cache_memoize from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.status import (HTTP_400_BAD_REQUEST, - HTTP_404_NOT_FOUND) +from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND -from treeherder.model.models import (Job, - JobType, - Push, - Repository) +from treeherder.model.models import Job, JobType, Push, Repository from treeherder.push_health.builds import get_build_failures from treeherder.push_health.compare import get_commit_history from treeherder.push_health.linting import get_lint_failures from treeherder.push_health.tests import get_test_failures from treeherder.push_health.usage import get_usage from treeherder.webapp.api.serializers import PushSerializer -from treeherder.webapp.api.utils import (to_datetime, - to_timestamp) +from treeherder.webapp.api.utils import to_datetime, to_timestamp logger = logging.getLogger(__name__) @@ -44,7 +39,14 @@ class PushViewSet(viewsets.ViewSet): meta = {} # support ranges for date as well as revisions(changes) like old tbpl - for param in ["fromchange", "tochange", "startdate", "enddate", "revision", "commit_revision"]: + for param in [ + "fromchange", + "tochange", + "startdate", + "enddate", + "revision", + "commit_revision", + ]: v = filter_params.get(param, None) if v: del filter_params[param] @@ -53,9 +55,9 @@ class PushViewSet(viewsets.ViewSet): try: repository = Repository.objects.get(name=project) except Repository.DoesNotExist: - return Response({ - "detail": "No project with name {}".format(project) - }, status=HTTP_404_NOT_FOUND) + return Response( + {"detail": "No project with name {}".format(project)}, status=HTTP_404_NOT_FOUND + ) pushes = Push.objects.filter(repository=repository).order_by('-time') @@ -63,43 +65,36 @@ class PushViewSet(viewsets.ViewSet): if param == 'fromchange': revision_field = 'revision__startswith' if len(value) < 40 else 'revision' filter_kwargs = {revision_field: value, 'repository': repository} - frompush_time = Push.objects.values_list('time', flat=True).get( - **filter_kwargs) + frompush_time = Push.objects.values_list('time', flat=True).get(**filter_kwargs) pushes = pushes.filter(time__gte=frompush_time) - filter_params.update({ - "push_timestamp__gte": to_timestamp(frompush_time) - }) + filter_params.update({"push_timestamp__gte": to_timestamp(frompush_time)}) self.report_if_short_revision(param, value) elif param == 'tochange': revision_field = 'revision__startswith' if len(value) < 40 else 'revision' filter_kwargs = {revision_field: value, 'repository': repository} - topush_time = Push.objects.values_list('time', flat=True).get( - **filter_kwargs) + topush_time = Push.objects.values_list('time', flat=True).get(**filter_kwargs) pushes = pushes.filter(time__lte=topush_time) - filter_params.update({ - "push_timestamp__lte": to_timestamp(topush_time) - }) + filter_params.update({"push_timestamp__lte": to_timestamp(topush_time)}) self.report_if_short_revision(param, value) elif param == 'startdate': pushes = pushes.filter(time__gte=to_datetime(value)) - filter_params.update({ - "push_timestamp__gte": to_timestamp(to_datetime(value)) - }) + filter_params.update({"push_timestamp__gte": to_timestamp(to_datetime(value))}) elif param == 'enddate': real_end_date = to_datetime(value) + datetime.timedelta(days=1) pushes = pushes.filter(time__lte=real_end_date) - filter_params.update({ - "push_timestamp__lt": to_timestamp(real_end_date) - }) + filter_params.update({"push_timestamp__lt": to_timestamp(real_end_date)}) elif param == 'revision': # revision must be the tip revision of the push itself revision_field = 'revision__startswith' if len(value) < 40 else 'revision' filter_kwargs = {revision_field: value} pushes = pushes.filter(**filter_kwargs) - rev_key = "revisions_long_revision" \ - if len(meta['revision']) == 40 else "revisions_short_revision" + rev_key = ( + "revisions_long_revision" + if len(meta['revision']) == 40 + else "revisions_short_revision" + ) filter_params.update({rev_key: meta['revision']}) self.report_if_short_revision(param, value) elif param == 'commit_revision': @@ -108,30 +103,31 @@ class PushViewSet(viewsets.ViewSet): pushes = pushes.filter(commits__revision=value) self.report_if_short_revision(param, value) - for param in ['push_timestamp__lt', 'push_timestamp__lte', - 'push_timestamp__gt', 'push_timestamp__gte']: + for param in [ + 'push_timestamp__lt', + 'push_timestamp__lte', + 'push_timestamp__gt', + 'push_timestamp__gte', + ]: if filter_params.get(param): # translate push timestamp directly into a filter try: - value = datetime.datetime.fromtimestamp( - float(filter_params.get(param))) + value = datetime.datetime.fromtimestamp(float(filter_params.get(param))) except ValueError: - return Response({ - "detail": "Invalid timestamp specified for {}".format( - param) - }, status=HTTP_400_BAD_REQUEST) - pushes = pushes.filter(**{ - param.replace('push_timestamp', 'time'): value - }) + return Response( + {"detail": "Invalid timestamp specified for {}".format(param)}, + status=HTTP_400_BAD_REQUEST, + ) + pushes = pushes.filter(**{param.replace('push_timestamp', 'time'): value}) for param in ['id__lt', 'id__lte', 'id__gt', 'id__gte', 'id']: try: value = int(filter_params.get(param, 0)) except ValueError: - return Response({ - "detail": "Invalid timestamp specified for {}".format( - param) - }, status=HTTP_400_BAD_REQUEST) + return Response( + {"detail": "Invalid timestamp specified for {}".format(param)}, + status=HTTP_400_BAD_REQUEST, + ) if value: pushes = pushes.filter(**{param: value}) @@ -140,8 +136,9 @@ class PushViewSet(viewsets.ViewSet): try: id_in_list = [int(id) for id in id_in.split(',')] except ValueError: - return Response({"detail": "Invalid id__in specification"}, - status=HTTP_400_BAD_REQUEST) + return Response( + {"detail": "Invalid id__in specification"}, status=HTTP_400_BAD_REQUEST + ) pushes = pushes.filter(id__in=id_in_list) author = filter_params.get("author") @@ -151,8 +148,7 @@ class PushViewSet(viewsets.ViewSet): try: count = int(filter_params.get("count", 10)) except ValueError: - return Response({"detail": "Valid count value required"}, - status=HTTP_400_BAD_REQUEST) + return Response({"detail": "Valid count value required"}, status=HTTP_400_BAD_REQUEST) if count > MAX_PUSH_COUNT: msg = "Specified count exceeds api limit: {}".format(MAX_PUSH_COUNT) @@ -170,10 +166,7 @@ class PushViewSet(viewsets.ViewSet): meta['repository'] = project meta['filter_params'] = filter_params - resp = { - 'meta': meta, - 'results': serializer.data - } + resp = {'meta': meta, 'results': serializer.data} return Response(resp) @@ -182,13 +175,11 @@ class PushViewSet(viewsets.ViewSet): GET method implementation for detail view of ``push`` """ try: - push = Push.objects.get(repository__name=project, - id=pk) + push = Push.objects.get(repository__name=project, id=pk) serializer = PushSerializer(push) return Response(serializer.data) except Push.DoesNotExist: - return Response("No push with id: {0}".format(pk), - status=HTTP_404_NOT_FOUND) + return Response("No push with id: {0}".format(pk), status=HTTP_404_NOT_FOUND) @action(detail=True) def status(self, request, project, pk=None): @@ -199,8 +190,7 @@ class PushViewSet(viewsets.ViewSet): try: push = Push.objects.get(id=pk) except Push.DoesNotExist: - return Response("No push with id: {0}".format(pk), - status=HTTP_404_NOT_FOUND) + return Response("No push with id: {0}".format(pk), status=HTTP_404_NOT_FOUND) return Response(push.get_status()) @action(detail=False) @@ -213,20 +203,26 @@ class PushViewSet(viewsets.ViewSet): try: push = Push.objects.get(revision=revision, repository__name=project) except Push.DoesNotExist: - return Response("No push with revision: {0}".format(revision), - status=HTTP_404_NOT_FOUND) + return Response( + "No push with revision: {0}".format(revision), status=HTTP_404_NOT_FOUND + ) push_health_test_failures = get_test_failures(push) push_health_lint_failures = get_lint_failures(push) push_health_build_failures = get_build_failures(push) + test_failure_count = len(push_health_test_failures['needInvestigation']) + build_failure_count = len(push_health_build_failures) + lint_failure_count = len(push_health_lint_failures) - return Response({ - 'needInvestigation': - len(push_health_test_failures['needInvestigation']) + - len(push_health_build_failures) + - len(push_health_lint_failures), - 'unsupported': len(push_health_test_failures['unsupported']), - }) + return Response( + { + 'testFailureCount': test_failure_count, + 'buildFailureCount': build_failure_count, + 'lintFailureCount': lint_failure_count, + 'needInvestigation': test_failure_count + build_failure_count + lint_failure_count, + 'unsupported': len(push_health_test_failures['unsupported']), + } + ) @action(detail=False) def health_usage(self, request, project): @@ -244,8 +240,9 @@ class PushViewSet(viewsets.ViewSet): repository = Repository.objects.get(name=project) push = Push.objects.get(revision=revision, repository=repository) except Push.DoesNotExist: - return Response("No push with revision: {0}".format(revision), - status=HTTP_404_NOT_FOUND) + return Response( + "No push with revision: {0}".format(revision), status=HTTP_404_NOT_FOUND + ) commit_history_details = None parent_push = None @@ -277,53 +274,53 @@ class PushViewSet(viewsets.ViewSet): elif metric_result == 'fail': push_result = metric_result - newrelic.agent.record_custom_event('push_health_need_investigation', { - 'revision': revision, - 'repo': repository.name, - 'needInvestigation': len(push_health_test_failures['needInvestigation']), - 'unsupported': len(push_health_test_failures['unsupported']), - 'author': push.author, - }) - - return Response({ - 'revision': revision, - 'id': push.id, - 'result': push_result, - 'metrics': { - 'commitHistory': { - 'name': 'Commit History', - 'result': 'none', - 'details': commit_history_details, - }, - 'linting': { - 'name': 'Linting', - 'result': lint_result, - 'details': lint_failures, - }, - 'tests': { - 'name': 'Tests', - 'result': test_result, - 'details': push_health_test_failures, - }, - 'builds': { - 'name': 'Builds', - 'result': build_result, - 'details': build_failures, - }, + newrelic.agent.record_custom_event( + 'push_health_need_investigation', + { + 'revision': revision, + 'repo': repository.name, + 'needInvestigation': len(push_health_test_failures['needInvestigation']), + 'unsupported': len(push_health_test_failures['unsupported']), + 'author': push.author, }, - 'status': push.get_status(), - }) + ) + + return Response( + { + 'revision': revision, + 'id': push.id, + 'result': push_result, + 'metrics': { + 'commitHistory': { + 'name': 'Commit History', + 'result': 'none', + 'details': commit_history_details, + }, + 'linting': { + 'name': 'Linting', + 'result': lint_result, + 'details': lint_failures, + }, + 'tests': { + 'name': 'Tests', + 'result': test_result, + 'details': push_health_test_failures, + }, + 'builds': { + 'name': 'Builds', + 'result': build_result, + 'details': build_failures, + }, + }, + 'status': push.get_status(), + } + ) @cache_memoize(60 * 60) def get_decision_jobs(self, push_ids): - job_types = JobType.objects.filter( - name__endswith='Decision Task', - symbol='D' - ) + job_types = JobType.objects.filter(name__endswith='Decision Task', symbol='D') return Job.objects.filter( - push_id__in=push_ids, - job_type__in=job_types, - result='success', + push_id__in=push_ids, job_type__in=job_types, result='success', ).select_related('taskcluster_metadata') @action(detail=False) @@ -336,20 +333,24 @@ class PushViewSet(viewsets.ViewSet): if decision_jobs: return Response( - {job.push_id: { - 'id': job.taskcluster_metadata.task_id, - 'run': job.guid.split('/')[1], - } for job in decision_jobs} + { + job.push_id: { + 'id': job.taskcluster_metadata.task_id, + 'run': job.guid.split('/')[1], + } + for job in decision_jobs + } ) logger.error('/decisiontask/ found no decision jobs for {}'.format(push_ids)) self.get_decision_jobs.invalidate(push_ids) - return Response("No decision tasks found for pushes: {}".format(push_ids), - status=HTTP_404_NOT_FOUND) + return Response( + "No decision tasks found for pushes: {}".format(push_ids), status=HTTP_404_NOT_FOUND + ) # TODO: Remove when we no longer support short revisions: Bug 1306707 def report_if_short_revision(self, param, revision): if len(revision) < 40: newrelic.agent.record_custom_event( 'short_revision_push_api', - {'error': 'Revision <40 chars', 'param': param, 'revision': revision} + {'error': 'Revision <40 chars', 'param': param, 'revision': revision}, ) diff --git a/ui/css/intermittent-failures.css b/ui/css/intermittent-failures.css index 868ad44f2..10fde36e3 100644 --- a/ui/css/intermittent-failures.css +++ b/ui/css/intermittent-failures.css @@ -54,10 +54,6 @@ input { vertical-align: middle; } -.small-text { - font-size: 12px; -} - .mg-chart-title { font-size: 14px; } diff --git a/ui/css/treeherder-custom-styles.css b/ui/css/treeherder-custom-styles.css index 7b563f4e5..20f875797 100644 --- a/ui/css/treeherder-custom-styles.css +++ b/ui/css/treeherder-custom-styles.css @@ -27,6 +27,10 @@ visibility: hidden; } +.small-text { + font-size: 12px; +} + /* this is better for drop down menu items because display: none will change text alignment */ .hide { @@ -376,6 +380,10 @@ max-width: 890px; } +.row-height-tight { + line-height: 15px !important; +} + /* ReactTable overriding row, button and link color style for contrast pass */ .ReactTable .-pagination .-btn:focus { box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5); diff --git a/ui/job-view/pushes/Push.jsx b/ui/job-view/pushes/Push.jsx index 3fcc5f896..f66bdc86e 100644 --- a/ui/job-view/pushes/Push.jsx +++ b/ui/job-view/pushes/Push.jsx @@ -26,6 +26,7 @@ import { } from '../../taskcluster-auth-callback/constants'; import { RevisionList } from '../../shared/RevisionList'; import { Revision } from '../../shared/Revision'; +import PushHealthSummary from '../../shared/PushHealthSummary'; import FuzzyJobFinder from './FuzzyJobFinder'; import PushHeader from './PushHeader'; @@ -92,6 +93,8 @@ class Push extends React.PureComponent { jobCounts: { pending: 0, running: 0, completed: 0, fixedByCommit: 0 }, pushGroupState: 'collapsed', collapsed: collapsedPushes.includes(push.id), + singleTryPush: false, + pushHealthStatus: null, }; } @@ -107,12 +110,15 @@ class Push extends React.PureComponent { await this.fetchTestManifests(); } + this.testForSingleTry(); + window.addEventListener(thEvents.applyNewJobs, this.handleApplyNewJobs); window.addEventListener('hashchange', this.handleUrlChanges); } componentDidUpdate(prevProps, prevState) { this.showUpdateNotifications(prevState); + this.testForSingleTry(); } componentWillUnmount() { @@ -170,6 +176,14 @@ class Push extends React.PureComponent { )}`; } + testForSingleTry = () => { + const { currentRepo } = this.props; + const revision = getUrlParam('revision'); + const singleTryPush = !!revision && currentRepo.name === 'try'; + + this.setState({ singleTryPush }); + }; + handleUrlChanges = async () => { const { push } = this.props; const allParams = getAllUrlParams(); @@ -523,6 +537,10 @@ class Push extends React.PureComponent { })); }; + pushHealthStatusCallback = pushHealthStatus => { + this.setState({ pushHealthStatus }); + }; + render() { const { push, @@ -548,6 +566,8 @@ class Push extends React.PureComponent { jobCounts, selectedRunnableJobs, collapsed, + singleTryPush, + pushHealthStatus, } = this.state; const { id, @@ -606,6 +626,7 @@ class Push extends React.PureComponent { notificationSupported={notificationSupported} pushHealthVisibility={pushHealthVisibility} groupCountsExpanded={groupCountsExpanded} + pushHealthStatusCallback={this.pushHealthStatusCallback} />
{!collapsed ? ( @@ -617,7 +638,17 @@ class Push extends React.PureComponent { revisionCount={revisionCount} repo={currentRepo} widthClass="col-5" - /> + > + {singleTryPush && ( +
+ +
+ )} + )} )} ({ diff --git a/ui/shared/PushHealthStatus.jsx b/ui/shared/PushHealthStatus.jsx index c7c5bd2d5..e6f686d8b 100644 --- a/ui/shared/PushHealthStatus.jsx +++ b/ui/shared/PushHealthStatus.jsx @@ -42,13 +42,14 @@ class PushHealthStatus extends Component { } async loadLatestStatus() { - const { repoName, revision } = this.props; + const { repoName, revision, statusCallback } = this.props; const { data, failureStatus } = await PushModel.getHealthSummary( repoName, revision, ); if (!failureStatus) { + statusCallback(data); this.setState({ ...data }); } } @@ -123,6 +124,11 @@ PushHealthStatus.propTypes = { running: PropTypes.number.isRequired, completed: PropTypes.number.isRequired, }).isRequired, + statusCallback: PropTypes.func, +}; + +PushHealthStatus.defaultProps = { + statusCallback: () => {}, }; export default PushHealthStatus; diff --git a/ui/shared/PushHealthSummary.jsx b/ui/shared/PushHealthSummary.jsx new file mode 100644 index 000000000..acad4f697 --- /dev/null +++ b/ui/shared/PushHealthSummary.jsx @@ -0,0 +1,105 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Spinner, Table } from 'reactstrap'; +import { faHeart, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import broken from '../img/push-health-broken.png'; +import ok from '../img/push-health-ok.png'; +import { getPushHealthUrl } from '../helpers/url'; + +class PushHealthSummary extends PureComponent { + render() { + const { healthStatus, revision, repoName } = this.props; + const status = healthStatus || {}; + const { + needInvestigation, + testFailureCount, + buildFailureCount, + lintFailureCount, + unsupported, + } = status; + const heartSize = 25; + + return ( +
+ +
+ {healthStatus !== null ? ( + {needInvestigation + ) : ( + + + + )} + Push Health Summary + +
+
+ {healthStatus ? ( + + + + + + + + + + + + + + + + + + + +
Build Failures{buildFailureCount}
Test Failures{testFailureCount}
Linting Failures{lintFailureCount}
Unsupported{unsupported}
+ ) : ( + + )} +
+ ); + } +} + +PushHealthSummary.propTypes = { + revision: PropTypes.string.isRequired, + repoName: PropTypes.string.isRequired, + healthStatus: PropTypes.shape({ + needInvestigation: PropTypes.number, + testFailureCount: PropTypes.number, + buildFailureCount: PropTypes.number, + lintFailureCount: PropTypes.number, + unsupported: PropTypes.number, + }), +}; + +PushHealthSummary.defaultProps = { + healthStatus: {}, +}; + +export default PushHealthSummary; diff --git a/ui/shared/RevisionList.jsx b/ui/shared/RevisionList.jsx index 9f6fdd83e..e3de6fe0a 100644 --- a/ui/shared/RevisionList.jsx +++ b/ui/shared/RevisionList.jsx @@ -8,7 +8,14 @@ import { Revision } from './Revision'; export class RevisionList extends React.PureComponent { render() { - const { revision, revisions, revisionCount, repo, widthClass } = this.props; + const { + revision, + revisions, + revisionCount, + repo, + widthClass, + children, + } = this.props; return ( @@ -18,6 +25,7 @@ export class RevisionList extends React.PureComponent { {revisionCount > revisions.length && ( )} + {children} ); }