зеркало из https://github.com/mozilla/treeherder.git
562 строки
16 KiB
JavaScript
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;
|