ui/job-view/details/DetailsPanel.jsx (414 lines of code) (raw):
import React from 'react';
import PropTypes from 'prop-types';
import chunk from 'lodash/chunk';
import { connect } from 'react-redux';
import { setPinBoardVisible } from '../redux/stores/pinnedJobs';
import { thEvents } from '../../helpers/constants';
import { addAggregateFields } from '../../helpers/job';
import { getLogViewerUrl, getArtifactsUrl } from '../../helpers/url';
import { formatArtifacts } from '../../helpers/display';
import { getData } from '../../helpers/http';
import BugJobMapModel from '../../models/bugJobMap';
import JobClassificationModel from '../../models/classification';
import JobModel from '../../models/job';
import JobLogUrlModel from '../../models/jobLogUrl';
import PerfSeriesModel from '../../models/perfSeries';
import { Perfdocs } from '../../perfherder/perf-helpers/perfdocs';
import PinBoard from './PinBoard';
import SummaryPanel from './summary/SummaryPanel';
import TabsPanel from './tabs/TabsPanel';
export const pinboardHeight = 100;
class DetailsPanel extends React.Component {
constructor(props) {
super(props);
// used to cancel all the ajax requests triggered by selectJob
this.selectJobController = null;
this.state = {
selectedJobFull: null,
jobDetails: [],
jobLogUrls: [],
jobDetailLoading: false,
jobArtifactsLoading: false,
logViewerUrl: null,
logViewerFullUrl: null,
perfJobDetail: [],
jobRevision: null,
logParseStatus: 'unavailable',
classifications: [],
bugs: [],
};
}
componentDidMount() {
window.addEventListener(
thEvents.classificationChanged,
this.updateClassifications,
);
}
componentDidUpdate(prevProps) {
const { selectedJob } = this.props;
if (selectedJob && prevProps.selectedJob) {
const {
id: prevId,
state: prevState,
result: prevResult,
failure_classification_id: prevFci,
} = prevProps.selectedJob;
const { id, state, result, failure_classification_id: fci } = selectedJob;
// Check the id in case the user switched to a new job.
// But also check some of the fields of the selected job,
// in case they have changed due to polling.
if (
prevId !== id ||
prevState !== state ||
prevResult !== result ||
prevFci !== fci
) {
this.selectJob();
}
} else if (selectedJob && selectedJob !== prevProps.selectedJob) {
this.selectJob();
}
}
componentWillUnmount() {
window.removeEventListener(
thEvents.classificationChanged,
this.updateClassifications,
);
}
togglePinBoardVisibility = () => {
const { setPinBoardVisible, isPinBoardVisible } = this.props;
setPinBoardVisible(!isPinBoardVisible);
};
updateClassifications = async () => {
const { selectedJob } = this.props;
const classifications = await JobClassificationModel.getList({
job_id: selectedJob.id,
});
const bugs = await BugJobMapModel.getList({ job_id: selectedJob.id });
this.setState({ classifications, bugs });
};
findPush = (pushId) => {
const { pushList } = this.props;
return pushList.find((push) => pushId === push.id);
};
selectJob = () => {
const { currentRepo, selectedJob, frameworks } = this.props;
const push = this.findPush(selectedJob.push_id);
this.setState(
{
jobDetails: [],
suggestions: [],
jobDetailLoading: true,
jobArtifactsLoading: true,
},
() => {
if (this.selectJobController !== null) {
// Cancel the in-progress fetch requests.
this.selectJobController.abort();
}
this.selectJobController = new AbortController();
const jobPromise =
'logs' in selectedJob
? Promise.resolve(selectedJob)
: JobModel.get(
currentRepo.name,
selectedJob.id,
this.selectJobController.signal,
);
const artifactsParams = {
jobId: selectedJob.id,
taskId: selectedJob.task_id,
run: selectedJob.retry_id,
rootUrl: currentRepo.tc_root_url,
};
const jobArtifactsPromise = getData(
getArtifactsUrl(artifactsParams),
this.selectJobController.signal,
);
let builtFromArtifactPromise;
if (
currentRepo.name === 'comm-central' ||
currentRepo.name === 'try-comm-central'
) {
builtFromArtifactPromise = getData(
getArtifactsUrl({
...artifactsParams,
...{ artifactPath: 'public/build/built_from.json' },
}),
);
}
const jobLogUrlPromise = JobLogUrlModel.getList(
{ job_id: selectedJob.id },
this.selectJobController.signal,
);
const phSeriesPromise = PerfSeriesModel.getSeriesData(
currentRepo.name,
{
job_id: selectedJob.id,
},
);
Promise.all([
jobPromise,
jobLogUrlPromise,
phSeriesPromise,
builtFromArtifactPromise,
])
.then(
async ([
jobResult,
jobLogUrlResult,
phSeriesResult,
builtFromArtifactResult,
]) => {
// This version of the job has more information than what we get in the main job list. This
// is what we'll pass to the rest of the details panel.
// Don't update the job instance in the greater job field so as to not add the memory overhead
// of all the extra fields in ``selectedJobFull``. It's not that much for just one job, but as
// one selects job after job, over the course of a day, it can add up. Therefore, we keep
// selectedJobFull data as transient only when the job is selected.
const selectedJobFull = {
...jobResult,
hasSideBySide: selectedJob.hasSideBySide,
};
const jobRevision = push ? push.revision : null;
addAggregateFields(selectedJobFull);
Promise.all([jobArtifactsPromise]).then(
async ([jobArtifactsResult]) => {
let jobDetails = jobArtifactsResult.data.artifacts
? formatArtifacts(jobArtifactsResult.data.artifacts, {
...artifactsParams,
})
: [];
if (
builtFromArtifactResult &&
!builtFromArtifactResult.failureStatus
) {
jobDetails = [
...jobDetails,
...builtFromArtifactResult.data,
];
}
this.setState({
jobDetails,
jobArtifactsLoading: false,
});
},
);
// the third result comes from the jobLogUrl promise
// exclude the json log URLs
const jobLogUrls = jobLogUrlResult.filter(
(log) => !log.name.endsWith('_json'),
);
let logParseStatus = 'unavailable';
// Provide a parse status as a scope variable for logviewer shortcut
if (jobLogUrls.length && jobLogUrls[0].parse_status) {
logParseStatus = jobLogUrls[0].parse_status;
}
const logViewerUrl = getLogViewerUrl(
selectedJob.id,
currentRepo.name,
);
const logViewerFullUrl = `${window.location.origin}${logViewerUrl}`;
const performanceData = Object.values(phSeriesResult).reduce(
(a, b) => [...a, ...b],
[],
);
let perfJobDetail = [];
if (performanceData.length) {
const signatureIds = [
...new Set(performanceData.map((perf) => perf.signature_id)),
];
const seriesListList = await Promise.all(
chunk(signatureIds, 20).map((signatureIdChunk) =>
PerfSeriesModel.getSeriesList(currentRepo.name, {
id: signatureIdChunk,
}),
),
);
const mappedFrameworks = {};
frameworks.forEach((element) => {
mappedFrameworks[element.id] = element.name;
});
const seriesList = seriesListList
.map((item) => item.data)
.reduce((a, b) => [...a, ...b], []);
perfJobDetail = performanceData
.map((d) => ({
series: seriesList.find((s) => d.signature_id === s.id),
...d,
}))
.map((d) => ({
url: `/perfherder/graphs?series=${[
currentRepo.name,
d.signature_id,
1,
d.series.frameworkId,
]}&selected=${[d.signature_id, d.id]}`,
shouldAlert: d.series.should_alert,
value: d.value,
measurementUnit: d.series.measurementUnit,
lowerIsBetter: d.series.lowerIsBetter,
title: d.series.name,
suite: d.series.suite,
options: d.series.options.join(' '),
frameworkName: mappedFrameworks[d.series.frameworkId],
perfdocs: new Perfdocs(
mappedFrameworks[d.series.frameworkId],
d.series.suite,
d.series.platform,
d.series.name,
),
}));
}
perfJobDetail.sort((a, b) => {
// Sort perfJobDetails by value of shouldAlert in a particular order:
// first true values, after that null values and then false.
if (a.shouldAlert === true) {
return -1;
}
if (a.shouldAlert === false) {
return 1;
}
if (a.shouldAlert === null && b.shouldAlert === true) {
return 1;
}
if (a.shouldAlert === null && b.shouldAlert === false) {
return -1;
}
return 0;
});
this.setState(
{
selectedJobFull,
jobLogUrls,
logParseStatus,
logViewerUrl,
logViewerFullUrl,
perfJobDetail,
jobRevision,
},
async () => {
await this.updateClassifications();
this.setState({ jobDetailLoading: false });
},
);
},
)
.finally(() => {
this.selectJobController = null;
});
},
);
};
render() {
const {
user,
currentRepo,
resizedHeight,
classificationMap,
classificationTypes,
isPinBoardVisible,
} = this.props;
const {
selectedJobFull,
jobDetails,
jobRevision,
jobLogUrls,
jobDetailLoading,
jobArtifactsLoading,
perfJobDetail,
suggestions,
errors,
bugSuggestionsLoading,
logParseStatus,
classifications,
logViewerUrl,
logViewerFullUrl,
bugs,
} = this.state;
const detailsPanelHeight = isPinBoardVisible
? resizedHeight - pinboardHeight
: resizedHeight;
return (
<div
id="details-panel"
style={{ height: `${detailsPanelHeight}px` }}
className={selectedJobFull ? 'details-panel-slide' : 'hidden'}
>
<PinBoard
currentRepo={currentRepo}
isLoggedIn={user.isLoggedIn || false}
isStaff={user.isStaff || false}
classificationTypes={classificationTypes}
selectedJobFull={selectedJobFull}
/>
{!!selectedJobFull && (
<div id="details-panel-content">
<SummaryPanel
selectedJobFull={selectedJobFull}
currentRepo={currentRepo}
classificationMap={classificationMap}
jobLogUrls={jobLogUrls}
logParseStatus={logParseStatus}
jobDetailLoading={jobDetailLoading}
latestClassification={
classifications.length
? classifications[classifications.length - 1]
: null
}
logViewerUrl={logViewerUrl}
logViewerFullUrl={logViewerFullUrl}
bugs={bugs}
user={user}
/>
<span className="job-tabs-divider" />
<TabsPanel
selectedJobFull={selectedJobFull}
currentRepo={currentRepo}
jobDetails={jobDetails}
jobArtifactsLoading={jobArtifactsLoading}
perfJobDetail={perfJobDetail}
repoName={currentRepo.name}
jobRevision={jobRevision}
suggestions={suggestions}
errors={errors}
bugSuggestionsLoading={bugSuggestionsLoading}
logParseStatus={logParseStatus}
classifications={classifications}
classificationMap={classificationMap}
jobLogUrls={jobLogUrls}
bugs={bugs}
togglePinBoardVisibility={() => this.togglePinBoardVisibility()}
logViewerFullUrl={logViewerFullUrl}
taskId={selectedJobFull.task_id}
rootUrl={currentRepo.tc_root_url}
/>
</div>
)}
<div id="clipboard-container">
<textarea id="clipboard" />
</div>
</div>
);
}
}
DetailsPanel.propTypes = {
currentRepo: PropTypes.shape({}).isRequired,
user: PropTypes.shape({}).isRequired,
resizedHeight: PropTypes.number.isRequired,
classificationTypes: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
classificationMap: PropTypes.shape({}).isRequired,
setPinBoardVisible: PropTypes.func.isRequired,
isPinBoardVisible: PropTypes.bool.isRequired,
pushList: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
selectedJob: PropTypes.shape({}),
};
DetailsPanel.defaultProps = {
selectedJob: null,
};
const mapStateToProps = ({
selectedJob: { selectedJob },
pushes: { pushList },
pinnedJobs: { isPinBoardVisible },
}) => ({ selectedJob, pushList, isPinBoardVisible });
export default connect(mapStateToProps, { setPinBoardVisible })(DetailsPanel);