treeherder/ui/perfherder/graphs/GraphsView.jsx

562 строки
16 KiB
JavaScript

import React from 'react';
import PropTypes from 'prop-types';
import { Button, Container, Col, Row } from 'reactstrap';
import unionBy from 'lodash/unionBy';
import queryString from 'query-string';
import { getData, processResponse, processErrors } from '../../helpers/http';
import {
createApiUrl,
createQueryParams,
getApiUrl,
parseQueryParams,
updateQueryParams,
} from '../../helpers/url';
import {
genericErrorMessage,
errorMessageClass,
} from '../../helpers/constants';
import { processSelectedParam, createGraphData } from '../perf-helpers/helpers';
import {
alertSummaryLimit,
endpoints,
graphColors,
graphSymbols,
phTimeRanges,
phDefaultTimeRangeValue,
} from '../perf-helpers/constants';
import ErrorMessages from '../../shared/ErrorMessages';
import ErrorBoundary from '../../shared/ErrorBoundary';
import LoadingSpinner from '../../shared/LoadingSpinner';
import LegendCard from './LegendCard';
import GraphsViewControls from './GraphsViewControls';
class GraphsView extends React.Component {
constructor(props) {
super(props);
this.state = {
timeRange: this.getDefaultTimeRange(),
zoom: {},
selectedDataPoint: null,
highlightAlerts: true,
highlightCommonAlerts: false,
highlightChangelogData: true,
highlightedRevisions: ['', ''],
testData: [],
errorMessages: [],
options: {},
loading: false,
colors: [...graphColors],
symbols: [...graphSymbols],
showModal: false,
showTable: false,
visibilityChanged: false,
replicates: false,
};
}
async componentDidMount() {
this.checkQueryParams();
}
componentDidUpdate(prevProps) {
const { location } = this.props;
const { testData, loading } = this.state;
const { replicates } = queryString.parse(this.props.location.search);
const { replicates: prevReplicates } = queryString.parse(
prevProps.location.search,
);
if (
location.search === '' &&
testData.length !== 0 &&
loading !== true &&
location.search !== prevProps.location.search
) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
testData: [],
});
}
if (prevReplicates !== undefined) {
if (replicates !== prevReplicates) {
window.location.reload(false);
}
}
}
getDefaultTimeRange = () => {
const { location } = this.props;
const { timerange } = parseQueryParams(location.search);
const defaultValue = timerange
? parseInt(timerange, 10)
: phDefaultTimeRangeValue;
return phTimeRanges.find((time) => time.value === defaultValue);
};
checkQueryParams = () => {
const {
series,
zoom,
selected,
highlightAlerts,
highlightCommonAlerts,
highlightChangelogData,
highlightedRevisions,
replicates,
} = queryString.parse(this.props.location.search);
const updates = {};
if (series) {
// TODO: Move series/test data fetch to after the params are parsed, and
// the component is updated. Here, it's using default settings even if
// the parameters are different.
const _series = typeof series === 'string' ? [series] : series;
const seriesParams = this.parseSeriesParam(
_series,
Boolean(parseInt(replicates, 10)),
);
this.getTestData(seriesParams, true);
}
if (highlightAlerts) {
updates.highlightAlerts = Boolean(parseInt(highlightAlerts, 10));
}
if (highlightCommonAlerts) {
updates.highlightCommonAlerts = Boolean(
parseInt(highlightCommonAlerts, 10),
);
}
if (highlightChangelogData) {
updates.highlightChangelogData = Boolean(
parseInt(highlightChangelogData, 10),
);
}
if (replicates) {
updates.replicates = Boolean(parseInt(replicates, 10));
}
if (highlightedRevisions) {
updates.highlightedRevisions =
typeof highlightedRevisions === 'string'
? [highlightedRevisions]
: highlightedRevisions;
}
if (zoom) {
const zoomArray = zoom.replace(/[[{}\]"]+/g, '').split(',');
const zoomObject = {
x: zoomArray.map((x) => new Date(parseInt(x, 10))).slice(0, 2),
y: zoomArray.slice(2, 4),
};
updates.zoom = zoomObject;
}
if (selected) {
const tooltipArray = selected.replace(/[[]"]/g, '').split(',');
const tooltipValues = processSelectedParam(tooltipArray);
updates.selectedDataPoint = tooltipValues;
}
this.setState(updates);
};
createSeriesParams = (series) => {
const {
repository_name: repositoryName,
signature_id: signatureId,
framework_id: frameworkId,
replicates,
} = series;
const { timeRange } = this.state;
return {
repository: repositoryName,
signature: signatureId,
framework: frameworkId,
interval: timeRange.value,
all_data: true,
replicates,
};
};
getTestData = async (newDisplayedTests = [], init = false) => {
const { testData } = this.state;
const tests = newDisplayedTests.length ? newDisplayedTests : testData;
this.setState({ loading: true });
const responses = await Promise.all(
tests.map((series) =>
getData(
createApiUrl(endpoints.summary, this.createSeriesParams(series)),
),
),
);
const errorMessages = processErrors(responses);
if (errorMessages.length) {
this.setState({ errorMessages, loading: false });
} else {
// If the server returns an empty array instead of signature data with data: [],
// that test won't be shown in the graph or legend; this will prevent the UI from breaking
const data = responses
.filter((response) => response.data.length)
.map((reponse) => reponse.data[0]);
let newTestData = await this.createGraphObject(data);
if (newDisplayedTests.length) {
newTestData = [...testData, ...newTestData];
}
this.setState(
{ testData: newTestData, loading: false, visibilityChanged: false },
() => {
if (!init) {
// we don't need to change params when getData is called on initial page load
this.changeParams();
}
},
);
}
};
createGraphObject = async (seriesData) => {
const { colors, symbols, timeRange, replicates } = this.state;
const alertSummaries = await Promise.all(
seriesData.map((series) =>
this.getAlertSummaries(series.signature_id, series.repository_id),
),
);
const commonAlerts = await Promise.all(
seriesData.map((series) =>
this.getCommonAlerts(series.framework_id, timeRange.value),
),
);
const newColors = [...colors];
const newSymbols = [...symbols];
const graphData = createGraphData(
seriesData,
alertSummaries.flat(),
newColors,
newSymbols,
commonAlerts,
replicates,
);
this.setState({ colors: newColors, symbols: newSymbols });
return graphData;
};
getAlertSummaries = async (signatureId, repository) => {
const { errorMessages, timeRange } = this.state;
const data = await getData(
createApiUrl(endpoints.alertSummary, {
alerts__series_signature: signatureId,
repository,
limit: alertSummaryLimit,
timerange: timeRange.value,
}),
);
const response = processResponse(data, 'alertSummaries', errorMessages);
if (response.alertSummaries) {
return response.alertSummaries.results;
}
this.setState({ errorMessages: response.errorMessages });
return [];
};
getCommonAlerts = async (frameworkId, timeRange) => {
const params = {
framework: frameworkId,
limit: alertSummaryLimit,
timerange: timeRange,
};
const url = getApiUrl(
`${endpoints.alertSummary}${createQueryParams(params)}`,
);
const response = await getData(url);
const commonAlerts = [...response.data.results];
return commonAlerts;
};
updateData = async (
signatureId,
repositoryName,
alertSummaryId,
dataPointIndex,
) => {
const { testData } = this.state;
const updatedData = testData.find(
(test) => test.signature_id === signatureId,
);
const alertSummaries = await this.getAlertSummaries(
signatureId,
repositoryName,
);
const alertSummary = alertSummaries.find(
(result) => result.id === alertSummaryId,
);
updatedData.data[dataPointIndex].alertSummary = alertSummary;
const newTestData = unionBy([updatedData], testData, 'signature_id');
this.setState({ testData: newTestData });
};
parseSeriesParam = (series, replicates) =>
series.map((encodedSeries) => {
const partialSeriesArray = encodedSeries.split(',');
const partialSeriesObject = {
repository_name: partialSeriesArray[0],
// TODO deprecate signature_hash
signature_id:
partialSeriesArray[1] && partialSeriesArray[1].length === 40
? partialSeriesArray[1]
: parseInt(partialSeriesArray[1], 10),
// TODO partialSeriesArray[2] is for the 1 that's inserted in the url
// for visibility of test legend cards but isn't actually being used
// to control visibility so it should be removed at some point
framework_id: parseInt(partialSeriesArray[3], 10),
replicates,
};
return partialSeriesObject;
});
toggle = (state) => {
this.setState((prevState) => ({
[state]: !prevState[state],
}));
};
updateParams = (params) => {
const { location, history } = this.props;
let newQueryString = queryString.stringify(params);
newQueryString = newQueryString.replace(/%2C/g, ',');
updateQueryParams(newQueryString, history, location);
};
changeParams = () => {
const {
testData,
selectedDataPoint,
zoom,
highlightAlerts,
highlightCommonAlerts,
highlightChangelogData,
highlightedRevisions,
timeRange,
replicates,
} = this.state;
const newSeries = testData.map(
(series) =>
`${series.repository_name},${series.signature_id},1,${series.framework_id}`,
);
const params = {
series: newSeries,
highlightAlerts: +highlightAlerts,
highlightCommonAlerts: +highlightCommonAlerts,
highlightChangelogData: +highlightChangelogData,
timerange: timeRange.value,
replicates: +replicates,
zoom,
};
const newHighlightedRevisions = highlightedRevisions.filter(
(rev) => rev.length,
);
if (newHighlightedRevisions.length) {
params.highlightedRevisions = newHighlightedRevisions;
}
if (!selectedDataPoint) {
delete params.selected;
} else {
const { signature_id: signatureId, dataPointId } = selectedDataPoint;
params.selected = [signatureId, dataPointId].join(',');
}
if (Object.keys(zoom).length === 0) {
delete params.zoom;
} else {
params.zoom = [...zoom.x.map((z) => z.getTime()), ...zoom.y].toString();
}
this.updateParams(params);
};
render() {
const {
timeRange,
testData,
highlightAlerts,
highlightCommonAlerts,
highlightChangelogData,
highlightedRevisions,
selectedDataPoint,
loading,
errorMessages,
zoom,
options,
colors,
symbols,
showModal,
showTable,
visibilityChanged,
replicates,
} = this.state;
const { projects, frameworks, user } = this.props;
return (
<ErrorBoundary
errorClasses={errorMessageClass}
message={genericErrorMessage}
>
<Container fluid className="pt-5 pr-5 pl-5">
{loading && <LoadingSpinner />}
{errorMessages.length > 0 && (
<Container className="pb-4 px-0 max-width-default">
<ErrorMessages errorMessages={errorMessages} />
</Container>
)}
<Row className="justify-content-center">
{!showTable && (
<Col
className={`${testData.length ? 'graph-chooser' : 'col-12'}`}
>
<Button
className="sr-only"
onClick={() => this.setState({ showTable: !showTable })}
>
Table View
</Button>
<Container
role="region"
aria-label="Graph Legend"
className="graph-legend pl-0 pb-4"
>
{testData.length > 0 &&
testData.map((series) => (
<div
key={`${series.name} ${series.repository_name} ${series.platform}`}
>
<LegendCard
series={series}
testData={testData}
{...this.props}
updateState={(state) => this.setState(state)}
updateStateParams={(state) =>
this.setState(state, this.changeParams)
}
colors={colors}
symbols={symbols}
selectedDataPoint={selectedDataPoint}
/>
</div>
))}
</Container>
</Col>
)}
<Col
className={`pl-0 ${
testData.length ? 'custom-col-xxl-auto' : 'col-auto'
} ${showTable && 'w-100'}`}
>
<GraphsViewControls
colors={colors}
symbols={symbols}
timeRange={timeRange}
frameworks={frameworks}
user={user}
projects={projects}
options={options}
getTestData={this.getTestData}
testData={testData}
showModal={showModal}
showTable={showTable}
highlightAlerts={highlightAlerts}
highlightChangelogData={highlightChangelogData}
highlightedRevisions={highlightedRevisions}
highlightCommonAlerts={highlightCommonAlerts}
zoom={zoom}
selectedDataPoint={selectedDataPoint}
updateStateParams={(state) =>
this.setState(state, this.changeParams)
}
visibilityChanged={visibilityChanged}
updateData={this.updateData}
toggle={() => this.setState({ showModal: !showModal })}
toggleTableView={() => this.setState({ showTable: !showTable })}
replicates={replicates}
updateTimeRange={(newTimeRange) =>
this.setState(
{
timeRange: newTimeRange,
zoom: {},
selectedDataPoint: null,
colors: [...graphColors],
symbols: [...graphSymbols],
},
this.getTestData,
)
}
updateTestsAndTimeRange={(newDisplayedTests, newTimeRange) =>
this.setState(
{
timeRange: newTimeRange,
zoom: {},
selectedDataPoint: null,
colors: [...graphColors],
symbols: [...graphSymbols],
},
() => this.getTestData(newDisplayedTests),
)
}
hasNoData={!testData.length && !loading}
/>
</Col>
</Row>
</Container>
</ErrorBoundary>
);
}
}
GraphsView.propTypes = {
location: PropTypes.shape({
zoom: PropTypes.string,
selected: PropTypes.string,
highlightAlerts: PropTypes.string,
highlightedRevisions: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
series: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
}),
};
GraphsView.defaultProps = {
location: undefined,
};
export default GraphsView;