ui/job-view/pushes/PushHeader.jsx (364 lines of code) (raw):
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import isEqual from 'lodash/isEqual';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faMinusSquare,
faPlusSquare,
} from '@fortawesome/free-regular-svg-icons';
import {
faExternalLinkAlt,
faThumbtack,
faTimesCircle,
} from '@fortawesome/free-solid-svg-icons';
import { Badge, Button } from 'reactstrap';
import { Link } from 'react-router-dom';
import { getPercentComplete, toDateStr } from '../../helpers/display';
import { formatTaskclusterError } from '../../helpers/errorMessage';
import { getJobsUrl } from '../../helpers/url';
import PushModel from '../../models/push';
import JobModel from '../../models/job';
import PushHealthStatus from '../../shared/PushHealthStatus';
import { getUrlParam } from '../../helpers/location';
import { notify } from '../redux/stores/notifications';
import { setSelectedJob } from '../redux/stores/selectedJob';
import { pinJobs } from '../redux/stores/pinnedJobs';
import PushActionMenu from './PushActionMenu';
// url params we don't want added from the current querystring to the revision
// and author links.
const SKIPPED_LINK_PARAMS = [
'revision',
'fromchange',
'tochange',
'nojobs',
'startdate',
'enddate',
'author',
];
function PushCounts(props) {
const { pending, running, completed, fixedByCommit } = props;
const inProgress = pending + running;
const total = completed + inProgress;
const percentComplete = getPercentComplete(props);
return (
<div>
{fixedByCommit >= 1 && (
<span
className="badge badge-warning ml-1"
title="Count of Fixed By Commit tasks for this push"
>
{fixedByCommit}
</span>
)}
<span className="push-progress">
{percentComplete === 100 && <span>- Complete -</span>}
{percentComplete < 100 && total > 0 && (
<span title="Proportion of jobs that are complete">
{percentComplete}% - {inProgress} in progress
</span>
)}
</span>
</div>
);
}
PushCounts.propTypes = {
pending: PropTypes.number.isRequired,
running: PropTypes.number.isRequired,
completed: PropTypes.number.isRequired,
fixedByCommit: PropTypes.number.isRequired,
};
class PushHeader extends React.Component {
constructor(props) {
super(props);
const { pushTimestamp } = this.props;
this.pushDateStr = toDateStr(pushTimestamp);
}
shouldComponentUpdate(prevProps) {
const {
jobCounts: prevJobCounts,
watchState: prevWatchState,
selectedRunnableJobs: prevSelectedRunnableJobs,
runnableVisible: prevRunnableVisible,
collapsed: prevCollapsed,
pushHealthVisibility: prevPushHealthVisibility,
filterModel: prevFilterModel,
groupCountsExpanded: prevgroupCountsExpanded,
} = prevProps;
const {
jobCounts,
watchState,
selectedRunnableJobs,
runnableVisible,
collapsed,
pushHealthVisibility,
filterModel,
groupCountsExpanded,
} = this.props;
return (
!isEqual(prevJobCounts, jobCounts) ||
prevWatchState !== watchState ||
prevSelectedRunnableJobs !== selectedRunnableJobs ||
prevRunnableVisible !== runnableVisible ||
prevCollapsed !== collapsed ||
prevPushHealthVisibility !== pushHealthVisibility ||
prevFilterModel !== filterModel ||
prevgroupCountsExpanded !== groupCountsExpanded
);
}
getLinkParams() {
const { filterModel } = this.props;
return Object.entries(filterModel.getUrlParamsWithoutDefaults()).reduce(
(acc, [field, values]) =>
SKIPPED_LINK_PARAMS.includes(field) ? acc : { ...acc, [field]: values },
{},
);
}
triggerNewJobs = async () => {
const {
pushId,
selectedRunnableJobs,
hideRunnableJobs,
notify,
decisionTaskMap,
currentRepo,
} = this.props;
if (
!window.confirm(
'This will trigger all selected jobs. Click "OK" if you want to proceed.',
)
) {
return;
}
const { id: decisionTaskId } = decisionTaskMap[pushId];
PushModel.triggerNewJobs(selectedRunnableJobs, decisionTaskId, currentRepo)
.then((result) => {
notify(result, 'success');
hideRunnableJobs(pushId);
this.props.hideRunnableJobs();
})
.catch((e) => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
};
cancelAllJobs = () => {
if (
window.confirm(
'This will cancel all pending and running jobs for this push. It cannot be undone! Are you sure?',
)
) {
const { notify, push, decisionTaskMap, currentRepo } = this.props;
JobModel.cancelAll(
push.id,
currentRepo,
notify,
decisionTaskMap[push.id],
);
}
};
pinAllShownJobs = () => {
const {
setSelectedJob,
pinJobs,
expandAllPushGroups,
getAllShownJobs,
notify,
pushId,
} = this.props;
const shownJobs = getAllShownJobs(pushId);
const selectedTaskRun = getUrlParam('selectedTaskRun');
if (shownJobs.length) {
expandAllPushGroups(() => {
pinJobs(shownJobs);
if (!selectedTaskRun) {
setSelectedJob(shownJobs[0]);
}
});
} else {
notify('No jobs available to pin', 'danger');
}
};
render() {
const {
pushId,
jobCounts,
author,
revision,
runnableVisible,
watchState,
showRunnableJobs,
hideRunnableJobs,
showFuzzyJobs,
cycleWatchState,
notificationSupported,
selectedRunnableJobs,
collapsed,
pushHealthVisibility,
currentRepo,
pushHealthStatusCallback,
togglePushCollapsed,
} = this.props;
const cancelJobsTitle = 'Cancel all jobs';
const linkParams = this.getLinkParams();
const revisionPushFilterUrl = getJobsUrl({ ...linkParams, revision });
// we don't do this for revision because it is handled differently via updateRange.
const authorParams = this.getLinkParams();
if (authorParams.selectedTaskRun) {
delete authorParams.selectedTaskRun;
}
const authorPushFilterUrl = getJobsUrl({ ...authorParams, author });
const showPushHealthStatus =
pushHealthVisibility === 'All' ||
currentRepo.name === pushHealthVisibility.toLowerCase();
const watchStateLabel = {
none: 'Watch',
push: 'Notifying (per-push)',
job: 'Notifying (per-job)',
}[watchState];
const countSelectedRunnableJobs = selectedRunnableJobs.length;
return (
<div className="push-header" data-testid="push-header">
<div className="push-bar">
<span className="push-left">
<span className="push-title-left">
<FontAwesomeIcon
onClick={togglePushCollapsed}
icon={collapsed ? faPlusSquare : faMinusSquare}
className="mr-2 mt-2 text-muted pointable"
title={`${collapsed ? 'Expand' : 'Collapse'} push data`}
/>
<span>
<Link to={revisionPushFilterUrl} title="View only this push">
{this.pushDateStr}{' '}
<FontAwesomeIcon
icon={faExternalLinkAlt}
className="icon-superscript"
/>
</Link>{' '}
-{' '}
</span>
<Link to={authorPushFilterUrl}>{author}</Link>
</span>
</span>
{showPushHealthStatus && (
<PushHealthStatus
repoName={currentRepo.name}
revision={revision}
jobCounts={jobCounts}
statusCallback={pushHealthStatusCallback}
/>
)}
<PushCounts
className="push-counts"
pending={jobCounts.pending}
running={jobCounts.running}
completed={jobCounts.completed}
fixedByCommit={jobCounts.fixedByCommit}
/>
<span className="push-buttons">
{jobCounts.pending + jobCounts.running > 0 && (
<button
type="button"
className="btn btn-sm btn-push watch-commit-btn"
disabled={!notificationSupported}
title={
notificationSupported
? 'Get Desktop Notifications for this Push'
: 'Desktop notifications not supported in this browser'
}
data-watch-state={watchState}
onClick={() => cycleWatchState()}
>
{watchStateLabel}
</button>
)}
<button
type="button"
className="btn btn-sm btn-push cancel-all-jobs-btn"
title={cancelJobsTitle}
onClick={this.cancelAllJobs}
aria-label={cancelJobsTitle}
>
<FontAwesomeIcon
icon={faTimesCircle}
className="dim-quarter"
title="Cancel jobs"
/>
</button>
<button
type="button"
className="btn btn-sm btn-push pin-all-jobs-btn"
title="Pin all available jobs in this push"
aria-label="Pin all available jobs in this push"
onClick={this.pinAllShownJobs}
>
<FontAwesomeIcon icon={faThumbtack} title="Pin all jobs" />
</button>
{!!countSelectedRunnableJobs && runnableVisible && (
<Button
className="btn btn-sm btn-push trigger-new-jobs-btn"
title="Trigger new jobs"
onClick={this.triggerNewJobs}
>
Trigger
<Badge color="info" className="mx-1">
{countSelectedRunnableJobs}
</Badge>
New Job{countSelectedRunnableJobs > 1 ? 's' : ''}
</Button>
)}
<PushActionMenu
runnableVisible={runnableVisible}
revision={revision}
currentRepo={currentRepo}
pushId={pushId}
showRunnableJobs={showRunnableJobs}
hideRunnableJobs={hideRunnableJobs}
showFuzzyJobs={showFuzzyJobs}
/>
</span>
</div>
</div>
);
}
}
PushHeader.propTypes = {
push: PropTypes.shape({
id: PropTypes.number,
}).isRequired,
pushId: PropTypes.number.isRequired,
pushTimestamp: PropTypes.number.isRequired,
author: PropTypes.string.isRequired,
revision: PropTypes.string,
filterModel: PropTypes.shape({}).isRequired,
runnableVisible: PropTypes.bool.isRequired,
showRunnableJobs: PropTypes.func.isRequired,
hideRunnableJobs: PropTypes.func.isRequired,
showFuzzyJobs: PropTypes.func.isRequired,
cycleWatchState: PropTypes.func.isRequired,
setSelectedJob: PropTypes.func.isRequired,
pinJobs: PropTypes.func.isRequired,
expandAllPushGroups: PropTypes.func.isRequired,
notificationSupported: PropTypes.bool.isRequired,
getAllShownJobs: PropTypes.func.isRequired,
selectedRunnableJobs: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
collapsed: PropTypes.bool.isRequired,
notify: PropTypes.func.isRequired,
jobCounts: PropTypes.shape({}).isRequired,
pushHealthVisibility: PropTypes.string.isRequired,
decisionTaskMap: PropTypes.shape({}).isRequired,
watchState: PropTypes.string,
pushHealthStatusCallback: PropTypes.func,
currentRepo: PropTypes.shape({}).isRequired,
};
PushHeader.defaultProps = {
watchState: 'none',
pushHealthStatusCallback: null,
revision: null,
};
const mapStateToProps = ({ pushes: { decisionTaskMap } }) => ({
decisionTaskMap,
});
export default connect(mapStateToProps, { notify, setSelectedJob, pinJobs })(
PushHeader,
);