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 ( {loading && } {errorMessages.length > 0 && ( )} {!showTable && ( {testData.length > 0 && testData.map((series) => (
this.setState(state)} updateStateParams={(state) => this.setState(state, this.changeParams) } colors={colors} symbols={symbols} selectedDataPoint={selectedDataPoint} />
))}
)} 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} />
); } } 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;