Bug 1630712 - Single try push view show Push Health Summary (#6314)

This commit is contained in:
Cameron Dawson 2020-04-17 12:03:21 -07:00 коммит произвёл GitHub
Родитель a965e7b62f
Коммит 96bf8370e1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
8 изменённых файлов: 285 добавлений и 126 удалений

Просмотреть файл

@ -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},
)

Просмотреть файл

@ -54,10 +54,6 @@ input {
vertical-align: middle;
}
.small-text {
font-size: 12px;
}
.mg-chart-title {
font-size: 14px;
}

Просмотреть файл

@ -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);

Просмотреть файл

@ -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}
/>
<div className="push-body-divider" />
{!collapsed ? (
@ -617,7 +638,17 @@ class Push extends React.PureComponent {
revisionCount={revisionCount}
repo={currentRepo}
widthClass="col-5"
/>
>
{singleTryPush && (
<div className="ml-3 mt-4">
<PushHealthSummary
healthStatus={pushHealthStatus}
revision={revision}
repoName={currentRepo.name}
/>
</div>
)}
</RevisionList>
)}
<span className="job-list job-list-pad col-7">
<PushJobs

Просмотреть файл

@ -274,6 +274,7 @@ class PushHeader extends React.Component {
collapsed,
pushHealthVisibility,
currentRepo,
pushHealthStatusCallback,
} = this.props;
const cancelJobsTitle = isLoggedIn
? 'Cancel all jobs'
@ -320,6 +321,7 @@ class PushHeader extends React.Component {
repoName={currentRepo.name}
revision={revision}
jobCounts={jobCounts}
statusCallback={pushHealthStatusCallback}
/>
)}
<PushCounts
@ -426,10 +428,12 @@ PushHeader.propTypes = {
decisionTaskMap: PropTypes.object.isRequired,
watchState: PropTypes.string,
currentRepo: PropTypes.object.isRequired,
pushHealthStatusCallback: PropTypes.func,
};
PushHeader.defaultProps = {
watchState: 'none',
pushHealthStatusCallback: null,
};
const mapStateToProps = ({ pushes: { decisionTaskMap } }) => ({

Просмотреть файл

@ -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;

Просмотреть файл

@ -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 (
<div>
<a
href={getPushHealthUrl({ revision, repo: repoName })}
title="View Push Health details for this push"
>
<div>
{healthStatus !== null ? (
<img
src={needInvestigation ? broken : ok}
alt={needInvestigation ? 'Broken' : 'OK'}
width={heartSize}
height={heartSize}
className="mr-1"
/>
) : (
<span className="ml-1 text-darker-secondary">
<FontAwesomeIcon
icon={faHeart}
height={heartSize}
width={heartSize}
color="darker-secondary"
/>
</span>
)}
Push Health Summary
<FontAwesomeIcon
icon={faExternalLinkAlt}
className="ml-1 icon-superscript"
/>
</div>
</a>
{healthStatus ? (
<Table className="ml-3 w-100 small-text row-height-tight">
<tbody>
<tr className={`${buildFailureCount ? 'font-weight-bold' : ''}`}>
<td className="py-1">Build Failures</td>
<td className="py-1">{buildFailureCount}</td>
</tr>
<tr
className={`${testFailureCount ? 'font-weight-bold' : ''} py-1`}
>
<td className="py-1">Test Failures</td>
<td className="py-1">{testFailureCount}</td>
</tr>
<tr
className={`${lintFailureCount ? 'font-weight-bold' : ''} py-1`}
>
<td className="py-1">Linting Failures</td>
<td className="py-1">{lintFailureCount}</td>
</tr>
<tr className={`${unsupported ? 'font-weight-bold' : ''} py-1`}>
<td className="py-1">Unsupported</td>
<td className="py-1">{unsupported}</td>
</tr>
</tbody>
</Table>
) : (
<Spinner />
)}
</div>
);
}
}
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;

Просмотреть файл

@ -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 (
<Col className={`${widthClass} mb-3`}>
@ -18,6 +25,7 @@ export class RevisionList extends React.PureComponent {
{revisionCount > revisions.length && (
<MoreRevisionsLink key="more" href={repo.getPushLogHref(revision)} />
)}
{children}
</Col>
);
}