diff --git a/.neutrinorc.js b/.neutrinorc.js index 657942437..56be410c1 100644 --- a/.neutrinorc.js +++ b/.neutrinorc.js @@ -150,7 +150,7 @@ module.exports = { neutrino.config.performance .hints('error') .maxAssetSize(2 * 1024 * 1024) - .maxEntrypointSize(2.1 * 1024 * 1024); + .maxEntrypointSize(2.2 * 1024 * 1024); } }, ], diff --git a/package.json b/package.json index 1c88f0d25..056684b0d 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,8 @@ "taskcluster-client-web": "8.1.1", "taskcluster-lib-scopes": "10.0.2", "webpack": "4.39.3", - "webpack-cli": "3.3.8" + "webpack-cli": "3.3.8", + "victory": "32.3.3" }, "devDependencies": { "@neutrinojs/eslint": "9.0.0-rc.3", diff --git a/tests/selenium/pages/perfherder.py b/tests/selenium/pages/perfherder.py index 43a69af6a..04d3bf7d3 100644 --- a/tests/selenium/pages/perfherder.py +++ b/tests/selenium/pages/perfherder.py @@ -1,27 +1,12 @@ from pypom import Region from selenium.webdriver.common.by import By -from selenium.webdriver.support.select import Select -from .base import (Base, - Modal) +from .base import Base class Perfherder(Base): URL_TEMPLATE = '/perf.html' - _add_test_data_locator = (By.ID, 'add-test-data-button') - _test_series_locator = (By.CSS_SELECTOR, 'tr[ng-repeat*="series"]') - - @property - def loaded(self): - return self.is_element_displayed(By.CSS_SELECTOR, '#graph canvas') - - def add_test_data(self): - self.find_element(*self._add_test_data_locator).click() - self.wait.until(lambda _: self.PerformanceTestChooserModal(self).is_displayed) - - return self.PerformanceTestChooserModal(self) - @property def tool_tip(self): return self.GraphTooltip(self) @@ -31,59 +16,6 @@ class Perfherder(Base): from pages.treeherder import Treeherder return Treeherder(self.driver, self.base_url).wait_for_page_to_load() - def series_list(self): - return [self.Series(self, element) - for element in self.find_elements(*self._test_series_locator)] - - class PerformanceTestChooserModal(Modal): - _selct_frammework_locator = (By.CSS_SELECTOR, 'select[ng-model="selectedFramework"]') - _selct_project_locator = (By.CSS_SELECTOR, 'select[ng-model="selectedProject"]') - _selct_platform_locator = (By.CSS_SELECTOR, 'select[ng-model="selectedPlatform"]') - _selct_test_signature_locator = (By.CSS_SELECTOR, 'select[ng-model="selectedTestSignatures"]') - _select_test_to_add_locator = (By.CSS_SELECTOR, 'select[ng-model="selectedTestsToAdd"]') - - _select_test_button_locator = (By.ID, 'select-test') - _add_button_locator = (By.CSS_SELECTOR, 'div.modal-footer > button') - - def select_test(self, perf_datum): - select_frammework = Select(self.find_element(*self._selct_frammework_locator)) - select_frammework.select_by_visible_text(perf_datum.signature.framework.name) - - select_project = Select(self.find_element(*self._selct_project_locator)) - select_project.select_by_visible_text(perf_datum.repository.name) - - select_platform = Select(self.find_element(*self._selct_platform_locator)) - select_platform.select_by_visible_text(perf_datum.signature.platform.platform) - - select_test_signature = Select(self.find_element(*self._selct_test_signature_locator)) - select_test_signature.select_by_value(perf_datum.signature.signature_hash) - - self.find_element(*self._select_test_button_locator).click() - - self.find_element(*self._add_button_locator).click() - - class Series(Region): - _signature_locator = (By.CSS_SELECTOR, 'div.signature') - _test_name_locator = (By.ID, 'test-name') - _project_name_locator = (By.ID, 'project-name') - _platform_locator = (By.ID, 'platform') - - @property - def signature_text(self): - return self.find_element(*self._signature_locator).text - - @property - def test_name_text(self): - return self.find_element(*self._test_name_locator).text - - @property - def project_name_text(self): - return self.find_element(*self._project_name_locator).text - - @property - def platform_text(self): - return self.find_element(*self._platform_locator).text - class GraphTooltip(Region): _root_locator = (By.ID, "graph-tooltip") _series_locator = (By.ID, "tt-series") diff --git a/tests/selenium/test_perfherder.py b/tests/selenium/test_perfherder.py index fda41c5d1..8a6e4352b 100644 --- a/tests/selenium/test_perfherder.py +++ b/tests/selenium/test_perfherder.py @@ -3,39 +3,40 @@ import pytest from pages.perfherder import Perfherder +@pytest.mark.skip(reason="Needs to be updated to work with react or replaced with react-testing-library") def test_add_test_data(base_url, selenium): '''This tests that we can click the add test data button''' page = Perfherder(selenium, base_url).open() page.add_test_data() - # FIXME: Add more coverage. -# TODO update to work with TestDataModal component -# def test_load_test_data(base_url, selenium, test_perf_data): -# """ -# Test that user is able to select test data from the "test chooser" and -# the correct test data is displayed -# """ -# test_data = test_perf_data.first() +@pytest.mark.skip(reason="Needs to be updated to work with react or replaced with react-testing-library") +def test_load_test_data(base_url, selenium, test_perf_data): + """ + Test that user is able to select test data from the "test chooser" and + the correct test data is displayed + """ -# perf_page = Perfherder(selenium, base_url).open() -# select_test_modal = perf_page.add_test_data() + test_data = test_perf_data.first() -# select_test_modal.select_test(test_data) + perf_page = Perfherder(selenium, base_url).open() + select_test_modal = perf_page.add_test_data() -# # We expect to see a signature in our series list to the side after selecting -# # it in the chooser -# test_signatures = perf_page.series_list() + select_test_modal.select_test(test_data) -# assert len(test_signatures) == 1 -# assert test_signatures[0].test_name_text == "%s %s %s %s" % (test_data.signature.suite, -# test_data.signature.test, -# test_data.signature.extra_options.split(' ')[1], -# test_data.signature.extra_options.split(' ')[0]) + # We expect to see a signature in our series list to the side after selecting + # it in the chooser + test_signatures = perf_page.series_list() -# assert test_signatures[0].project_name_text == test_data.repository.name -# assert test_signatures[0].platform_text == test_data.signature.platform.platform -# assert test_signatures[0].signature_text == test_data.signature.signature_hash + assert len(test_signatures) == 1 + assert test_signatures[0].test_name_text == "%s %s %s %s" % (test_data.signature.suite, + test_data.signature.test, + test_data.signature.extra_options.split(' ')[1], + test_data.signature.extra_options.split(' ')[0]) + + assert test_signatures[0].project_name_text == test_data.repository.name + assert test_signatures[0].platform_text == test_data.signature.platform.platform + assert test_signatures[0].signature_text == test_data.signature.signature_hash @pytest.mark.skip(reason="Test started failing after updating mozlog, but still fails after revert.") diff --git a/tests/ui/mock/performance_signature_formatted.json b/tests/ui/mock/performance_signature_formatted.json new file mode 100644 index 000000000..6b958849e --- /dev/null +++ b/tests/ui/mock/performance_signature_formatted.json @@ -0,0 +1,47 @@ +[ + { + "id": 1647494, + "name": "a11yr opt e10s stylo", + "testName": "a11yr", + "suite": "a11yr", + "test": null, + "signature": "fcefb979eac44d057f9c05434580ce7845f4c2d6", + "hasSubtests": true, + "parentSignature": null, + "projectName": "mozilla-central", + "platform": "linux64", + "options": ["opt", "e10s", "stylo"], + "frameworkId": 1, + "lowerIsBetter": true + }, + { + "id": 1660552, + "name": "about_preferences_basic opt e10s stylo", + "testName": "about_preferences_basic", + "suite": "about_preferences_basic", + "test": null, + "signature": "9db5c2909418068b3004ea3911675ee3e6e5f6da", + "hasSubtests": true, + "parentSignature": null, + "projectName": "mozilla-central", + "platform": "linux64", + "options": ["opt", "e10s", "stylo"], + "frameworkId": 1, + "lowerIsBetter": true + }, + { + "id": 1648571, + "name": "basic_compositor_video opt e10s stylo", + "testName": "basic_compositor_video", + "suite": "basic_compositor_video", + "test": null, + "signature": "aaf7d0091b502d1596c9791a47cb25efeda8a5ff", + "hasSubtests": true, + "parentSignature": null, + "projectName": "mozilla-central", + "platform": "linux64", + "options": ["opt", "e10s", "stylo"], + "frameworkId": 1, + "lowerIsBetter": true + } +] diff --git a/tests/ui/mock/performance_signature_formatted2.json b/tests/ui/mock/performance_signature_formatted2.json new file mode 100644 index 000000000..6f4fd4fa3 --- /dev/null +++ b/tests/ui/mock/performance_signature_formatted2.json @@ -0,0 +1,47 @@ +[ + { + "id": 1538924, + "name": "a11yr opt e10s stylo", + "testName": "a11yr", + "suite": "a11yr", + "test": null, + "signature": "0195bad4939abd449c7df2e378d2d48ddd44b850", + "hasSubtests": true, + "parentSignature": null, + "projectName": "mozilla-central", + "platform": "windows7-32", + "options": ["opt", "e10s", "stylo"], + "frameworkId": 1, + "lowerIsBetter": true + }, + { + "id": 1660572, + "name": "about_preferences_basic opt e10s stylo", + "testName": "about_preferences_basic", + "suite": "about_preferences_basic", + "test": null, + "signature": "d8a13b9a6582e86e134cc8394e133d662821edab", + "hasSubtests": true, + "parentSignature": null, + "projectName": "mozilla-central", + "platform": "windows7-32", + "options": ["opt", "e10s", "stylo"], + "frameworkId": 1, + "lowerIsBetter": true + }, + { + "id": 1538908, + "name": "basic_compositor_video opt e10s stylo", + "testName": "basic_compositor_video", + "suite": "basic_compositor_video", + "test": null, + "signature": "4627f0409136a69d4f10c0615b7b34a62bfbdb38", + "hasSubtests": true, + "parentSignature": null, + "projectName": "mozilla-central", + "platform": "windows7-32", + "options": ["opt", "e10s", "stylo"], + "frameworkId": 1, + "lowerIsBetter": true + } +] diff --git a/tests/ui/mock/performance_summary.json b/tests/ui/mock/performance_summary.json new file mode 100644 index 000000000..626e10485 --- /dev/null +++ b/tests/ui/mock/performance_summary.json @@ -0,0 +1,60 @@ +[ + { + "signature_id": 1647494, + "framework_id": 1, + "signature_hash": "fcefb979eac44d057f9c05434580ce7845f4c2d6", + "platform": "linux64", + "test": "", + "suite": "a11yr", + "lower_is_better": true, + "has_subtests": true, + "values": [], + "name": "a11yr opt e10s stylo", + "parent_signature": null, + "job_ids": [], + "repository_name": "mozilla-central", + "repository_id": 1, + "data": [ + { + "job_id": 260889536, + "id": 887236036, + "value": 212.40805982905135, + "push_timestamp": "2019-08-09T21:56:59", + "push_id": 530260, + "revision": "2909b0a1eb06cc34ce0a11544e5e6826aba87c06" + }, + { + "job_id": 260895760, + "id": 887279300, + "value": 211.24042970178886, + "push_timestamp": "2019-08-09T21:57:48", + "push_id": 530261, + "revision": "3afb892abb74c6d281f3e66431408cbb2e16b8c4" + }, + { + "job_id": 260976940, + "id": 887616827, + "value": 211.97006831947064, + "push_timestamp": "2019-08-10T21:28:45", + "push_id": 530471, + "revision": "462d2d4ba0516adc69de20372b16931cef20de9e" + }, + { + "job_id": 260977399, + "id": 887619689, + "value": 214.94212187528396, + "push_timestamp": "2019-08-10T21:31:07", + "push_id": 530472, + "revision": "c53f789ffabb03c04b1b20252aea9331301bfa02" + }, + { + "job_id": 261015918, + "id": 887831038, + "value": 215.0925727552894, + "push_timestamp": "2019-08-11T09:56:40", + "push_id": 530521, + "revision": "e8fe8b0af1a7a0c64d28b4e08a9c5509d916759f" + } + ] + } +] diff --git a/tests/ui/perfherder/alerts_test.jsx b/tests/ui/perfherder/alerts_test.jsx index 41b470388..99ccc2682 100644 --- a/tests/ui/perfherder/alerts_test.jsx +++ b/tests/ui/perfherder/alerts_test.jsx @@ -155,9 +155,9 @@ const testAlertSummaries = [ summary_id: 20239, related_summary_id: null, manually_created: false, - classifier: 'mozilla-ldap/sclements@mozilla.com', + classifier: 'mozilla-ldap/user@mozilla.com', starred: false, - classifier_email: 'sclements@mozilla.com', + classifier_email: 'user@mozilla.com', }, ], related_alerts: [], @@ -428,6 +428,4 @@ test('selecting the alert summary checkbox then deselecting one alert only updat }); // TODO should write tests for alert summary dropdown menu actions performed in StatusDropdown -// (adding notes or marking as 'fixed', etc), however there was difficulty trying to simulate -// user actions of clicking the dropdown menu and then a dropdown item, due to a JSDOM/popper.js -// issue used (reactstrap uses popper.js): https://github.com/FezVrasta/popper.js/issues/478 +// (adding notes or marking as 'fixed', etc) diff --git a/tests/ui/perfherder/graphs_view_test.jsx b/tests/ui/perfherder/graphs_view_test.jsx new file mode 100644 index 000000000..ca8ef9e52 --- /dev/null +++ b/tests/ui/perfherder/graphs_view_test.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { + render, + cleanup, + fireEvent, + waitForElement, +} from '@testing-library/react'; + +import GraphsViewControls from '../../../ui/perfherder/graphs/GraphsViewControls'; +import repos from '../mock/repositories'; +import testData from '../mock/performance_summary.json'; +import seriesData from '../mock/performance_signature_formatted.json'; +import seriesData2 from '../mock/performance_signature_formatted2.json'; + +const frameworks = [{ id: 1, name: 'talos' }, { id: 2, name: 'build_metrics' }]; +const platforms = ['linux64', 'windows10-64', 'windows7-32']; + +const updates = { + filteredData: [], + loading: false, + relatedTests: [], + seriesData, + showNoRelatedTests: false, +}; +const updates2 = { ...updates }; +updates2.seriesData = seriesData2; + +const mockGetSeriesData = jest + .fn() + .mockResolvedValueOnce(updates) + .mockResolvedValueOnce(updates2) + .mockResolvedValue(updates); + +const mockShowModal = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + +const graphsViewControls = () => + render( + {}} + graphs={false} + highlightAlerts={false} + highlightedRevisions={['', '']} + updateTimeRange={() => {}} + hasNoData + frameworks={frameworks} + projects={repos} + timeRange={{ value: 172800, text: 'Last two days' }} + options={{}} + getTestData={() => {}} + testData={testData} + getInitialData={() => ({ + platforms, + })} + getSeriesData={mockGetSeriesData} + showModal={Boolean(mockShowModal)} + toggle={mockShowModal} + />, + ); + +afterEach(cleanup); + +test('Changing the platform dropdown in the Test Data Model displays expected tests', async () => { + const { getByText, queryByTestId, getByTitle } = graphsViewControls(); + + fireEvent.click(getByText('Add test data')); + + const platform = getByTitle('Platform'); + fireEvent.click(platform); + + const windowsPlatform = await waitForElement(() => getByText('windows7-32')); + fireEvent.click(windowsPlatform); + + // 'mozilla-central windows7-32 a11yr opt e10s stylo' + const existingTest = await waitForElement(() => + queryByTestId(seriesData2[0].id.toString()), + ); + expect(existingTest).toBeInTheDocument(); + expect(mockShowModal.mock.calls).toHaveLength(1); + mockShowModal.mockClear(); +}); + +test('Tests section in Test Data Modal only shows tests not already displayed in graph', async () => { + const { getByText, queryByTestId, getByLabelText } = graphsViewControls(); + + fireEvent.click(getByText('Add test data')); + + const testDataModal = getByText('Add Test Data'); + expect(testDataModal).toBeInTheDocument(); + + // this test is already displayed (testData prop) in the legend and graph + const existingTest = queryByTestId(testData[0].signature_id.toString()); + expect(existingTest).not.toBeInTheDocument(); + + fireEvent.click(getByLabelText('Close')); + expect(mockShowModal.mock.calls).toHaveLength(2); + + mockShowModal.mockClear(); +}); + +test('Selecting a test in the Test Data Modal adds it to Selected Tests section; deselecting a test from Selected Tests removes it', async () => { + const { getByText, getByTestId } = graphsViewControls(); + + fireEvent.click(getByText('Add test data')); + + const selectedTests = getByTestId('selectedTests'); + + const testToSelect = await waitForElement(() => + getByText('about_preferences_basic opt e10s stylo'), + ); + fireEvent.click(testToSelect); + + const fullTestToSelect = await waitForElement(() => + getByText('mozilla-central linux64 about_preferences_basic opt e10s stylo'), + ); + fireEvent.click(fullTestToSelect); + expect(mockShowModal.mock.calls).toHaveLength(1); + expect(selectedTests).not.toContain(fullTestToSelect); +}); diff --git a/tests/webapp/api/test_performance_data_api.py b/tests/webapp/api/test_performance_data_api.py index 465566077..80408a23c 100644 --- a/tests/webapp/api/test_performance_data_api.py +++ b/tests/webapp/api/test_performance_data_api.py @@ -400,6 +400,7 @@ def test_perf_summary(client, test_perf_signature, test_perf_data): 'suite': test_perf_signature.suite, 'repository_name': test_perf_signature.repository.name, 'repository_id': test_perf_signature.repository.id, + 'data': [] }] resp1 = client.get(reverse('performance-summary') + query_params1) diff --git a/treeherder/webapp/api/performance_data.py b/treeherder/webapp/api/performance_data.py index b5b9ae768..c80739821 100644 --- a/treeherder/webapp/api/performance_data.py +++ b/treeherder/webapp/api/performance_data.py @@ -427,8 +427,11 @@ class PerformanceSummary(generics.ListAPIView): .select_related('framework', 'repository', 'platform', 'push', 'job') .filter(repository__name=repository_name)) - if len(signature): - signature_data = signature_data.filter(id__in=list(signature)) + # TODO deprecate signature hash support + if signature and len(signature) == 40: + signature_data = signature_data.filter(signature_hash=signature) + elif signature: + signature_data = signature_data.filter(id=signature) else: signature_data = signature_data.filter(parent_signature__isnull=no_subtests) @@ -438,7 +441,9 @@ class PerformanceSummary(generics.ListAPIView): if parent_signature: signature_data = signature_data.filter(parent_signature_id=parent_signature) - if interval: + # we do this so all relevant signature data is returned even if there isn't performance data + # and it's also not needed since this param is used to filter directly on signature_id + if interval and not all_data: signature_data = signature_data.filter(last_updated__gte=datetime.datetime.utcfromtimestamp( int(time.time() - int(interval)))) diff --git a/treeherder/webapp/api/performance_serializers.py b/treeherder/webapp/api/performance_serializers.py index a730efec4..e54605084 100644 --- a/treeherder/webapp/api/performance_serializers.py +++ b/treeherder/webapp/api/performance_serializers.py @@ -198,7 +198,7 @@ class PerformanceQueryParamsSerializer(serializers.Serializer): framework = serializers.ListField(required=False, child=serializers.IntegerField(), default=[]) interval = serializers.IntegerField(required=False, allow_null=True, default=None) parent_signature = serializers.CharField(required=False, allow_null=True, default=None) - signature = serializers.ListField(child=serializers.IntegerField(), required=False, allow_null=True, default=[]) + signature = serializers.CharField(required=False, allow_null=True, default=None) no_subtests = serializers.BooleanField(required=False) all_data = serializers.BooleanField(required=False, default=False) @@ -236,7 +236,7 @@ class PerformanceSummarySerializer(serializers.ModelSerializer): parent_signature = serializers.IntegerField(source="parent_signature_id") signature_id = serializers.IntegerField(source="id") job_ids = serializers.ListField(child=serializers.IntegerField(), default=[]) - data = PerformanceDatumSerializer(read_only=True, many=True) + data = PerformanceDatumSerializer(read_only=True, many=True, default=[]) repository_name = serializers.CharField() class Meta: diff --git a/ui/css/perf.css b/ui/css/perf.css index f2cbd5f73..c15c1542f 100644 --- a/ui/css/perf.css +++ b/ui/css/perf.css @@ -27,11 +27,6 @@ h1 { } } -.ph-horizontal-layout { - display: flex; - flex-flow: row; -} - #perf-logo { padding: 15px; border: none; @@ -39,23 +34,12 @@ h1 { background: #f8f8f8; } -#graph-chooser { +.graph-chooser { flex: none; width: 300px; padding: 8px; } -#data-display { - flex: auto; -} - -#graph { - width: 100%; - height: 300px; - margin-bottom: 30px; - cursor: crosshair; -} - .graph-legend { width: 275px; } @@ -85,54 +69,54 @@ h1 { width: 120px; } -#graph-tooltip { +.graph-tooltip { position: absolute; - visibility: hidden; pointer-events: none; width: 280px; + z-index: 999; } -#graph-tooltip.locked { +.graph-tooltip.locked { pointer-events: auto; } -#graph-tooltip .body { +.graph-tooltip .body { display: block; background: rgba(0, 0, 0, 0.75); color: #fff; border-radius: 5px; padding: 10px 15px; } -#graph-tooltip.locked .body { +.graph-tooltip.locked .body { background: rgba(0, 0, 0, 0.9); } -#graph-tooltip .body div { +.graph-tooltip .body div { margin-top: 0.5em; } -#graph-tooltip .body div:first-child { +.graph-tooltip .body div:first-child { margin-top: 0.5em; } -#graph-tooltip .body p { +.graph-tooltip .body p { margin: 0; } -#graph-tooltip .body p.small { +.graph-tooltip .body p.small { font-size: 10px; color: #c0c0c0; } -#graph-tooltip .body a { - color: #c9e2f2; +.graph-tooltip .body a { + color: #17a2b8; } -#graph-tooltip .tip { +.graph-tooltip .tip { display: block; height: 10px; background: url('../img/tip.png') no-repeat center top; } -#graph-tooltip.locked .tip { +.graph-tooltip.locked .tip { background-image: url('../img/tip-locked.png'); } .graphchooser-close { color: #fff; outline: none; - margin: -14px -4px; + z-index: 999; } .graphchooser-close:hover { @@ -146,12 +130,11 @@ h1 { cursor: pointer; } -#overview-plot { +.overview-plot { margin-top: 5px; margin-bottom: 10px; - width: 100%; - height: 100px; - cursor: crosshair; + width: 1200px; + height: 150px; } .subtest-header th { @@ -405,8 +388,8 @@ button:disabled { } li.pagination-active.active > button { - background-color: lightgray !Important; - border-color: lightgray !Important; + background-color: lightgray !important; + border-color: lightgray !important; } /* graph colors */ @@ -437,3 +420,11 @@ li.pagination-active.active > button { .brown { border-left-color: #b87e17; } + +@media only screen and (min-width: 1700px) { + .custom-col-xxl-auto { + flex: 0 0 auto; + width: auto; + max-width: 100%; + } +} diff --git a/ui/css/treeherder-global.css b/ui/css/treeherder-global.css index 52a3c49ed..80f01b39d 100755 --- a/ui/css/treeherder-global.css +++ b/ui/css/treeherder-global.css @@ -55,6 +55,10 @@ a { visibility: hidden; } +.show { + visibility: visible; +} + /* Similar Jobs panel */ .checkbox { min-height: 20px; diff --git a/ui/css/treeherder-loading-overlay.css b/ui/css/treeherder-loading-overlay.css index 9e5fd4b74..dd0fc4cbd 100644 --- a/ui/css/treeherder-loading-overlay.css +++ b/ui/css/treeherder-loading-overlay.css @@ -1,7 +1,3 @@ -#loading-symbol { - position: relative; /* So we can absolutely position the loading overlay */ -} - .overlay { position: absolute; top: 0; diff --git a/ui/entry-perf.js b/ui/entry-perf.js index 77c8dad54..27cca2b9d 100644 --- a/ui/entry-perf.js +++ b/ui/entry-perf.js @@ -7,30 +7,11 @@ import 'bootstrap/dist/css/bootstrap.min.css'; // Vendor JS import 'bootstrap'; import { library, dom, config } from '@fortawesome/fontawesome-svg-core'; +import { faFileCode, faFileWord } from '@fortawesome/free-regular-svg-icons'; import { - faArrowAltCircleRight, - faClock, - faFileCode, - faFileWord, - faStar as faStarRegular, -} from '@fortawesome/free-regular-svg-icons'; -import { - faAngleDoubleLeft, - faAngleDoubleRight, - faBan, faBug, - faCheck, - faChevronLeft, - faChevronRight, faCode, - faExclamationCircle, - faExclamationTriangle, - faLevelDownAlt, - faPlus, faQuestionCircle, - faSpinner, - faStar as faStarSolid, - faUser, } from '@fortawesome/free-solid-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons'; @@ -44,15 +25,11 @@ import 'jquery.flot/jquery.flot.selection'; import './css/treeherder-global.css'; import './css/treeherder-navbar.css'; import './css/perf.css'; -import './css/treeherder-loading-overlay.css'; // Bootstrap the Angular modules against which everything will be registered import './js/perf'; // Perf JS -import './js/filters'; -import './js/controllers/perf/graphs'; -import './js/components/loading'; import './js/perfapp'; import './perfherder/compare/CompareSelectorView'; import './perfherder/compare/CompareView'; @@ -60,36 +37,12 @@ import './perfherder/compare/CompareSubtestDistributionView'; import './perfherder/compare/CompareSubtestsView'; import './perfherder/alerts/AlertTable'; import './perfherder/alerts/AlertsView'; -import './perfherder/graphs/TestDataModal'; -import './perfherder/graphs/SelectedTestsContainer'; +import './perfherder/graphs/GraphsView'; config.showMissingIcons = true; // TODO: Remove these as Perfherder components switch to using react-fontawesome. -library.add( - faAngleDoubleLeft, - faAngleDoubleRight, - faArrowAltCircleRight, - faBan, - faBug, - faCheck, - faChevronLeft, - faChevronRight, - faClock, - faCode, - faExclamationCircle, - faExclamationTriangle, - faFileCode, - faFileWord, - faGithub, - faLevelDownAlt, - faPlus, - faQuestionCircle, - faSpinner, - faStarRegular, - faStarSolid, - faUser, -); +library.add(faBug, faCode, faFileCode, faFileWord, faGithub, faQuestionCircle); // Replace any existing or tags with and set up a MutationObserver // to continue doing this as the DOM changes. Remove once using react-fontawesome. diff --git a/ui/helpers/constants.js b/ui/helpers/constants.js index 840cf8f0c..002adfff3 100644 --- a/ui/helpers/constants.js +++ b/ui/helpers/constants.js @@ -240,28 +240,6 @@ export const phFrameworksWithRelatedBranches = [ 11, // js-bench 12, // devtools ]; -// TODO remove usage in favor of summaryStatusMap perfherder's constant file -export const phAlertSummaryStatusMap = { - UNTRIAGED: { id: 0, text: 'untriaged' }, - DOWNSTREAM: { id: 1, text: 'downstream' }, - REASSIGNED: { id: 2, text: 'reassigned' }, - INVALID: { id: 3, text: 'invalid' }, - IMPROVEMENT: { id: 4, text: 'improvement' }, - INVESTIGATING: { id: 5, text: 'investigating' }, - WONTFIX: { id: 6, text: 'wontfix' }, - FIXED: { id: 7, text: 'fixed' }, - BACKEDOUT: { id: 8, text: 'backedout' }, - CONFIRMING: { id: 9, text: 'confirming' }, -}; -// TODO move into perfherder constants file -export const phAlertStatusMap = { - UNTRIAGED: { id: 0, text: 'untriaged' }, - DOWNSTREAM: { id: 1, text: 'downstream' }, - REASSIGNED: { id: 2, text: 'reassigned' }, - INVALID: { id: 3, text: 'invalid' }, - ACKNOWLEDGED: { id: 4, text: 'acknowledged' }, - CONFIRMING: { id: 5, text: 'confirming' }, -}; export const compareDefaultTimeRange = { value: 86400 * 2, diff --git a/ui/intermittent-failures/Layout.jsx b/ui/intermittent-failures/Layout.jsx index cc253f483..64becfed4 100644 --- a/ui/intermittent-failures/Layout.jsx +++ b/ui/intermittent-failures/Layout.jsx @@ -1,12 +1,11 @@ import React from 'react'; import { Container } from 'reactstrap'; import PropTypes from 'prop-types'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCog } from '@fortawesome/free-solid-svg-icons'; import ErrorBoundary from '../shared/ErrorBoundary'; import ErrorMessages from '../shared/ErrorMessages'; import { genericErrorMessage, errorMessageClass } from '../helpers/constants'; +import LoadingSpinner from '../shared/LoadingSpinner'; import Navigation from './Navigation'; import GraphsContainer from './GraphsContainer'; @@ -43,16 +42,7 @@ const Layout = props => { tableFailureStatus || graphFailureStatus || errorMessages.length > 0 - ) && ( -
- -
- )} + ) && } {(tableFailureStatus || graphFailureStatus || errorMessages.length > 0) && ( diff --git a/ui/js/components/loading.js b/ui/js/components/loading.js deleted file mode 100644 index 01753d3ef..000000000 --- a/ui/js/components/loading.js +++ /dev/null @@ -1,14 +0,0 @@ -import treeherder from '../treeherder'; - -treeherder.component('loading', { - bindings: { - data: '<', - }, - template: ` -
-
- -
-
- `, -}); diff --git a/ui/js/controllers/perf/graphs.js b/ui/js/controllers/perf/graphs.js deleted file mode 100644 index fff7ec0fb..000000000 --- a/ui/js/controllers/perf/graphs.js +++ /dev/null @@ -1,960 +0,0 @@ -// Remove the eslint-disable when rewriting this file during the React conversion. -/* eslint-disable func-names, no-use-before-define, no-useless-escape, no-var, object-shorthand, prefer-destructuring, prefer-template, radix, vars-on-top */ -// TODO: Vet/fix the use-before-defines to ensure switching var -// to let/const won't break anything. - -import $ from 'jquery'; -import map from 'lodash/map'; -import countBy from 'lodash/countBy'; -import angular from 'angular'; -import Mousetrap from 'mousetrap'; - -import perf from '../../perf'; -import { - alertIsOfState, - createAlert, - findPushIdNeighbours, - getAlertStatusText, - getAlertSummaries, - getAlertSummaryStatusText, - nudgeAlert, -} from '../../../perfherder/helpers'; -import testDataChooserTemplate from '../../../partials/perf/testdatachooser.html'; -import { - phTimeRanges, - phAlertStatusMap, - phAlertSummaryStatusMap, - phDefaultTimeRangeValue, -} from '../../../helpers/constants'; -import PushModel from '../../../models/push'; -import RepositoryModel from '../../../models/repository'; -import PerfSeriesModel from '../../../models/perfSeries'; - -perf.controller('GraphsCtrl', [ - '$state', '$stateParams', '$scope', '$rootScope', '$uibModal', - '$window', '$q', '$timeout', - function GraphsCtrl($state, $stateParams, $scope, $rootScope, - $uibModal, $window, $q, $timeout) { - const availableColors = [['magenta', '#e252cf'], ['blue', '#1752b8'], ['darkorchid', '#9932cc'], ['brown', '#b87e17'], - ['green', '#19a572'], ['turquoise', '#17a2b8'], ['scarlet', '#b81752']]; - - $scope.highlightedRevisions = [undefined, undefined]; - $scope.highlightAlerts = true; - $scope.loadingGraphs = false; - $scope.nudgingAlert = false; - - $scope.timeranges = phTimeRanges; - - $scope.timeRangeChanged = null; - $scope.ttHideTimer = null; - $scope.selectedDataPoint = null; - $scope.showToolTipTimeout = null; - $scope.seriesList = []; - - $scope.createAlert = function (dataPoint) { - $scope.creatingAlert = true; - createAlert(dataPoint) - .then(alertSummaryId => refreshGraphData(alertSummaryId, dataPoint)) - .then(() => { - $scope.creatingAlert = false; - }); - }; - - $scope.nudgeAlert = (dataPoint, direction) => { - $scope.nudgingAlert = true; - - const resultSetData = dataPoint.series.flotSeries.resultSetData; - const towardsDataPoint = findPushIdNeighbours(dataPoint, resultSetData, direction); - - nudgeAlert(dataPoint, towardsDataPoint) - .then(alertSummaryId => refreshGraphData(alertSummaryId, dataPoint), - (error) => { - $scope.nudgingAlert = false; - alertHttpError(error); - }).then(() => { - deselectDataPoint(); - $scope.nudgingAlert = false; - }); - }; - - function refreshGraphData(alertSummaryId, dataPoint) { - return getAlertSummaries({ - signatureId: dataPoint.series.id, - repository: dataPoint.project.id, - }).then(function (alertSummaryData) { - var alertSummary = alertSummaryData.results.find(result => - result.id === alertSummaryId); - $scope.tooltipContent.alertSummary = alertSummary; - - dataPoint.series.relatedAlertSummaries = alertSummaryData.results; - plotGraph(); - }); - } - - function getSeriesDataPoint(flotItem) { - // gets universal elements of a series given a flot item - - // sometimes we have multiple results with the same result id, in - // which case we need to calculate an offset to it (I guess - // technically even this is subject to change in the case of - // retriggers but oh well, hopefully this will work for 99% - // of cases) - var resultSetId = flotItem.series.resultSetData[flotItem.dataIndex]; - return { - projectName: flotItem.series.thSeries.projectName, - signature: flotItem.series.thSeries.signature, - signatureId: flotItem.series.thSeries.id, - frameworkId: flotItem.series.thSeries.frameworkId, - resultSetId: resultSetId, - flotDataOffset: (flotItem.dataIndex - - flotItem.series.resultSetData.indexOf(resultSetId)), - id: flotItem.series.idData[flotItem.dataIndex], - jobId: flotItem.series.jobIdData[flotItem.dataIndex], - }; - } - - function deselectDataPoint() { - $timeout(function () { - $scope.selectedDataPoint = null; - hideTooltip(); - updateDocument(); - }); - } - - function showTooltip(dataPoint) { - if ($scope.showToolTipTimeout) { - window.clearTimeout($scope.showToolTipTimeout); - } - - $scope.showToolTipTimeout = window.setTimeout(function () { - if ($scope.ttHideTimer) { - clearTimeout($scope.ttHideTimer); - $scope.ttHideTimer = null; - } - - var phSeries = $scope.seriesList.find( - s => s.id === dataPoint.signatureId); - - // we need the flot data for calculating values/deltas and to know where - // on the graph to position the tooltip - var flotIndex = phSeries.flotSeries.idData.indexOf( - dataPoint.id); - var flotData = { - series: $scope.plot.getData().find( - fs => fs.thSeries.id === dataPoint.signatureId), - pointIndex: flotIndex, - }; - // check if there are any points belonging to earlier pushes in this - // graph -- if so, get the previous push so we can link to a pushlog - var firstResultSetIndex = phSeries.flotSeries.resultSetData.indexOf( - dataPoint.resultSetId); - var prevResultSetId = (firstResultSetIndex > 0) ? - phSeries.flotSeries.resultSetData[firstResultSetIndex - 1] : null; - - var retriggerNum = countBy(phSeries.flotSeries.resultSetData, - function (resultSetId) { - return resultSetId === dataPoint.resultSetId ? 'retrigger' : 'original'; - }); - var prevFlotDataPointIndex = (flotData.pointIndex - 1); - var flotSeriesData = flotData.series.data; - - var t = flotSeriesData[flotData.pointIndex][0]; - var v = flotSeriesData[flotData.pointIndex][1]; - var v0 = (prevFlotDataPointIndex >= 0) ? flotSeriesData[prevFlotDataPointIndex][1] : v; - var dv = v - v0; - var dvp = v / v0 - 1; - var alertSummary = phSeries.relatedAlertSummaries.find(alertSummary => - alertSummary.push_id === dataPoint.resultSetId); - var alert; - if (alertSummary) { - alert = alertSummary.alerts.find(alert => - alert.series_signature.id === phSeries.id); - } - $scope.tooltipContent = { - project: $rootScope.repos.find(repo => - repo.name === phSeries.projectName), - revisionUrl: `/#/jobs?repo=${phSeries.projectName}`, - prevResultSetId: prevResultSetId, - resultSetId: dataPoint.resultSetId, - jobId: dataPoint.jobId, - series: phSeries, - value: Math.round(v * 1000) / 1000, - deltaValue: dv.toFixed(1), - deltaPercentValue: (100 * dvp).toFixed(1), - date: $.plot.formatDate(new Date(t), '%a %b %d, %H:%M:%S'), - retriggers: (retriggerNum.retrigger - 1), - alertSummary: alertSummary, - revisionInfoAvailable: true, - alert: alert, - }; - - // Get revision information for both this datapoint and the previous - // one - [{ - resultSetId: dataPoint.resultSetId, - scopeKey: 'revision', - }, { - resultSetId: prevResultSetId, - scopeKey: 'prevRevision', - }].forEach((resultRevision) => { - PushModel.get(resultRevision.resultSetId, { repo: phSeries.projectName }) - .then(async (resp) => { - const push = await resp.json(); - $scope.tooltipContent[resultRevision.scopeKey] = push.revision; - if ($scope.tooltipContent.prevRevision && $scope.tooltipContent.revision) { - $scope.tooltipContent.pushlogURL = $scope.tooltipContent.project.getPushLogRangeHref({ - fromchange: $scope.tooltipContent.prevRevision, - tochange: $scope.tooltipContent.revision, - }); - } - $scope.$apply(); - }, function () { - $scope.tooltipContent.revisionInfoAvailable = false; - }); - }); - - // now position it - $timeout(function () { - var x = parseInt(flotData.series.xaxis.p2c(t) + - $scope.plot.offset().left); - var y = parseInt(flotData.series.yaxis.p2c(v) + - $scope.plot.offset().top); - - var tip = $('#graph-tooltip'); - function getTipPosition(tip, x, y, yoffset) { - return { - left: x - tip.width() / 2, - top: y - tip.height() - yoffset, - }; - } - - tip.stop(true); - - // first, reposition tooltip (width/height won't be calculated correctly - // in all cases otherwise) - var tipPosition = getTipPosition(tip, x, y, 10); - tip.css({ left: tipPosition.left, top: tipPosition.top }); - - // get new tip position after transform - tipPosition = getTipPosition(tip, x, y, 10); - if (tip.css('visibility') === 'hidden') { - tip.css({ - opacity: 0, - visibility: 'visible', - left: tipPosition.left, - top: tipPosition.top + 10, - }); - tip.animate({ - opacity: 1, - left: tipPosition.left, - top: tipPosition.top, - }, 250); - } else { - tip.css({ - opacity: 1, - left: tipPosition.left, - top: tipPosition.top, - }); - } - }); - }, 250); - } - - function hideTooltip(now) { - var tip = $('#graph-tooltip'); - if ($scope.showToolTipTimeout) { - window.clearTimeout($scope.showToolTipTimeout); - } - - if (!$scope.ttHideTimer && tip.css('visibility') === 'visible') { - $scope.ttHideTimer = setTimeout(function () { - $scope.ttHideTimer = null; - tip.animate({ opacity: 0, top: '+=10' }, - 250, 'linear', function () { - $(this).css({ visibility: 'hidden' }); - }); - }, now ? 0 : 250); - } - } - - Mousetrap.bind('escape', function () { - deselectDataPoint(); - }); - - // on window resize, replot the graph - angular.element($window).bind('resize', () => plotGraph()); - - // Highlight the points persisted in the url - function highlightDataPoints() { - $scope.plot.unhighlight(); - - // if we have a highlighted revision(s), highlight all points that - // correspond to that - $scope.seriesList.forEach(function (series, i) { - if (series.visible && series.highlightedPoints && - series.highlightedPoints.length) { - series.highlightedPoints.forEach(function (highlightedPoint) { - $scope.plot.highlight(i, highlightedPoint); - }); - } - }); - - // also highlighted the selected item (if there is one) - if ($scope.selectedDataPoint) { - var selectedSeriesIndex = $scope.seriesList.findIndex( - s => s.id === $scope.selectedDataPoint.signatureId); - var selectedSeries = $scope.seriesList[selectedSeriesIndex]; - var flotDataPoint = selectedSeries.flotSeries.idData.indexOf( - $scope.selectedDataPoint.id); - flotDataPoint = flotDataPoint || selectedSeries.flotSeries.resultSetData.indexOf( - $scope.selectedDataPoint.resultSetId); - $scope.plot.highlight(selectedSeriesIndex, flotDataPoint); - } - } - - function plotUnselected() { - $scope.zoom = {}; - $scope.selectedDataPoint = null; - hideTooltip(); - updateDocument(); - plotGraph(); - } - - function plotSelected(event, ranges) { - deselectDataPoint(); - hideTooltip(); - - $.each($scope.plot.getXAxes(), function (_, axis) { - var opts = axis.options; - opts.min = ranges.xaxis.from; - opts.max = ranges.xaxis.to; - }); - $.each($scope.plot.getYAxes(), function (_, axis) { - var opts = axis.options; - opts.min = ranges.yaxis.from; - opts.max = ranges.yaxis.to; - }); - $scope.zoom = { x: [ranges.xaxis.from, ranges.xaxis.to], y: [ranges.yaxis.from, ranges.yaxis.to] }; - - $scope.plot.setupGrid(); - $scope.plot.draw(); - updateDocument(); - } - - function plotOverviewGraph() { - // We want to show lines for series in the overview plot, if they are visible - $scope.seriesList.forEach(function (series) { - series.flotSeries.points.show = false; - series.flotSeries.lines.show = series.visible; - }); - - $scope.overviewPlot = $.plot( - $('#overview-plot'), - $scope.seriesList.map(function (series) { - return series.flotSeries; - }), - { - xaxis: { mode: 'time' }, - selection: { mode: 'xy', color: '#97c6e5' }, - series: { shadowSize: 0 }, - lines: { show: true }, - points: { show: false }, - legend: { show: false }, - grid: { - color: '#cdd6df', - borderWidth: 2, - backgroundColor: '#fff', - hoverable: true, - clickable: true, - autoHighlight: false, - }, - }, - ); - // Reset $scope.seriesList with lines.show = false - $scope.seriesList.forEach(function (series) { - series.flotSeries.points.show = series.visible; - series.flotSeries.lines.show = false; - }); - - $('#overview-plot').on('plotunselected', plotUnselected); - - $('#overview-plot').on('plotselected', plotSelected); - } - - function zoomGraph() { - // If either x or y exists then there is zoom set in the variable - if ($scope.zoom.x) { - if ($scope.seriesList.find(series => series.visible)) { - $.each($scope.plot.getXAxes(), function (_, axis) { - var opts = axis.options; - opts.min = $scope.zoom.x[0]; - opts.max = $scope.zoom.x[1]; - }); - $.each($scope.plot.getYAxes(), function (_, axis) { - var opts = axis.options; - opts.min = $scope.zoom.y[0]; - opts.max = $scope.zoom.y[1]; - }); - $scope.plot.setupGrid(); - $scope.overviewPlot.setSelection({ - xaxis: { - from: $scope.zoom.x[0], - to: $scope.zoom.x[1], - }, - yaxis: { - from: $scope.zoom.y[0], - to: $scope.zoom.y[1], - }, - }, true); - $scope.overviewPlot.draw(); - $scope.plot.draw(); - } - } - } - - function plotGraph() { - // synchronize series visibility with flot, in case it's changed - $scope.seriesList.forEach(function (series) { - series.flotSeries.points.show = series.visible; - series.blockColor = series.visible ? series.color : 'grey'; - }); - - // reset highlights - $scope.seriesList.forEach(function (series) { - series.highlightedPoints = []; - }); - - // highlight points which correspond to an alert - var markings = []; - function addHighlightedDatapoint(series, resultSetId) { - // add a vertical line where alerts are, for extra visibility - var index = series.flotSeries.resultSetData.indexOf(resultSetId); - if (index !== (-1)) { - markings.push({ - color: '#ddd', - lineWidth: 1, - xaxis: { - from: series.flotSeries.data[index][0], - to: series.flotSeries.data[index][0], - }, - }); - } - // highlight the datapoints too - series.highlightedPoints = [...new Set([ - ...series.highlightedPoints, - ...series.flotSeries.resultSetData.map((seriesResultSetId, index) => ( - resultSetId === seriesResultSetId ? index : null - )).filter(v => v)])]; - } - - if ($scope.highlightAlerts) { - $scope.seriesList.forEach(function (series) { - if (series.visible) { - series.relatedAlertSummaries.forEach(function (alertSummary) { - addHighlightedDatapoint(series, alertSummary.push_id); - }); - } - }); - } - - // highlight each explicitly highlighted revision on visible serii - var highlightPromises = []; - $scope.highlightedRevisions.forEach((rev) => { - if (rev && rev.length === 12) { - highlightPromises = [...new Set([ - ...highlightPromises, - ...$scope.seriesList.map(async (series) => { - if (series.visible) { - const { data, failureStatus } = await PushModel.getList({ - repo: series.projectName, - revision: rev, - }); - if (!failureStatus && data.results && data.results.length) { - addHighlightedDatapoint(series, data.results[0].id); - $scope.$apply(); - } - // ignore cases where no push exists - // for revision - } - return null; - })])]; - } - }); - $q.all(highlightPromises).then(function () { - // plot the actual graph - $scope.plot = $.plot( - $('#graph'), - $scope.seriesList.map(function (series) { - return series.flotSeries; - }), - { - xaxis: { mode: 'time' }, - series: { shadowSize: 0 }, - selection: { mode: 'xy', color: '#97c6e5' }, - lines: { show: false }, - points: { show: true }, - legend: { show: false }, - grid: { - color: '#cdd6df', - borderWidth: 2, - backgroundColor: '#fff', - hoverable: true, - clickable: true, - autoHighlight: false, - markings: markings, - }, - }, - ); - - updateSelectedItem(null); - highlightDataPoints(); - plotOverviewGraph(); - zoomGraph(); - - if ($scope.selectedDataPoint) { - showTooltip($scope.selectedDataPoint); - } - - function updateSelectedItem() { - if (!$scope.selectedDataPoint) { - hideTooltip(); - } - } - - $('#graph').on('plothover', function (event, pos, item) { - // if examining an item, disable this behaviour - if ($scope.selectedDataPoint) return; - - $('#graph').css({ cursor: item ? 'pointer' : '' }); - - if (item && item.series.thSeries) { - if (item.seriesIndex !== $scope.prevSeriesIndex || - item.dataIndex !== $scope.prevDataIndex) { - var seriesDataPoint = getSeriesDataPoint(item); - showTooltip(seriesDataPoint); - $scope.prevSeriesIndex = item.seriesIndex; - $scope.prevDataIndex = item.dataIndex; - } - } else { - hideTooltip(); - $scope.prevSeriesIndex = null; - $scope.prevDataIndex = null; - } - }); - - $('#graph').on('plotclick', function (e, pos, item) { - if (item) { - $scope.selectedDataPoint = getSeriesDataPoint(item); - showTooltip($scope.selectedDataPoint); - updateSelectedItem(); - } else { - $scope.selectedDataPoint = null; - hideTooltip(); - $scope.$digest(); - } - updateDocument(); - highlightDataPoints(); - }); - - $('#graph').on('plotselected', function (event, ranges) { - $scope.plot.clearSelection(); - plotSelected(event, ranges); - zoomGraph(); - }); - - // Close pop up when user clicks outside of the graph area - $('html').click(function () { - $scope.closePopup(); - }); - - // Stop propagation when user clicks inside the graph area - $('#graph, #graph-tooltip').click(function (event) { - event.stopPropagation(); - }); - }); - } - - $scope.repoName = $stateParams.projectId; - - function updateDocumentTitle() { - if ($scope.seriesList.length) { - window.document.title = ($scope.seriesList[0].name + ' ' + - $scope.seriesList[0].platform + - ' (' + $scope.seriesList[0].projectName + - ')'); - if ($scope.seriesList.length > 1) { - window.document.title += ' and others'; - } - } else { - window.document.title = $state.current.title; - } - } - - function updateDocument() { - $state.transitionTo('graphs', { - series: $scope.seriesList.map(series => `${series.projectName},${series.id},${series.visible ? 1 : 0},${series.frameworkId}`), - timerange: ($scope.myTimerange.value !== phDefaultTimeRangeValue) ? - $scope.myTimerange.value : undefined, - highlightedRevisions: $scope.highlightedRevisions.filter(highlight => highlight && highlight.length >= 12), - highlightAlerts: !$scope.highlightAlerts ? 0 : undefined, - zoom: (function () { - if ((typeof $scope.zoom.x !== 'undefined') - && (typeof $scope.zoom.y !== 'undefined') - && ($scope.zoom.x !== 0 && $scope.zoom.y !== 0)) { - var modifiedZoom = ('[' + ($scope.zoom.x.toString() - + ',' + $scope.zoom.y.toString()) + ']').replace(/[\[\{\}\]"]+/g, ''); - return modifiedZoom; - } - $scope.zoom = []; - return $scope.zoom; - }()), - selected: (function () { - return ($scope.selectedDataPoint) ? [$scope.selectedDataPoint.projectName, - $scope.selectedDataPoint.signatureId, - $scope.selectedDataPoint.resultSetId, - $scope.selectedDataPoint.id, - $scope.selectedDataPoint.frameworkId].toString() : undefined; - }()), - }, { - location: true, - inherit: true, - relative: $state.$current, - notify: false, - }); - - updateDocumentTitle(); - } - - function getSeriesData(series) { - return PerfSeriesModel.getSeriesData(series.projectName, { interval: $scope.myTimerange.value, - signature_id: series.id, - framework: series.frameworkId, - }).then( - function (seriesData) { - series.flotSeries = { - lines: { show: false }, - points: { show: series.visible }, - color: series.color ? series.color[1] : '#6c757d', - label: series.projectName + ' ' + series.name, - data: map( - seriesData[series.signature], - function (dataPoint) { - return [ - new Date(dataPoint.push_timestamp * 1000), - dataPoint.value, - ]; - }), - resultSetData: map( - seriesData[series.signature], - 'push_id'), - thSeries: $.extend({}, series), - jobIdData: map(seriesData[series.signature], 'job_id'), - idData: map(seriesData[series.signature], 'id'), - }; - }).then(function () { - series.relatedAlertSummaries = []; - var repo = $rootScope.repos.find(repo => - repo.name === series.projectName); - return getAlertSummaries({ - signatureId: series.id, - repository: repo.id }).then(function (data) { - series.relatedAlertSummaries = data.results; - }); - }); - } - - function addSeriesList(partialSeriesList) { - $q.all(partialSeriesList.map(async function (partialSeries) { - $scope.loadingGraphs = true; - const params = { framework: partialSeries.frameworkId }; - if (partialSeries.id) { - params.id = partialSeries.id; - } else { - params.signature = partialSeries.signature; - } - const { data: seriesList, failureStatus} = await PerfSeriesModel.getSeriesList( - partialSeries.project, params); - - if (failureStatus) { - return alert('Error loading performance signature\n\n' + seriesList); - } - if (!seriesList.length) { - return $q.reject('Signature `' + partialSeries.signature + - '` not found for ' + partialSeries.project); - } - var seriesSummary = seriesList[0]; - seriesSummary.projectName = partialSeries.project; - seriesSummary.visible = partialSeries.visible; - seriesSummary.color = availableColors.pop(); - seriesSummary.highlighted = partialSeries.highlighted; - $scope.seriesList.push(seriesSummary); - - $q.all($scope.seriesList.map(getSeriesData)).then(function () { - plotGraph(); - updateDocumentTitle(); - $scope.loadingGraphs = false; - if ($scope.selectedDataPoint) { - showTooltip($scope.selectedDataPoint); - } - }); - $scope.seriesList = [...$scope.seriesList]; - })); - } - - function alertHttpError(error) { - if (error.statusText) { - error = 'HTTP Error: ' + error.statusText; - } - - // we could probably do better than print this - // rather useless error, but at least this gives - // a hint on what the problem is - alert('Error loading performance data\n\n' + error); - } - - $scope.removeSeries = function (projectName, signature) { - var newSeriesList = []; - $scope.seriesList.forEach(function (series) { - if (series.signature !== signature || - series.projectName !== projectName) { - newSeriesList.push(series); - } else { - // add the color back to the list of available colors - availableColors.push(series.color); - - // deselect datapoint if no longer valid - if ($scope.selectedDataPoint && - $scope.selectedDataPoint.signatureId === series.id) { - $scope.selectedDataPoint = null; - } - } - }); - $scope.seriesList = newSeriesList; - - if ($scope.seriesList.length === 0) { - $scope.resetHighlight(); - $scope.zoom = {}; - } - updateDocument(); - plotGraph(); - if ($scope.selectedDataPoint) { - showTooltip($scope.selectedDataPoint); - } - }; - - $scope.showHideSeries = function (signature) { - const updatedSeries = $scope.seriesList.find(series => series.signature === signature); - updatedSeries.visible = !updatedSeries.visible; - updateDocument(); - plotGraph(); - }; - - $scope.resetHighlight = function (i) { - $scope.highlightedRevisions[i] = ''; - - // update url - updateDocument(); - plotGraph(); - }; - - $scope.updateHighlightedRevisions = function () { - updateDocument(); - plotGraph(); - }; - - $scope.closePopup = function () { - $scope.selectedDataPoint = null; - hideTooltip(); - highlightDataPoints(); - }; - - // Alert functions - $scope.phAlertStatusMap = phAlertStatusMap; - - $scope.getAlertStatusText = getAlertStatusText; - $scope.alertIsOfState = alertIsOfState; - - // AlertSummary functions - $scope.phAlertSummaryStatusMap = phAlertSummaryStatusMap; - - $scope.getAlertSummaryStatusText = getAlertSummaryStatusText; - - RepositoryModel.getList().then((repos) => { - $rootScope.repos = repos; - if ($stateParams.timerange) { - var timeRange = phTimeRanges.find(timeRange => - timeRange.value === parseInt($stateParams.timerange)); - $scope.myTimerange = timeRange; - } else { - $scope.myTimerange = phTimeRanges.find(timeRange => - timeRange.value === phDefaultTimeRangeValue); - } - $scope.timeRangeChanged = function () { - $scope.loadingGraphs = true; - $scope.zoom = {}; - deselectDataPoint(); - - updateDocument(); - // refetch and re-render all graph data - $q.all($scope.seriesList.map(getSeriesData)).then(function () { - plotGraph(); - $scope.loadingGraphs = false; - }); - }; - - if ($stateParams.zoom) { - var zoomString = decodeURIComponent($stateParams.zoom).replace(/[\[\{\}\]"]+/g, ''); - var zoomArray = zoomString.split(','); - var zoomObject = { - x: zoomArray.slice(0, 2), - y: zoomArray.slice(2, 4), - }; - $scope.zoom = (zoomString) ? zoomObject : []; - } else { - $scope.zoom = []; - } - - if ($stateParams.series) { - $scope.seriesList = []; - if (typeof $stateParams.series === 'string') { - $stateParams.series = [$stateParams.series]; - } - if ($stateParams.highlightAlerts) { - $scope.highlightAlerts = parseInt($stateParams.highlightAlerts); - } - if ($stateParams.highlightedRevisions) { - if (typeof ($stateParams.highlightedRevisions) === 'string') { - $scope.highlightedRevisions = [$stateParams.highlightedRevisions]; - } else { - $scope.highlightedRevisions = $stateParams.highlightedRevisions; - } - } else { - $scope.highlightedRevisions = ['', '']; - } - - // we only store the signature + project name in the url, we need to - // fetch everything else from the server - var partialSeriesList = $stateParams.series.map(function (encodedSeries) { - var partialSeriesString = decodeURIComponent(encodedSeries).replace(/[\[\]"]/g, ''); - var partialSeriesArray = partialSeriesString.split(','); - var partialSeriesObject = { - project: partialSeriesArray[0], - signature: partialSeriesArray[1].length === 40 ? partialSeriesArray[1] : undefined, - id: partialSeriesArray[1].length === 40 ? undefined : partialSeriesArray[1], - visible: partialSeriesArray[2] !== 0, - frameworkId: partialSeriesArray[3], - }; - return partialSeriesObject; - }); - addSeriesList(partialSeriesList); - } else { - $scope.seriesList = []; - plotGraph(); - } - if ($stateParams.selected) { - var tooltipString = decodeURIComponent($stateParams.selected).replace(/[\[\]"]/g, ''); - var tooltipArray = tooltipString.split(','); - var tooltip = { - projectName: tooltipArray[0], - signatureId: parseInt(tooltipArray[1]), - resultSetId: parseInt(tooltipArray[2]), - id: parseInt(tooltipArray[3]), - frameworkId: parseInt(tooltipArray[4]) || 1, - }; - $scope.selectedDataPoint = (tooltipString) ? tooltip : null; - } - - $scope.addTestData = function (option, seriesSignature) { - var defaultProjectName; - var defaultPlatform; - var defaultFrameworkId; - var options = {}; - if ($scope.seriesList.length > 0) { - var lastSeries = $scope.seriesList.slice(-1)[0]; - defaultProjectName = lastSeries.projectName; - defaultPlatform = lastSeries.platform; - defaultFrameworkId = lastSeries.frameworkId; - } - - if (option !== undefined) { - var series = $scope.seriesList.find(series => - series.signature === seriesSignature); - options = { option: option, relatedSeries: series }; - } - - var modalInstance = $uibModal.open({ - template: testDataChooserTemplate, - controller: 'TestChooserCtrl', - size: 'lg', - resolve: { - projects: function () { - return $rootScope.repos; - }, - timeRange: function () { - return $scope.myTimerange.value; - }, - testsDisplayed: function () { - return $scope.seriesList; - }, - defaultFrameworkId: function () { return defaultFrameworkId; }, - defaultProjectName: function () { return defaultProjectName; }, - defaultPlatform: function () { return defaultPlatform; }, - options: function () { return options; }, - }, - }); - - modalInstance.result.then(function (seriesList) { - $scope.loadingGraphs = true; - seriesList.forEach(function (series) { - series.hightlightedPoints = []; - series.visible = true; - series.color = availableColors.pop(); - $scope.seriesList.push(series); - }); - if (!$scope.highlightedRevision) { - $scope.highlightedRevision = ''; - } - if (!$scope.zoom) { - $scope.zoom = {}; - } - updateDocument(); - $q.all($scope.seriesList.map(getSeriesData)).then(function () { - plotGraph(); - $scope.loadingGraphs = false; - }); - $scope.seriesList = [...$scope.seriesList]; - }); - }; - }); - }]); - -perf.filter('testNameContainsWords', function () { - /* - Filter a list of test by ensuring that every word in the textFilter is - present in the test name. - */ - return function (tests, textFilter) { - if (!textFilter) { - return tests; - } - - var filters = textFilter.split(/\s+/); - return tests.filter(test => filters.every(filter => test.name.toLowerCase().indexOf(filter) !== -1)); - }; -}); - -perf.controller('TestChooserCtrl', ['$scope', '$uibModalInstance', 'projects', 'timeRange', - 'defaultFrameworkId', 'defaultProjectName', 'defaultPlatform', '$q', 'testsDisplayed', 'options', - function ($scope, $uibModalInstance, projects, timeRange, - defaultFrameworkId, defaultProjectName, defaultPlatform, $q, testsDisplayed, options) { - $scope.options = options; - $scope.timeRange = timeRange; - $scope.testsDisplayed = testsDisplayed; - - $scope.submitData = function (series) { - $uibModalInstance.close(series); - } - $scope.cancel = function () { - $uibModalInstance.dismiss('cancel'); - }; - }]); diff --git a/ui/js/filters.js b/ui/js/filters.js deleted file mode 100755 index ed1f7b430..000000000 --- a/ui/js/filters.js +++ /dev/null @@ -1,30 +0,0 @@ -// Remove the eslint-disable when rewriting this file during the React conversion. -/* eslint-disable func-names */ -import { getJobsUrl } from '../helpers/url'; - -import treeherder from './treeherder'; - -treeherder.filter('getRevisionUrl', function () { - return function (revision, projectName) { - if (revision) { - return getJobsUrl({ repo: projectName, revision }); - } - return ''; - }; -}); -// TODO replace usage with displayNumber in helpers file -treeherder.filter('displayNumber', ['$filter', function ($filter) { - return function (input) { - if (Number.isNaN(input)) { - return 'N/A'; - } - - return $filter('number')(input, 2); - }; -}]); - -treeherder.filter('absoluteValue', function () { - return function (input) { - return Math.abs(input); - }; -}); diff --git a/ui/js/perfapp.js b/ui/js/perfapp.js index 15541a4e1..6d5e14099 100644 --- a/ui/js/perfapp.js +++ b/ui/js/perfapp.js @@ -37,7 +37,6 @@ perf.config(['$compileProvider', '$locationProvider', '$httpProvider', '$statePr title: 'Graphs', template: graphsCtrlTemplate, url: '/graphs?timerange&series&highlightedRevisions&highlightAlerts&zoom&selected', - controller: 'GraphsCtrl', }) .state('compare', { title: 'Compare', diff --git a/ui/partials/perf/graphsctrl.html b/ui/partials/perf/graphsctrl.html index 2f06fdf16..e8484c5be 100644 --- a/ui/partials/perf/graphsctrl.html +++ b/ui/partials/perf/graphsctrl.html @@ -1,131 +1 @@ -
-
-
-
-

Nothing here yet

-
- - -
-
- -
-
-
-
- -
-
- Highlight revisions: - - - - - -
- -
-
-
-
-
- -
-
-
- -

- ()

-

-
-
-

- {{tooltipContent.value|displayNumber}} - (lower is better) - (higher is better) -

-

Δ {{tooltipContent.deltaValue|displayNumber}} - (%)

-
-
-

- - - - - {{tooltipContent.revision| limitTo: 12}} - - - - - - (job, compare) - -

-

- - - Alert #{{tooltipContent.alertSummary.id}} - - {{tooltipContent.alert && (alertIsOfState(tooltipContent.alert, phAlertStatusMap.ACKNOWLEDGED) ? getAlertSummarytStatusText(tooltipContent.alertSummary) : getAlertStatusText(tooltipContent.alert))}} - - - to alert #{{tooltipContent.alert.related_summary_id}} - - - from alert #{{tooltipContent.alert.related_summary_id}} - - - -

-

- - No alert - - (create) - - - (log in as a a sheriff to create) - - - - Creating alert... - -

-

- Revision info unavailable - Loading revision... -

-

-

Retriggers: {{tooltipContent.retriggers}}

-
- Click to lock -   -
-
-
-
+ diff --git a/ui/partials/perf/testdatachooser.html b/ui/partials/perf/testdatachooser.html deleted file mode 100644 index 2ccc8267d..000000000 --- a/ui/partials/perf/testdatachooser.html +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/ui/perfherder/Validation.jsx b/ui/perfherder/Validation.jsx index 8659a08a0..b7bdd5d3f 100644 --- a/ui/perfherder/Validation.jsx +++ b/ui/perfherder/Validation.jsx @@ -1,13 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Container } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCog } from '@fortawesome/free-solid-svg-icons'; import { getData, processResponse } from '../helpers/http'; import { getApiUrl, repoEndpoint } from '../helpers/url'; import PushModel from '../models/push'; import ErrorMessages from '../shared/ErrorMessages'; +import LoadingSpinner from '../shared/LoadingSpinner'; import { endpoints, summaryStatusMap } from './constants'; @@ -191,14 +190,7 @@ const withValidation = ( return ( {!validationComplete && errorMessages.length === 0 && ( -
- -
+ )} {errorMessages.length > 0 && ( diff --git a/ui/perfherder/alerts/AlertTable.jsx b/ui/perfherder/alerts/AlertTable.jsx index 3e1ce9a23..1066c92da 100644 --- a/ui/perfherder/alerts/AlertTable.jsx +++ b/ui/perfherder/alerts/AlertTable.jsx @@ -3,8 +3,8 @@ import PropTypes from 'prop-types'; import { Container, Form, FormGroup, Label, Input, Table } from 'reactstrap'; import orderBy from 'lodash/orderBy'; +import { alertStatusMap } from '../constants'; import { - phAlertStatusMap, genericErrorMessage, errorMessageClass, } from '../../helpers/constants'; @@ -70,7 +70,7 @@ export default class AlertTable extends React.Component { alertSummary.alerts .map(alert => { if ( - alert.status === phAlertStatusMap.DOWNSTREAM.id && + alert.status === alertStatusMap.downstream && alert.summary_id !== alertSummary.id ) { return [alert.summary_id]; @@ -91,14 +91,14 @@ export default class AlertTable extends React.Component { const matchesFilters = (!hideImprovements || alert.is_regression) && (alert.summary_id === alertSummary.id || - alert.status !== phAlertStatusMap.DOWNSTREAM.id) && + alert.status !== alertStatusMap.downstream) && !( hideDownstream && - alert.status === phAlertStatusMap.REASSIGNED.id && + alert.status === alertStatusMap.reassigned && alert.related_summary_id !== alertSummary.id ) && - !(hideDownstream && alert.status === phAlertStatusMap.DOWNSTREAM.id) && - !(hideDownstream && alert.status === phAlertStatusMap.INVALID.id); + !(hideDownstream && alert.status === alertStatusMap.downstream) && + !(hideDownstream && alert.status === alertStatusMap.invalid); if (!filterText) return matchesFilters; diff --git a/ui/perfherder/alerts/AlertsView.jsx b/ui/perfherder/alerts/AlertsView.jsx index 5c8405061..602ff730c 100644 --- a/ui/perfherder/alerts/AlertsView.jsx +++ b/ui/perfherder/alerts/AlertsView.jsx @@ -9,8 +9,6 @@ import { PaginationItem, PaginationLink, } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCog } from '@fortawesome/free-solid-svg-icons'; import perf from '../../js/perf'; import withValidation from '../Validation'; @@ -25,6 +23,7 @@ import { errorMessageClass, } from '../../helpers/constants'; import ErrorBoundary from '../../shared/ErrorBoundary'; +import LoadingSpinner from '../../shared/LoadingSpinner'; import AlertsViewControls from './AlertsViewControls'; @@ -226,16 +225,7 @@ export class AlertsView extends React.Component { message={genericErrorMessage} > - {loading && ( -
- -
- )} + {loading && } {errorMessages.length > 0 && ( diff --git a/ui/perfherder/alerts/StatusDropdown.jsx b/ui/perfherder/alerts/StatusDropdown.jsx index bd10f9111..c8edcd984 100644 --- a/ui/perfherder/alerts/StatusDropdown.jsx +++ b/ui/perfherder/alerts/StatusDropdown.jsx @@ -12,12 +12,7 @@ import moment from 'moment'; import template from 'lodash/template'; import templateSettings from 'lodash/templateSettings'; -import { - getAlertSummaryStatusText, - getTextualSummary, - getTitle, - getStatus, -} from '../helpers'; +import { getTextualSummary, getTitle, getStatus } from '../helpers'; import { getData, update } from '../../helpers/http'; import { getApiUrl, bzBaseUrl, createQueryParams } from '../../helpers/url'; import { endpoints, summaryStatusMap } from '../constants'; @@ -196,7 +191,7 @@ export default class StatusDropdown extends React.Component { color="transparent" caret > - {getAlertSummaryStatusText(alertSummary)} + {getStatus(alertSummary.status)} Copy Summary diff --git a/ui/perfherder/compare/CompareSubtestDistributionView.jsx b/ui/perfherder/compare/CompareSubtestDistributionView.jsx index 96d23e230..b76bd2b2a 100644 --- a/ui/perfherder/compare/CompareSubtestDistributionView.jsx +++ b/ui/perfherder/compare/CompareSubtestDistributionView.jsx @@ -1,7 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCog } from '@fortawesome/free-solid-svg-icons'; import { react2angular } from 'react2angular/index.es2015'; import { Container, Row } from 'reactstrap'; @@ -10,6 +8,7 @@ import RepositoryModel from '../../models/repository'; import PushModel from '../../models/push'; import { getData } from '../../helpers/http'; import { createApiUrl, perfSummaryEndpoint } from '../../helpers/url'; +import LoadingSpinner from '../../shared/LoadingSpinner'; import RevisionInformation from './RevisionInformation'; import ReplicatesGraph from './ReplicatesGraph'; @@ -163,14 +162,7 @@ export default class CompareSubtestDistributionView extends React.Component { newRevision && ( {dataLoading ? ( -
- -
+ ) : ( diff --git a/ui/perfherder/compare/CompareTableView.jsx b/ui/perfherder/compare/CompareTableView.jsx index 1b8462993..793027628 100644 --- a/ui/perfherder/compare/CompareTableView.jsx +++ b/ui/perfherder/compare/CompareTableView.jsx @@ -1,8 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Alert, Col, Row, Container } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCog } from '@fortawesome/free-solid-svg-icons'; import ErrorMessages from '../../shared/ErrorMessages'; import { @@ -20,6 +18,7 @@ import { } from '../../helpers/url'; import { getFrameworkData } from '../helpers'; import TruncatedText from '../../shared/TruncatedText'; +import LoadingSpinner from '../../shared/LoadingSpinner'; import RevisionInformation from './RevisionInformation'; import CompareTableControls from './CompareTableControls'; @@ -207,16 +206,8 @@ export default class CompareTableView extends React.Component { return ( - {loading && !failureMessage && ( -
- -
- )} + {loading && !failureMessage && } + - - + ) : ( { + // we either have partial information provided by the selected + // query parameter or the full dataPoint object provided from the + // graph library + const datum = dataPoint.datum ? dataPoint.datum : dataPoint; + const testDetails = testData.find( + item => item.signature_id === datum.signature_id, + ); + + const flotIndex = testDetails.data.findIndex( + item => item.pushId === datum.pushId, + ); + const dataPointDetails = testDetails.data[flotIndex]; + + const retriggers = countBy(testDetails.resultSetData, resultSetId => + resultSetId === dataPoint.pushId ? 'retrigger' : 'original', + ); + const retriggerNum = retriggers.retrigger - 1; + const prevFlotDataPointIndex = flotIndex - 1; + const value = dataPointDetails.y; + + const v0 = + prevFlotDataPointIndex !== -1 + ? testDetails.data[prevFlotDataPointIndex].y + : value; + const deltaValue = value - v0; + const deltaPercent = value / v0 - 1; + let alert; + let alertStatus; + + if (dataPointDetails.alertSummary && dataPointDetails.alertSummary.alerts) { + alert = dataPointDetails.alertSummary.alerts.find( + alert => alert.series_signature.id === testDetails.signature_id, + ); + } + + if (alert) { + alertStatus = + alert.status === alertStatusMap.acknowledged + ? getStatus(testDetails.alertSummary.status) + : getStatus(alert.status, alertStatusMap); + } + + const repository_name = projects.find( + repository_name => repository_name.name === testDetails.repository_name, + ); + + let prevRevision; + let prevPushId; + let pushUrl; + if (prevFlotDataPointIndex !== -1) { + prevRevision = testDetails.data[prevFlotDataPointIndex].revision; + prevPushId = testDetails.data[prevFlotDataPointIndex].pushId; + const repoModel = new RepositoryModel(repository_name); + pushUrl = repoModel.getPushLogRangeHref({ + fromchange: prevRevision, + tochange: dataPointDetails.revision, + }); + } + + const jobsUrl = `${getJobsUrl({ + repo: testDetails.repository_name, + revision: dataPointDetails.revision, + })}${createQueryParams({ + selectedJob: dataPointDetails.jobId, + group_state: 'expanded', + })}`; + + // TODO refactor create to use getData wrapper + const createAlert = () => + create(getApiUrl(endpoints.alertSummary), { + repository_id: testDetails.projectId, + framework_id: testDetails.framework_id, + push_id: dataPointDetails.pushId, + prev_push_id: prevPushId, + }) + .then(response => response.json()) + .then(response => { + const newAlertSummaryId = response.alert_summary_id; + return create(getApiUrl('/performance/alert/'), { + summary_id: newAlertSummaryId, + signature_id: testDetails.signature_id, + }).then(() => + updateData( + testDetails.signature_id, + testDetails.projectId, + newAlertSummaryId, + flotIndex, + ), + ); + }); + + return ( +
+
+

({testDetails.repository_name})

+

{testDetails.platform}

+
+
+

+ {displayNumber(value)} + + {testDetails.lowerIsBetter + ? ' (lower is better)' + : ' (higher is better)'} + +

+

+ Δ {displayNumber(deltaValue.toFixed(1))} ( + {(100 * deltaPercent).toFixed(1)}%) +

+
+ +
+ {prevRevision && ( + + + {dataPointDetails.revision.slice(0, 13)} + {' '} + ( + {dataPointDetails.jobId && ( + + job + + )} + ,{' '} + + compare + + ) + + )} + {dataPointDetails.alertSummary && ( +

+ + + {` Alert # ${dataPointDetails.alertSummary.id}`} + + + {` - ${alertStatus} `} + {alert.related_summary_id && ( + + {alert.related_summary_id !== dataPointDetails.alertSummary.id + ? 'to' + : 'from'} + {` alert # ${alert.related_summary_id}`} + + )} + +

+ )} + {!dataPointDetails.alertSummary && prevPushId && ( +

+ {user.isStaff ? ( + + ) : ( + (log in as a a sheriff to create) + )} +

+ )} +

{`${moment + .utc(dataPointDetails.x) + .format('MMM DD hh:mm:ss')} UTC`}

+ {Boolean(retriggerNum) && ( +

{`Retriggers: ${retriggerNum}`}

+ )} +
+
+ ); +}; + +GraphTooltip.propTypes = { + dataPoint: PropTypes.shape({}).isRequired, + testData: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + user: PropTypes.shape({}).isRequired, + updateData: PropTypes.func.isRequired, + projects: PropTypes.arrayOf(PropTypes.shape({})), +}; + +GraphTooltip.defaultProps = { + projects: [], +}; + +export default GraphTooltip; diff --git a/ui/perfherder/graphs/GraphsContainer.jsx b/ui/perfherder/graphs/GraphsContainer.jsx new file mode 100644 index 000000000..1074b2448 --- /dev/null +++ b/ui/perfherder/graphs/GraphsContainer.jsx @@ -0,0 +1,499 @@ +/* eslint-disable react/no-did-update-set-state */ + +// disabling due to a new bug with this rule: https://github.com/eslint/eslint/issues/12117 +/* eslint-disable no-unused-vars */ +import React from 'react'; +import { Row, Col } from 'reactstrap'; +import PropTypes from 'prop-types'; +import { + VictoryChart, + VictoryLine, + VictoryAxis, + VictoryBrushContainer, + VictoryScatter, + VictoryZoomContainer, +} from 'victory'; +import moment from 'moment'; +import debounce from 'lodash/debounce'; +import last from 'lodash/last'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimes, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; + +import SimpleTooltip from '../../shared/SimpleTooltip'; + +import GraphTooltip from './GraphTooltip'; + +class GraphsContainer extends React.Component { + constructor(props) { + super(props); + this.updateZoom = debounce(this.updateZoom.bind(this), 500); + this.hideTooltip = debounce(this.hideTooltip.bind(this), 250); + this.tooltip = React.createRef(); + this.leftChartPadding = 25; + this.state = { + highlights: [], + scatterPlotData: this.props.testData.flatMap(item => + item.visible ? item.data : [], + ), + entireDomain: this.getEntireDomain(), + showTooltip: false, + lockTooltip: false, + dataPoint: this.props.selectedDataPoint, + }; + } + + componentDidMount() { + const { zoom, selectedDataPoint } = this.props; + + this.addHighlights(); + this.updateData(zoom); + if (selectedDataPoint) this.verifySelectedDataPoint(); + } + + componentDidUpdate(prevProps) { + const { + highlightAlerts, + highlightedRevisions, + testData, + timeRange, + } = this.props; + + if ( + prevProps.highlightAlerts !== highlightAlerts || + prevProps.highlightedRevisions !== highlightedRevisions + ) { + this.addHighlights(); + } + + if (prevProps.testData !== testData) { + this.updateGraphs(); + } + + if (prevProps.timeRange !== timeRange && this.state.dataPoint) { + this.setState({ + dataPoint: null, + showTooltip: false, + lockTooltip: false, + }); + } + } + + verifySelectedDataPoint = () => { + const { selectedDataPoint, testData, updateStateParams } = this.props; + + const dataPointFound = testData.find(item => { + if (item.signature_id === selectedDataPoint.signature_id) { + return item.data.find( + datum => datum.pushId === selectedDataPoint.pushId, + ); + } + return false; + }); + + if (dataPointFound) { + this.setState({ dataPoint: selectedDataPoint }); + this.showTooltip(selectedDataPoint, true); + } else { + updateStateParams({ + errorMessages: [ + `Tooltip for datapoint with signature ${ + selectedDataPoint.signature_id + } and date ${moment + .utc(selectedDataPoint.x) + .format('MMM DD hh:mm')} UTC can't be found.`, + ], + }); + } + }; + + updateGraphs = () => { + const { testData, updateStateParams } = this.props; + const entireDomain = this.getEntireDomain(); + const scatterPlotData = testData.flatMap(item => + item.visible ? item.data : [], + ); + this.addHighlights(); + this.setState({ + entireDomain, + scatterPlotData, + }); + updateStateParams({ zoom: {} }); + }; + + getEntireDomain = () => { + const { testData } = this.props; + const data = testData.flatMap(item => (item.visible ? item.data : [])); + const yValues = data.map(item => item.y); + + if (!data.length) { + return {}; + } + return { + y: [Math.min(...yValues), Math.max(...yValues)], + x: [data[0].x, last(data).x], + }; + }; + + addHighlights = () => { + const { testData, highlightAlerts, highlightedRevisions } = this.props; + let highlights = []; + + for (const series of testData) { + if (!series.visible) { + continue; + } + + if (highlightAlerts) { + const dataPoints = series.data.filter(item => item.alertSummary); + highlights = [...highlights, ...dataPoints]; + } + + for (const rev of highlightedRevisions) { + if (!rev) { + continue; + } + // in case people are still using 12 character sha + const dataPoint = series.data.find( + item => item.revision.indexOf(rev) !== -1, + ); + + if (dataPoint) { + highlights.push(dataPoint); + } + } + } + this.setState({ highlights }); + }; + + getTooltipPosition = (point, yOffset = 15) => ({ + left: point.x - 280 / 2, + top: point.y - yOffset, + }); + + showTooltip = (dataPoint, lock) => { + const position = this.getTooltipPosition(dataPoint); + this.hideTooltip.cancel(); + this.tooltip.current.style.cssText = `left: ${position.left}px; top: ${position.top}px;`; + + this.setState({ + showTooltip: true, + lockTooltip: lock, + dataPoint, + }); + }; + + setTooltip = (dataPoint, lock = false) => { + const { lockTooltip } = this.state; + const { updateStateParams } = this.props; + + // we don't want the mouseOver event to reposition the tooltip + if (lockTooltip && !lock) { + return; + } + this.showTooltip(dataPoint, lock); + + if (lock) { + updateStateParams({ + selectedDataPoint: { + signature_id: dataPoint.datum.signature_id, + pushId: dataPoint.datum.pushId, + x: dataPoint.x, + y: dataPoint.y, + }, + }); + } + }; + + closeTooltip = () => { + this.setState({ + showTooltip: false, + lockTooltip: false, + dataPoint: null, + }); + this.props.updateStateParams({ selectedDataPoint: null }); + }; + + // The Victory library doesn't provide a way of dynamically setting the left + // padding for the y axis tick labels, so this is a workaround (setting state + // doesn't work with this callback, which is why a class property is used instead) + setLeftPadding = (tick, index, ticks) => { + const highestTickLength = ticks[ticks.length - 1].toString(); + const newLeftPadding = highestTickLength.length * 8 + 10; + this.leftChartPadding = + this.leftChartPadding > newLeftPadding + ? this.leftChartPadding + : newLeftPadding; + const numberFormat = new Intl.NumberFormat(); + return numberFormat.format(tick); + }; + + updateData(zoom) { + const { testData } = this.props; + + // we do this along with debouncing updateZoom to make zooming + // faster by removing unneeded data points based on the updated x,y + if (zoom.x && zoom.y) { + const scatterPlotData = testData + .flatMap(item => (item.visible ? item.data : [])) + .filter( + data => + data.x >= zoom.x[0] && + data.x <= zoom.x[1] && + data.y >= zoom.y[0] && + data.y <= zoom.y[1], + ); + this.setState({ scatterPlotData }); + } + } + + // debounced + hideTooltip() { + const { showTooltip, lockTooltip } = this.state; + + if (showTooltip && !lockTooltip) { + this.setState({ showTooltip: false }); + } + } + + // debounced + updateZoom(zoom) { + const { showTooltip, lockTooltip } = this.state; + + if (showTooltip && lockTooltip) { + this.closeTooltip(); + } + + this.props.updateStateParams({ zoom }); + this.updateData(zoom); + } + + render() { + const { testData, zoom, highlightedRevisions } = this.props; + const { + highlights, + scatterPlotData, + entireDomain, + showTooltip, + lockTooltip, + dataPoint, + } = this.state; + + const highlightPoints = !!highlights.length; + + const hasHighlightedRevision = point => + highlightedRevisions.find(rev => point.revision.indexOf(rev) !== -1); + + const axisStyle = { + grid: { stroke: 'lightgray', strokeWidth: 0.5 }, + tickLabels: { fontSize: 13 }, + }; + + const chartPadding = { top: 10, right: 10, bottom: 50 }; + chartPadding.left = this.leftChartPadding; + + return ( + +
+ + + + {dataPoint && showTooltip && ( + + )} +
+
+ + + + } + > + + moment.utc(x).format('MMM DD')} + style={axisStyle} + /> + {testData.map(item => ( + + ))} + + + + + } + tooltipText="The bottom graph has mouse zoom enabled. When there's a large amount of data points, use the overview graph's selection marquee to narrow the x and y range before zooming with the mouse." + /> + + + + + + + } + > + {highlights.length > 0 && + highlights.map(item => ( + item.x} + /> + ))} + + + (data.alertSummary || hasHighlightedRevision(data)) && + highlightPoints + ? data.z + : '#fff', + strokeOpacity: data => + (data.alertSummary || hasHighlightedRevision(data)) && + highlightPoints + ? 0.3 + : 100, + stroke: d => d.z, + strokeWidth: data => + (data.alertSummary || hasHighlightedRevision(data)) && + highlightPoints + ? 12 + : 2, + }, + }} + size={() => 5} + data={scatterPlotData} + events={[ + { + target: 'data', + eventHandlers: { + onClick: () => { + return [ + { + target: 'data', + mutation: props => this.setTooltip(props, true), + }, + ]; + }, + onMouseOver: () => { + return [ + { + target: 'data', + mutation: props => this.setTooltip(props), + }, + ]; + }, + onMouseOut: () => { + return [ + { + target: 'data', + callback: this.hideTooltip, + }, + ]; + }, + }, + }, + ]} + /> + + moment.utc(x).format('MMM DD hh:mm')} + style={axisStyle} + fixLabelOverlap + /> + + + + + ); + } +} + +GraphsContainer.propTypes = { + testData: PropTypes.arrayOf(PropTypes.shape({})), + updateStateParams: PropTypes.func.isRequired, + zoom: PropTypes.shape({}), + selectedDataPoint: PropTypes.shape({}), + highlightAlerts: PropTypes.bool, + highlightedRevisions: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]), + user: PropTypes.shape({}).isRequired, + timeRange: PropTypes.shape({}).isRequired, +}; + +GraphsContainer.defaultProps = { + testData: [], + zoom: {}, + selectedDataPoint: undefined, + highlightAlerts: true, + highlightedRevisions: ['', ''], +}; + +export default GraphsContainer; diff --git a/ui/perfherder/graphs/GraphsView.jsx b/ui/perfherder/graphs/GraphsView.jsx new file mode 100644 index 000000000..306b607ec --- /dev/null +++ b/ui/perfherder/graphs/GraphsView.jsx @@ -0,0 +1,496 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular/index.es2015'; +import { Container, Col, Row } from 'reactstrap'; +import unionBy from 'lodash/unionBy'; + +import { getData, processResponse, processErrors } from '../../helpers/http'; +import { + getApiUrl, + repoEndpoint, + createApiUrl, + perfSummaryEndpoint, + createQueryParams, +} from '../../helpers/url'; +import { + phTimeRanges, + phDefaultTimeRangeValue, + genericErrorMessage, + errorMessageClass, +} from '../../helpers/constants'; +import perf from '../../js/perf'; +import { processSelectedParam } from '../helpers'; +import { endpoints, graphColors } from '../constants'; +import ErrorMessages from '../../shared/ErrorMessages'; +import ErrorBoundary from '../../shared/ErrorBoundary'; +import LoadingSpinner from '../../shared/LoadingSpinner'; + +import GraphsContainer from './GraphsContainer'; +import LegendCard from './LegendCard'; +import GraphsViewControls from './GraphsViewControls'; + +class GraphsView extends React.Component { + constructor(props) { + super(props); + this.state = { + timeRange: this.getDefaultTimeRange(), + frameworks: [], + projects: [], + zoom: {}, + selectedDataPoint: null, + highlightAlerts: true, + highlightedRevisions: ['', ''], + testData: [], + errorMessages: [], + options: {}, + loading: false, + colors: [...graphColors], + showModal: false, + }; + } + + async componentDidMount() { + this.getData(); + this.checkQueryParams(); + } + + getDefaultTimeRange = () => { + const { $stateParams } = this.props; + + const defaultValue = $stateParams.timerange + ? parseInt($stateParams.timerange, 10) + : phDefaultTimeRangeValue; + return phTimeRanges.find(time => time.value === defaultValue); + }; + + async getData() { + const [projects, frameworks] = await Promise.all([ + getData(getApiUrl(repoEndpoint)), + getData(getApiUrl(endpoints.frameworks)), + ]); + + const updates = { + ...processResponse(projects, 'projects'), + ...processResponse(frameworks, 'frameworks'), + }; + this.setState(updates); + } + + checkQueryParams = () => { + const { + series, + zoom, + selected, + highlightAlerts, + highlightedRevisions, + } = this.props.$stateParams; + + const updates = {}; + + if (series) { + const _series = typeof series === 'string' ? [series] : series; + const seriesParams = this.parseSeriesParam(_series); + this.getTestData(seriesParams, true); + } + + if (highlightAlerts) { + updates.highlightAlerts = Boolean(parseInt(highlightAlerts, 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, signature_id, framework_id } = series; + const { timeRange } = this.state; + + return { + repository: repository_name, + signature: signature_id, + framework: framework_id, + interval: timeRange.value, + all_data: true, + }; + }; + + 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(perfSummaryEndpoint, 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 }, () => { + if (!init) { + // we don't need to change params when getData is called on initial page load + this.changeParams(); + } + }); + } + }; + + createGraphObject = async seriesData => { + const { colors } = this.state; + let alertSummaries = await Promise.all( + seriesData.map(series => + this.getAlertSummaries(series.signature_id, series.repository_id), + ), + ); + alertSummaries = alertSummaries.flat(); + + let relatedAlertSummaries; + let color; + const newColors = [...colors]; + + const graphData = seriesData.map(series => { + relatedAlertSummaries = alertSummaries.find( + item => item.id === series.id, + ); + color = newColors.pop(); + // signature_id, framework_id and repository_name are + // not renamed in camel case in order to match the fields + // returned by the performance/summary API (since we only fetch + // new data if a user adds additional tests to the graph) + return { + color: color || ['border-secondary', ''], + relatedAlertSummaries, + visible: Boolean(color), + name: series.name, + signature_id: series.signature_id, + signatureHash: series.signature_hash, + framework_id: series.framework_id, + platform: series.platform, + repository_name: series.repository_name, + projectId: series.repository_id, + id: `${series.repository_name} ${series.name}`, + data: series.data.map(dataPoint => ({ + x: new Date(dataPoint.push_timestamp), + y: dataPoint.value, + z: color ? color[1] : '', + revision: dataPoint.revision, + alertSummary: alertSummaries.find( + item => item.revision === dataPoint.revision, + ), + signature_id: series.signature_id, + pushId: dataPoint.push_id, + jobId: dataPoint.job_id, + })), + lowerIsBetter: series.lower_is_better, + resultSetData: series.data.map(dataPoint => dataPoint.push_id), + }; + }); + this.setState({ colors: newColors }); + return graphData; + }; + + getAlertSummaries = async (signature_id, repository) => { + const { errorMessages } = this.state; + + const url = getApiUrl( + `${endpoints.alertSummary}${createQueryParams({ + alerts__series_signature: signature_id, + repository, + })}`, + ); + + const data = await getData(url); + const response = processResponse(data, 'alertSummaries', errorMessages); + + if (response.alertSummaries) { + return response.alertSummaries.results; + } + this.setState({ errorMessages: response.errorMessages }); + return []; + }; + + updateData = async ( + signature_id, + repository_name, + alertSummaryId, + dataPointIndex, + ) => { + const { testData } = this.state; + + const updatedData = testData.find( + test => test.signature_id === signature_id, + ); + const alertSummaries = await this.getAlertSummaries( + signature_id, + repository_name, + ); + 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 => + series.map(encodedSeries => { + const partialSeriesString = decodeURIComponent(encodedSeries).replace( + /[[\]"]/g, + '', + ); + const partialSeriesArray = partialSeriesString.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), + }; + + return partialSeriesObject; + }); + + toggle = state => { + this.setState(prevState => ({ + [state]: !prevState[state], + })); + }; + + updateParams = params => { + const { transitionTo, current } = this.props.$state; + + transitionTo('graphs', params, { + location: true, + inherit: true, + relative: current, + notify: false, + }); + }; + + changeParams = () => { + const { + testData, + selectedDataPoint, + zoom, + highlightAlerts, + highlightedRevisions, + timeRange, + } = this.state; + + const newSeries = testData.map( + series => + `${series.repository_name},${series.signature_id},1,${series.framework_id}`, + ); + const params = { + series: newSeries, + highlightedRevisions: highlightedRevisions.filter(rev => rev.length), + highlightAlerts: +highlightAlerts, + timerange: timeRange.value, + zoom, + }; + + if (!selectedDataPoint) { + params.selected = null; + } else { + const { signature_id, pushId, x, y } = selectedDataPoint; + params.selected = [signature_id, pushId, x, y].join(','); + } + + if (Object.keys(zoom).length === 0) { + params.zoom = null; + } else { + params.zoom = [...zoom.x.map(z => z.getTime()), ...zoom.y].toString(); + } + + this.updateParams(params); + }; + + render() { + const { + timeRange, + projects, + frameworks, + testData, + highlightAlerts, + highlightedRevisions, + selectedDataPoint, + loading, + errorMessages, + zoom, + options, + colors, + showModal, + } = this.state; + + return ( + + + {loading && } + + {errorMessages.length > 0 && ( + + + + )} + + + + + {testData.length > 0 && + testData.map(series => ( +
+ this.setState(state)} + updateStateParams={state => + this.setState(state, this.changeParams) + } + colors={colors} + selectedDataPoint={selectedDataPoint} + /> +
+ ))} +
+ + + this.setState({ showModal: !showModal })} + graphs={ + testData.length > 0 && ( + + this.setState(state, this.changeParams) + } + user={this.props.user} + updateData={this.updateData} + projects={projects} + /> + ) + } + updateStateParams={state => + this.setState(state, this.changeParams) + } + highlightAlerts={highlightAlerts} + highlightedRevisions={highlightedRevisions} + updateTimeRange={timeRange => + this.setState( + { + timeRange, + zoom: {}, + selectedDataPoint: null, + colors: [...graphColors], + }, + this.getTestData, + ) + } + hasNoData={!testData.length && !loading} + /> + +
+
+
+ ); + } +} + +GraphsView.propTypes = { + $stateParams: 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), + ]), + }), + $state: PropTypes.shape({ + current: PropTypes.shape({}), + transitionTo: PropTypes.func, + }), + user: PropTypes.shape({}).isRequired, +}; + +GraphsView.defaultProps = { + $stateParams: undefined, + $state: undefined, +}; + +perf.component( + 'graphsView', + react2angular(GraphsView, ['user'], ['$stateParams', '$state']), +); + +export default GraphsView; diff --git a/ui/perfherder/graphs/GraphsViewControls.jsx b/ui/perfherder/graphs/GraphsViewControls.jsx new file mode 100644 index 000000000..43ac089b8 --- /dev/null +++ b/ui/perfherder/graphs/GraphsViewControls.jsx @@ -0,0 +1,155 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Container, + Col, + Row, + UncontrolledDropdown, + DropdownToggle, + Input, +} from 'reactstrap'; + +import { phTimeRanges } from '../../helpers/constants'; +import DropdownMenuItems from '../../shared/DropdownMenuItems'; + +import TestDataModal from './TestDataModal'; + +export default class GraphsViewControls extends React.Component { + changeHighlightedRevision = (index, newValue) => { + const { highlightedRevisions, updateStateParams } = this.props; + + const newRevisions = [...highlightedRevisions]; + newRevisions.splice(index, 1, newValue); + + updateStateParams({ highlightedRevisions: newRevisions }); + }; + + render() { + const { + timeRange, + graphs, + updateStateParams, + highlightAlerts, + highlightedRevisions, + updateTimeRange, + hasNoData, + projects, + frameworks, + toggle, + showModal, + } = this.props; + + return ( + + {projects.length > 0 && frameworks.length > 0 && ( + + )} + + + + {timeRange.text} + item.text)} + selectedItem={timeRange.text} + updateData={value => + updateTimeRange( + phTimeRanges.find(item => item.text === value), + ) + } + /> + + + + + + + + {hasNoData ? ( + +

+ Nothing here yet. Add test data to plot graphs. +

+
+ ) : ( + + {graphs} + + {highlightedRevisions.length > 0 && + highlightedRevisions.map((revision, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + this.changeHighlightedRevision( + index, + event.target.value, + ) + } + /> + + ))} + + + + + + )} +
+ ); + } +} + +GraphsViewControls.propTypes = { + updateStateParams: PropTypes.func.isRequired, + graphs: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.bool]).isRequired, + timeRange: PropTypes.shape({}).isRequired, + highlightAlerts: PropTypes.bool.isRequired, + highlightedRevisions: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]).isRequired, + updateTimeRange: PropTypes.func.isRequired, + hasNoData: PropTypes.bool.isRequired, + projects: PropTypes.arrayOf(PropTypes.shape({})), + getTestData: PropTypes.func.isRequired, + options: PropTypes.shape({ + option: PropTypes.string, + relatedSeries: PropTypes.shape({}), + }), + testData: PropTypes.arrayOf(PropTypes.shape({})), + frameworks: PropTypes.arrayOf(PropTypes.shape({})), + showModal: PropTypes.bool, + toggle: PropTypes.func.isRequired, +}; + +GraphsViewControls.defaultProps = { + frameworks: [], + projects: [], + options: undefined, + testData: [], + showModal: false, +}; diff --git a/ui/perfherder/graphs/LegendCard.jsx b/ui/perfherder/graphs/LegendCard.jsx new file mode 100644 index 000000000..e9f870681 --- /dev/null +++ b/ui/perfherder/graphs/LegendCard.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup, Input } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; + +const LegendCard = ({ + series, + testData, + updateState, + updateStateParams, + selectedDataPoint, + colors, +}) => { + const updateSelectedTest = () => { + const newColors = [...colors]; + const errorMessages = []; + let updates; + const newTestData = [...testData].map(item => { + if (item.signature_id === series.signature_id) { + const isVisible = !item.visible; + + if (isVisible && newColors.length) { + item.color = newColors.pop(); + item.visible = isVisible; + } else if (!isVisible) { + newColors.push(item.color); + item.color = ['border-secondary', '']; + item.visible = isVisible; + } else { + errorMessages.push( + "The graph supports viewing 6 tests at a time. To select and view a test that isn't currently visible, first deselect a visible test", + ); + } + } + return item; + }); + + if (errorMessages.length) { + updates = { errorMessages }; + } else { + updates = { testData: newTestData, colors: newColors, errorMessages }; + } + updateStateParams(updates); + }; + + const addTestData = option => { + const options = { option, relatedSeries: series }; + updateState({ options, showModal: true }); + }; + + const resetParams = testData => { + const updates = { testData, colors: [...colors, ...[series.color]] }; + + if ( + selectedDataPoint && + selectedDataPoint.signature_id === series.signature_id + ) { + updates.selectedDataPoint = null; + } + + if (testData.length === 0) { + updates.highlightedRevisions = ['', '']; + updates.zoom = {}; + } + updateStateParams(updates); + }; + + const removeTest = () => { + const index = testData.findIndex(test => test === series); + const newData = [...testData]; + + if (index === -1) { + return; + } + + newData.splice(index, 1); + resetParams(newData); + }; + + const subtitleStyle = 'p-0 mb-0 border-0 text-secondary text-left'; + + return ( + + + + +
+

addTestData('addRelatedConfigs')} + title="Add related configurations" + type="button" + > + {series.name} +

+

addTestData('addRelatedBranches')} + title="Add related branches" + type="button" + > + {series.repository_name} +

+

addTestData('addRelatedPlatform')} + title="Add related platforms" + type="button" + > + {series.platform} +

+ {`${series.signatureHash.slice( + 0, + 16, + )}...`} +
+ +
+ ); +}; + +LegendCard.propTypes = { + series: PropTypes.PropTypes.shape({ + visible: PropTypes.bool, + }).isRequired, + updateState: PropTypes.func.isRequired, + testData: PropTypes.arrayOf(PropTypes.shape({})), + updateStateParams: PropTypes.func.isRequired, + colors: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)).isRequired, + selectedDataPoint: PropTypes.shape({}), +}; + +LegendCard.defaultProps = { + testData: [], + selectedDataPoint: null, +}; + +export default LegendCard; diff --git a/ui/perfherder/graphs/SelectedTestsContainer.jsx b/ui/perfherder/graphs/SelectedTestsContainer.jsx deleted file mode 100644 index 27b1a1beb..000000000 --- a/ui/perfherder/graphs/SelectedTestsContainer.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular/index.es2015'; -import { Container } from 'reactstrap'; - -import perf from '../../js/perf'; - -import TestCard from './TestCard'; - -const SelectedTestsContainer = props => { - // TODO seriesList is the same as testsDisplayed in TestDataModel - change - // name to keep it consistent - const { seriesList } = props; - - return ( - - {seriesList.length > 0 && - seriesList.map(series => ( -
- -
- ))} -
- ); -}; - -SelectedTestsContainer.propTypes = { - seriesList: PropTypes.arrayOf(PropTypes.shape({})), -}; - -SelectedTestsContainer.defaultProps = { - seriesList: undefined, -}; - -perf.component( - 'selectedTestsContainer', - react2angular( - SelectedTestsContainer, - ['seriesList', 'addTestData', 'removeSeries', 'showHideSeries'], - ['$stateParams', '$state'], - ), -); - -export default SelectedTestsContainer; diff --git a/ui/perfherder/graphs/TestCard.jsx b/ui/perfherder/graphs/TestCard.jsx deleted file mode 100644 index 0904b1c06..000000000 --- a/ui/perfherder/graphs/TestCard.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { FormGroup, Input } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; - -export class TestContainer extends React.Component { - constructor(props) { - super(props); - this.state = { - checked: this.props.series.visible, - }; - } - - updateSelectedTest = () => { - const { checked } = this.state; - const { series, showHideSeries } = this.props; - this.setState({ checked: !checked }); - showHideSeries(series.signature); - }; - - render() { - const { series, addTestData, removeSeries } = this.props; - const { checked } = this.state; - const subtitleStyle = 'p-0 mb-0 border-0 text-secondary text-left'; - - return ( - - removeSeries(series.projectName, series.signature)} - > - - -
-

addTestData('addRelatedConfigs', series.signature)} - title="Add related configurations" - type="button" - > - {series.name} -

-

addTestData('addRelatedBranches', series.signature)} - title="Add related branches" - type="button" - > - {series.projectName} -

-

addTestData('addRelatedPlatform', series.signature)} - title="Add related branches" - type="button" - > - {series.platform} -

- {`${series.signature.slice( - 0, - 16, - )}...`} -
- -
- ); - } -} - -TestContainer.propTypes = { - series: PropTypes.PropTypes.shape({ - visible: PropTypes.bool, - }).isRequired, - addTestData: PropTypes.func, - removeSeries: PropTypes.func, - showHideSeries: PropTypes.func, -}; - -TestContainer.defaultProps = { - showHideSeries: undefined, - addTestData: undefined, - removeSeries: undefined, -}; - -export default TestContainer; diff --git a/ui/perfherder/graphs/TestDataModal.jsx b/ui/perfherder/graphs/TestDataModal.jsx index a51ba140d..4590bfa75 100644 --- a/ui/perfherder/graphs/TestDataModal.jsx +++ b/ui/perfherder/graphs/TestDataModal.jsx @@ -1,26 +1,35 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular/index.es2015'; -import { Button, Col, Form, Input, Label, ModalBody, Row } from 'reactstrap'; +import { + Button, + Col, + Form, + Input, + Label, + Modal, + ModalHeader, + ModalBody, + Row, +} from 'reactstrap'; -import perf from '../../js/perf'; import { createDropdowns } from '../FilterControls'; import InputFilter from '../InputFilter'; -import { getData, processResponse } from '../../helpers/http'; -import { getApiUrl } from '../../helpers/url'; -import { endpoints } from '../constants'; +import { processResponse } from '../../helpers/http'; import PerfSeriesModel from '../../models/perfSeries'; import { thPerformanceBranches } from '../../helpers/constants'; -import { containsText } from '../helpers'; +import { containsText, getInitialData, getSeriesData } from '../helpers'; -export class TestDataModal extends React.Component { +export default class TestDataModal extends React.Component { constructor(props) { super(props); this.state = { - frameworks: [], platforms: [], framework: { name: 'talos', id: 1 }, - project: this.findObject(this.props.repos, 'name', 'mozilla-central'), + repository_name: this.findObject( + this.props.projects, + 'name', + 'mozilla-central', + ), platform: 'linux64', errorMessages: [], includeSubtests: false, @@ -30,15 +39,25 @@ export class TestDataModal extends React.Component { filteredData: [], showNoRelatedTests: false, filterText: '', + loading: true, }; } - componentDidMount() { - this.getInitialData(); + async componentDidMount() { + const { errorMessages, repository_name, framework } = this.state; + const { timeRange, getInitialData } = this.props; + const updates = await getInitialData( + errorMessages, + repository_name, + framework, + timeRange, + ); + this.setState(updates, this.processOptions); } componentDidUpdate(prevProps, prevState) { const { platforms, platform } = this.state; + const { testData } = this.props; if (prevState.platforms !== platforms) { const newPlatform = platforms.find(item => item === platform) @@ -47,55 +66,22 @@ export class TestDataModal extends React.Component { // eslint-disable-next-line react/no-did-update-set-state this.setState({ platform: newPlatform }); } + if (this.props.options !== prevProps.options) { + this.processOptions(true); + } + + if (testData !== prevProps.testData) { + this.processOptions(); + } } - getInitialData = async () => { - const { errorMessages, project, framework } = this.state; - const { timeRange } = this.props; - - const params = { interval: timeRange, framework: framework.id }; - const [frameworks, platforms] = await Promise.all([ - getData(getApiUrl(endpoints.frameworks)), - PerfSeriesModel.getPlatformList(project.name, params), - ]); - - const updates = { - ...processResponse(frameworks, 'frameworks', errorMessages), - ...processResponse(platforms, 'platforms', errorMessages), - }; - - this.setState(updates, this.processOptions); - }; - - getSeriesData = async params => { - const { errorMessages, project } = this.state; - const { testsDisplayed } = this.props; - - let updates = { - filteredData: [], - relatedTests: [], - showNoRelatedTests: false, - }; - const response = await PerfSeriesModel.getSeriesList(project.name, params); - updates = { - ...updates, - ...processResponse(response, 'seriesData', errorMessages), - }; - if (testsDisplayed.length && updates.seriesData) { - updates.seriesData = updates.seriesData.filter( - item => testsDisplayed.findIndex(test => item.id === test.id) === -1, - ); - } - this.setState(updates); - }; - async getPlatforms() { - const { project, framework, errorMessages } = this.state; + const { repository_name, framework, errorMessages } = this.state; const { timeRange } = this.props; - const params = { interval: timeRange, framework: framework.id }; + const params = { interval: timeRange.value, framework: framework.id }; const response = await PerfSeriesModel.getPlatformList( - project.name, + repository_name.name, params, ); @@ -106,9 +92,12 @@ export class TestDataModal extends React.Component { addRelatedConfigs = async params => { const { relatedSeries } = this.props.options; - const { errorMessages, project } = this.state; + const { errorMessages, repository_name } = this.state; - const response = await PerfSeriesModel.getSeriesList(project.name, params); + const response = await PerfSeriesModel.getSeriesList( + repository_name.name, + params, + ); const updates = processResponse(response, 'relatedTests', errorMessages); if (updates.relatedTests.length) { @@ -116,22 +105,26 @@ export class TestDataModal extends React.Component { updates.relatedTests.filter( series => series.platform === relatedSeries.platform && - series.testName === relatedSeries.testName && + series.testName === relatedSeries.test && series.name !== relatedSeries.name, ) || []; updates.relatedTests = tests; } updates.showNoRelatedTests = updates.relatedTests.length === 0; + updates.loading = false; this.setState(updates); }; addRelatedPlatforms = async params => { const { relatedSeries } = this.props.options; - const { errorMessages, project } = this.state; + const { errorMessages, repository_name } = this.state; - const response = await PerfSeriesModel.getSeriesList(project.name, params); + const response = await PerfSeriesModel.getSeriesList( + repository_name.name, + params, + ); const updates = processResponse(response, 'relatedTests', errorMessages); if (updates.relatedTests.length) { @@ -145,6 +138,7 @@ export class TestDataModal extends React.Component { updates.relatedTests = tests; } updates.showNoRelatedTests = updates.relatedTests.length === 0; + updates.loading = false; this.setState(updates); }; @@ -154,7 +148,7 @@ export class TestDataModal extends React.Component { const errorMessages = []; const relatedProjects = thPerformanceBranches.filter( - project => project !== relatedSeries.projectName, + repository_name => repository_name !== relatedSeries.repository_name, ); const requests = relatedProjects.map(projectName => PerfSeriesModel.getSeriesList(projectName, params), @@ -173,41 +167,48 @@ export class TestDataModal extends React.Component { relatedTests, showNoRelatedTests: relatedTests.length === 0, errorMessages, + loading: false, }); }; - processOptions = () => { + processOptions = async (relatedTestsMode = false) => { const { option, relatedSeries } = this.props.options; const { platform, framework, includeSubtests, - relatedTests, - showNoRelatedTests, + errorMessages, + repository_name, } = this.state; - const { timeRange } = this.props; + const { timeRange, getSeriesData, testData } = this.props; const params = { - interval: timeRange, + interval: timeRange.value, framework: framework.id, subtests: +includeSubtests, }; + this.setState({ loading: true }); - // TODO reset option after it's called the first time - // so user can press update to use test filter controls - if (!option || relatedTests.length || showNoRelatedTests) { + if (!relatedTestsMode) { params.platform = platform; - return this.getSeriesData(params); + const updates = await getSeriesData( + params, + errorMessages, + repository_name, + testData, + ); + + this.setState(updates); + return; } - params.framework = relatedSeries.frameworkId; - + params.framework = relatedSeries.framework_id; if (option === 'addRelatedPlatform') { this.addRelatedPlatforms(params); } else if (option === 'addRelatedConfigs') { this.addRelatedConfigs(params); } else if (option === 'addRelatedBranches') { - params.signature = relatedSeries.signature; + params.id = relatedSeries.signature_id; this.addRelatedBranches(params); } }; @@ -241,13 +242,34 @@ export class TestDataModal extends React.Component { getOriginalTestName = test => this.state.relatedTests.length > 0 ? this.getFullTestName(test) : test.name; + closeModal = () => { + this.setState( + { relatedTests: [], filteredData: [], showNoRelatedTests: false }, + this.props.toggle, + ); + }; + + submitData = () => { + const { selectedTests } = this.state; + const { getTestData } = this.props; + + const displayedTestParams = selectedTests.map(series => ({ + repository_name: series.projectName, + signature_id: parseInt(series.id, 10), + framework_id: parseInt(series.frameworkId, 10), + })); + + getTestData(displayedTestParams); + this.setState({ selectedTests: [] }); + this.closeModal(); + }; + render() { const { - frameworks, platforms, seriesData, framework, - project, + repository_name, platform, includeSubtests, selectedTests, @@ -255,8 +277,9 @@ export class TestDataModal extends React.Component { relatedTests, showNoRelatedTests, filterText, + loading, } = this.state; - const { repos, submitData } = this.props; + const { projects, frameworks, showModal } = this.props; const modalOptions = [ { @@ -272,11 +295,11 @@ export class TestDataModal extends React.Component { title: 'Framework', }, { - options: repos.length ? repos.map(item => item.name) : [], - selectedItem: project.name || '', + options: projects.length ? projects.map(item => item.name) : [], + selectedItem: repository_name.name || '', updateData: value => this.setState( - { project: this.findObject(repos, 'name', value) }, + { repository_name: this.findObject(projects, 'name', value) }, this.getPlatforms, ), title: 'Project', @@ -289,131 +312,147 @@ export class TestDataModal extends React.Component { title: 'Platform', }, ]; - let tests = seriesData; + + let tests = []; if (filterText) { tests = filteredData; - } else if (relatedTests.length) { + } else if (relatedTests.length || showNoRelatedTests) { tests = relatedTests; + } else if (seriesData.length && !loading) { + tests = seriesData; } return ( - -
- - {createDropdowns(modalOptions, 'p-2', true)} - - - - - - - 0} - updateFilterText={this.updateFilterText} - /> - - - - - - - {tests.length > 0 && - tests.sort().map(test => ( - - ))} - - {showNoRelatedTests && ( -

No related tests found.

- )} - -
- - - - - {selectedTests.length > 0 && - selectedTests.map(test => ( - - ))} - - {selectedTests.length > 6 && ( -

- Displaying more than 6 graphs at a time is not supported in - the UI. -

- )} - -
- - - - - -
-
+ + Add Test Data + +
+ + {createDropdowns(modalOptions, 'p-2', true)} + + + + + + + 0} + updateFilterText={this.updateFilterText} + /> + + + + + + + {tests.length > 0 && + tests.sort().map(test => ( + + ))} + + {showNoRelatedTests && ( +

No related tests found.

+ )} + +
+ + + + + {selectedTests.length > 0 && + selectedTests.map(test => ( + + ))} + + {selectedTests.length > 6 && ( +

+ Displaying more than 6 graphs at a time is not supported in + the UI. +

+ )} + +
+ + + + + +
+
+
); } } TestDataModal.propTypes = { - repos: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - timeRange: PropTypes.number.isRequired, - submitData: PropTypes.func.isRequired, + projects: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + timeRange: PropTypes.shape({}).isRequired, + getTestData: PropTypes.func.isRequired, options: PropTypes.shape({ option: PropTypes.string, relatedSeries: PropTypes.shape({}), }), - testsDisplayed: PropTypes.arrayOf(PropTypes.shape({})), + testData: PropTypes.arrayOf(PropTypes.shape({})), + frameworks: PropTypes.arrayOf(PropTypes.shape({})), + showModal: PropTypes.bool.isRequired, + toggle: PropTypes.func.isRequired, + getInitialData: PropTypes.func, + getSeriesData: PropTypes.func, }; TestDataModal.defaultProps = { options: undefined, - testsDisplayed: [], + testData: [], + frameworks: [], + getInitialData, + getSeriesData, }; - -perf.component( - 'testDataModal', - react2angular( - TestDataModal, - ['repos', 'testsDisplayed', 'timeRange', 'submitData', 'options'], - [], - ), -); - -export default TestDataModal; diff --git a/ui/perfherder/helpers.js b/ui/perfherder/helpers.js index 5b440e1a9..de382b47e 100644 --- a/ui/perfherder/helpers.js +++ b/ui/perfherder/helpers.js @@ -1,13 +1,14 @@ import numeral from 'numeral'; import sortBy from 'lodash/sortBy'; +import queryString from 'query-string'; -import { getApiUrl, createQueryParams } from '../helpers/url'; -import { create, getData, update } from '../helpers/http'; -import { getSeriesName, getTestName } from '../models/perfSeries'; -import OptionCollectionModel from '../models/optionCollection'; +import { getApiUrl } from '../helpers/url'; +import { update, processResponse } from '../helpers/http'; +import PerfSeriesModel, { + getSeriesName, + getTestName, +} from '../models/perfSeries'; import { - phAlertStatusMap, - phAlertSummaryStatusMap, phFrameworksWithRelatedBranches, phTimeRanges, thPerformanceBranches, @@ -296,7 +297,7 @@ export const getGraphsLink = function getGraphsLink( params.timerange = timeRange; } - return `perf.html#/graphs${createQueryParams(params)}`; + return `perf.html#/graphs?${queryString.stringify(params)}`; }; export const createNoiseMetric = function createNoiseMetric( @@ -315,6 +316,7 @@ export const createNoiseMetric = function createNoiseMetric( return compareResults; }; +// TODO export const createGraphsLinks = ( validatedProps, links, @@ -353,7 +355,6 @@ export const createGraphsLinks = ( return links; }; -// old PhAlerts' inner workings // TODO change all usage of signature_hash to signature.id // for originalSignature and newSignature query params const Alert = (alertData, optionCollectionMap) => ({ @@ -362,23 +363,21 @@ const Alert = (alertData, optionCollectionMap) => ({ includePlatformInName: true, }), }); -// TODO move into graphs component or remove -export const getAlertStatusText = alert => - Object.values(phAlertStatusMap).find(status => status.id === alert.status) - .text; -// TODO look into using signature_id instead of the hash +// TODO look into using signature_id instead of the hash and remove all other params export const getGraphsURL = ( alert, timeRange, alertRepository, performanceFrameworkId, ) => { - let url = `#/graphs?timerange=${timeRange}&series=${alertRepository},${alert.series_signature.id},1`; + let url = `#/graphs?timerange=${timeRange}&series=${alertRepository},${alert.series_signature.id},1,${alert.series_signature.framework_id}`; + // TODO deprecate usage of signature hash // automatically add related branches (we take advantage of // the otherwise rather useless signature hash to avoid having to fetch this // information from the server) + if (phFrameworksWithRelatedBranches.includes(performanceFrameworkId)) { const branches = alertRepository === 'mozilla-beta' @@ -387,7 +386,7 @@ export const getGraphsURL = ( url += branches .map( branch => - `&series=${branch},${alert.series_signature.signature_hash},0`, + `&series=${branch},${alert.series_signature.signature_hash},1,${alert.series_signature.framework_id}`, ) .join(''); } @@ -398,12 +397,6 @@ export const getGraphsURL = ( export const modifyAlert = (alert, modification) => update(getApiUrl(`${endpoints.alert}${alert.id}/`), modification); -// TODO remove after graphs conversion -export const alertIsOfState = (alert, phAlertStatus) => - alert.status === phAlertStatus.id; - -let issueTrackers; // will cache on first AlertSummary call - export const getInitializedAlerts = (alertSummary, optionCollectionMap) => // this function converts the representation returned by the perfherder // api into a representation more suited for display in the UI @@ -487,7 +480,7 @@ export const getTitle = alertSummary => { // we should never include downstream alerts in the description let alertsInSummary = alertSummary.alerts.filter( alert => - alert.status !== phAlertStatusMap.DOWNSTREAM.id || + alert.status !== alertStatusMap.downstream || alert.summary_id === alertSummary.id, ); @@ -527,138 +520,6 @@ export const getTitle = alertSummary => { return title; }; -// TODO replace usage with summaryStatusMap after graphs conversion -export const getAlertSummaryStatusText = alertSummary => - Object.values(phAlertSummaryStatusMap).find( - status => status.id === alertSummary.status, - ).text; - -// TODO remove after graphs conversion -const constructAlertSummary = ( - alertSummaryData, - optionCollectionMap, - issueTrackers, -) => { - const alertSummaryState = { - ...alertSummaryData, - issueTrackers, - alerts: getInitializedAlerts(alertSummaryData, optionCollectionMap), - }; - - return alertSummaryState; -}; - -// TODO remove graphs conversion -export const AlertSummary = async (alertSummaryData, optionCollectionMap) => { - if (issueTrackers === undefined) { - return getData(getApiUrl(endpoints.issueTrackers)).then( - ({ data: issueTrackerList }) => { - issueTrackers = issueTrackerList; - return constructAlertSummary( - alertSummaryData, - optionCollectionMap, - issueTrackers, - ); - }, - ); - } - - return constructAlertSummary( - alertSummaryData, - optionCollectionMap, - issueTrackers, - ); -}; - -// TODO remove after graphs conversion -export const getAlertSummaries = options => { - let { href } = options; - if (!options || !options.href) { - href = getApiUrl(endpoints.alertSummary); - - // add filter parameters for status and framework - const params = []; - if ( - options && - options.statusFilter !== undefined && - options.statusFilter !== -1 - ) { - params[params.length] = `status=${options.statusFilter}`; - } - if (options && options.frameworkFilter !== undefined) { - params[params.length] = `framework=${options.frameworkFilter}`; - } - // TODO replace all usage with createQueryParams except for - // signatureId and seriesSignature (used in graphs controller) - if (options && options.signatureId !== undefined) { - params[params.length] = `alerts__series_signature=${options.signatureId}`; - } - if (options && options.seriesSignature !== undefined) { - params[ - params.length - ] = `alerts__series_signature__signature_hash=${options.seriesSignature}`; - } - if (options && options.repository !== undefined) { - params[params.length] = `repository=${options.repository}`; - } - if (options && options.page !== undefined) { - params[params.length] = `page=${options.page}`; - } - - if (params.length) { - href += `?${params.join('&')}`; - } - } - - return OptionCollectionModel.getMap().then(optionCollectionMap => - getData(href).then(({ data }) => - Promise.all( - data.results.map(alertSummaryData => - AlertSummary(alertSummaryData, optionCollectionMap), - ), - ).then(alertSummaries => ({ - results: alertSummaries, - next: data.next, - count: data.count, - })), - ), - ); -}; - -export const createAlert = data => - create(getApiUrl(endpoints.alertSummary), { - repository_id: data.project.id, - framework_id: data.series.frameworkId, - push_id: data.resultSetId, - prev_push_id: data.prevResultSetId, - }) - .then(response => response.json()) - .then(response => { - const newAlertSummaryId = response.alert_summary_id; - return create(getApiUrl('/performance/alert/'), { - summary_id: newAlertSummaryId, - signature_id: data.series.id, - }).then(() => newAlertSummaryId); - }); - -export const findPushIdNeighbours = (dataPoint, resultSetData, direction) => { - const pushId = dataPoint.resultSetId; - const pushIdIndex = - direction === 'left' - ? resultSetData.indexOf(pushId) - : resultSetData.lastIndexOf(pushId); - const relativePos = direction === 'left' ? -1 : 1; - return { - push_id: resultSetData[pushIdIndex + relativePos], - prev_push_id: resultSetData[pushIdIndex + (relativePos - 1)], - }; -}; - -export const nudgeAlert = (dataPoint, towardsDataPoint) => { - const alertId = dataPoint.alert.id; - return update(getApiUrl(`/performance/alert/${alertId}/`), towardsDataPoint); -}; - export const convertParams = (params, value) => Boolean(params[value] !== undefined && parseInt(params[value], 10)); @@ -687,3 +548,61 @@ export const containsText = (string, text) => { const regex = RegExp(words, 'gi'); return regex.test(string); }; + +export const processSelectedParam = tooltipArray => ({ + signature_id: parseInt(tooltipArray[0], 10), + pushId: parseInt(tooltipArray[1], 10), + x: parseFloat(tooltipArray[2]), + y: parseFloat(tooltipArray[3]), +}); + +export const getInitialData = async ( + errorMessages, + repository_name, + framework, + timeRange, +) => { + const params = { interval: timeRange.value, framework: framework.id }; + const platforms = await PerfSeriesModel.getPlatformList( + repository_name.name, + params, + ); + + const updates = { + ...processResponse(platforms, 'platforms', errorMessages), + }; + return updates; +}; + +export const updateSeriesData = (origSeriesData, testData) => + origSeriesData.filter( + item => testData.findIndex(test => item.id === test.signature_id) === -1, + ); + +export const getSeriesData = async ( + params, + errorMessages, + repository_name, + testData, +) => { + let updates = { + filteredData: [], + relatedTests: [], + showNoRelatedTests: false, + loading: false, + }; + const response = await PerfSeriesModel.getSeriesList( + repository_name.name, + params, + ); + updates = { + ...updates, + ...processResponse(response, 'origSeriesData', errorMessages), + }; + + if (updates.origSeriesData) { + updates.seriesData = updateSeriesData(updates.origSeriesData, testData); + } + + return updates; +}; diff --git a/ui/shared/LoadingSpinner.jsx b/ui/shared/LoadingSpinner.jsx new file mode 100644 index 000000000..12348cd19 --- /dev/null +++ b/ui/shared/LoadingSpinner.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCog } from '@fortawesome/free-solid-svg-icons'; + +const LoadingSpinner = () => ( +
+ +
+); + +export default LoadingSpinner; diff --git a/yarn.lock b/yarn.lock index cc346a031..c3464ae0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2946,7 +2946,7 @@ d3-dsv@1: iconv-lite "0.4" rw "1" -d3-ease@1: +d3-ease@1, d3-ease@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.5.tgz#8ce59276d81241b1b72042d6af2d40e76d936ffb" integrity sha512-Ct1O//ly5y5lFM9YTdu+ygq7LleSgSE4oj7vUt9tPLHUi8VCV7QoizGpdWRWAwCO9LdYzIrQDg97+hGVdsSGPQ== @@ -2985,7 +2985,7 @@ d3-hierarchy@1: resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.8.tgz#7a6317bd3ed24e324641b6f1e76e978836b008cc" integrity sha512-L+GHMSZNwTpiq4rt9GEsNcpLa4M96lXMR8M/nMG9p5hBE0jy6C+3hWtyZMenPQdwla249iJy7Nx0uKt3n+u9+w== -d3-interpolate@1: +d3-interpolate@1, d3-interpolate@^1.1.1: version "1.3.2" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.3.2.tgz#417d3ebdeb4bc4efcc8fd4361c55e4040211fd68" integrity sha512-NlNKGopqaz9qM1PXh9gBF1KSCVh+jSFErrSlD/4hybwoNX/gt1d8CDbDW+3i+5UOHhjC6s6nMvRxcuoMVNgL2w== @@ -3032,12 +3032,25 @@ d3-scale@2: d3-time "1" d3-time-format "2" +d3-scale@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d" + integrity sha512-KvU92czp2/qse5tUfGms6Kjig0AhHOwkzXG0+PqIJB3ke0WUv088AHMZI0OssO9NCkXt4RP8yju9rpH8aGB7Lw== + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-color "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + d3-selection@1, d3-selection@^1.1.0: version "1.4.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.0.tgz#ab9ac1e664cf967ebf1b479cc07e28ce9908c474" integrity sha512-EYVwBxQGEjLCKF2pJ4+yrErskDnz5v403qvAid96cNdCMr8rmCYfY5RGzWz24mdIbxmDf6/4EAH+K9xperD5jg== -d3-shape@1: +d3-shape@1, d3-shape@^1.0.0, d3-shape@^1.2.0: version "1.3.5" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.5.tgz#e81aea5940f59f0a79cfccac012232a8987c6033" integrity sha512-VKazVR3phgD+MUCldapHD7P9kcrvPcexeX/PkMJmkUov4JM8IxsSg1DvbYoYich9AtdTsa5nNk2++ImPiDiSxg== @@ -3056,7 +3069,7 @@ d3-time@1: resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.11.tgz#1d831a3e25cd189eb256c17770a666368762bbce" integrity sha512-Z3wpvhPLW4vEScGeIMUckDW7+3hWKOQfAWg/U7PlWBnQmeKQ00gCUsTtWSYulrKNA7ta8hJ+xXc6MHrMuITwEw== -d3-timer@1: +d3-timer@1, d3-timer@^1.0.0: version "1.0.9" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.9.tgz#f7bb8c0d597d792ff7131e1c24a36dd471a471ba" integrity sha512-rT34J5HnQUHhcLvhSB9GjCkN0Ddd5Y8nCwDBG2u6wQEeYxT/Lf51fTFFkldeib/sE/J0clIe0pnCfs6g/lRbyg== @@ -3073,7 +3086,7 @@ d3-transition@1: d3-selection "^1.1.0" d3-timer "1" -d3-voronoi@1: +d3-voronoi@1, d3-voronoi@^1.1.2: version "1.1.4" resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297" integrity sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg== @@ -3258,6 +3271,18 @@ del@^4.0.0, del@^4.1.1: pify "^4.0.1" rimraf "^2.6.3" +delaunator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-4.0.0.tgz#3630f477b4923f472f534c62a028aeca6fe22d96" + integrity sha512-KzVgOHix5xaIVzZSfbv3Uzw9aI7mQNDet4Yd2p+tBNkfNHMFJbjbVa3q0nC7q7TjWZLX49QbzcT+pXazXX3Qmg== + +delaunay-find@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/delaunay-find/-/delaunay-find-0.0.3.tgz#b9863465c4cbca963b3d75a54550e73b2fc56c30" + integrity sha512-Ex8DtJudrPsB0IhmJxFjHqzZnzbCOoFgw8kTGAnTlc6uU/v25nd7o2HeWhyZSaPhholsfL33PmLSEdaBi0qfug== + dependencies: + delaunator "^4.0.0" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -7631,6 +7656,11 @@ react-dom@16.9.0: prop-types "^15.6.2" scheduler "^0.15.0" +react-fast-compare@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-highlight-words@0.16.0: version "0.16.0" resolved "https://registry.yarnpkg.com/react-highlight-words/-/react-highlight-words-0.16.0.tgz#4b4b9824e3d2b98789d3e3b3aedb5e961ae1b7cf" @@ -9424,6 +9454,286 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +victory-area@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-area/-/victory-area-32.3.2.tgz#3c0e6c9d5480b4f570810f28a1d9ea2fd0e23ffb" + integrity sha512-KonmBC4RdzdHU9hylIUS4Fa3m89P2K+ds27YC4s0Grb97q4FGzBivnkJqIlXJQgsYlIojK1YlUwpcBXKEUHYvQ== + dependencies: + d3-shape "^1.2.0" + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-axis@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-axis/-/victory-axis-32.3.2.tgz#e061aa0628e41a44fed96b28f724011a05e15ce7" + integrity sha512-rIf1h1EbiZY9GVS3IKFW5JDAlYaeDng3vVdjQq8dDo3LU4LJzWGMgD/RT4+skldbeCCTz0qYpjggwSgjRjv8Ew== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-bar@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-bar/-/victory-bar-32.3.2.tgz#7268551c6f9cb00346ceb067f39a94a9f85eff6e" + integrity sha512-Z0rYAF3NU1FfeUqv0p8RH1HpV70F1G6H+3p0MofPnuVKQNtCc/NruJ9ZaS9IYxexdbiuYaFIx1ozd9cFpdAW8A== + dependencies: + d3-shape "^1.2.0" + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-box-plot@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-box-plot/-/victory-box-plot-32.3.2.tgz#a48bcd0c0ca6dce281a6539b53bf74bce1a2d24d" + integrity sha512-ECrc2W5OxN6pBkFLlmT+qj94nfphmZm40XAp1AcdvvUYF/EKEmb0RyvhhBemK5r2syDjFBLFOgIxrdOT8n9FYA== + dependencies: + d3-array "^1.2.0" + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-brush-container@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-brush-container/-/victory-brush-container-32.3.2.tgz#a3421bf69892fd74d67405e0134d79cc8fa54486" + integrity sha512-ESD1DXzmt+wT6sA7JEIue+e2MxhKxG8H/i+sHAYuqxhGhXdV/zA952mjcyzx3PTZPaopHXd0s6MukM1aWbOZCA== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-brush-line@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-brush-line/-/victory-brush-line-32.3.2.tgz#17cc5dc2b4693177a4998ee6beb9a6912a9d4175" + integrity sha512-yj3+qoFFnQZnNtIX+J6zGjOEUkNaVKmkqQ4PDK3NHef3OUmkQsOQgladyVPjc4vtCRWb5d+9TvVjCcosUYiytw== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-candlestick@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-candlestick/-/victory-candlestick-32.3.2.tgz#873fd02b3dc13df2a30f6c6378568e6e2583f1bf" + integrity sha512-yplTlyDmjS5C/VOt6nFsieGzZn6lwixRI7MX5Qe5t4LLSuyY/E+b+9oya8mimo1e7s4N/hbaXo7eVEDXGExuCw== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-chart@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-chart/-/victory-chart-32.3.2.tgz#4781141fbd233f5d1c4a03474bfd679f5f5e48e8" + integrity sha512-xL8FUl9EYspHWw8XIQTrFhyQAMuHidIiYsyf2920bFgZB+DPLO8zhQHMVhhjnWNVgw4gVoW3Xx7XS2YXNmmLFg== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + react-fast-compare "^2.0.0" + victory-axis "^32.3.2" + victory-core "^32.3.2" + victory-polar-axis "^32.3.2" + victory-shared-events "^32.3.2" + +victory-core@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-core/-/victory-core-32.3.2.tgz#6e2150b0b2807ffcd73106786c43f78f000e90c7" + integrity sha512-HXlxQOacxCuZqhXgalyz71ftLOmtOLvfaIUKTBaGqUFbaLFVhcTBwqWBM+xa7gnjOhgr/VjR5apx2X/Rr8jdgA== + dependencies: + d3-ease "^1.0.0" + d3-interpolate "^1.1.1" + d3-scale "^1.0.0" + d3-shape "^1.2.0" + d3-timer "^1.0.0" + lodash "^4.17.11" + prop-types "^15.5.8" + react-fast-compare "^2.0.0" + +victory-create-container@^32.3.3: + version "32.3.3" + resolved "https://registry.yarnpkg.com/victory-create-container/-/victory-create-container-32.3.3.tgz#a47235db0b194f455300611126f93ca9444501df" + integrity sha512-Y0NqvPAAbNgyVoU2hwz+6E4Df/qa5QV7S4OpvOyljZut/Q/R0Lx9QlGsdpz6qI1veNGvQfP0KyDiKof4kFieRg== + dependencies: + lodash "^4.17.11" + victory-brush-container "^32.3.2" + victory-core "^32.3.2" + victory-cursor-container "^32.3.2" + victory-selection-container "^32.3.2" + victory-voronoi-container "^32.3.3" + victory-zoom-container "^32.3.2" + +victory-cursor-container@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-cursor-container/-/victory-cursor-container-32.3.2.tgz#ca49bccdc0d9cf9b587d89e77eda753f75e9e73f" + integrity sha512-w5ocJVLBhPMBliJNdUBIe+BSlTJQufrtF+WiT0g9V+b32hMAiNQuG4W/bH91Mjko8mHuJnTtawBMbdgz1KGQAQ== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-errorbar@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-errorbar/-/victory-errorbar-32.3.2.tgz#497c9749eb4fa6ee5134b0ba6d32761537189277" + integrity sha512-tv6euhdhFT0iL4z2BV024HqNHCcXfNso7qjJErs1AjITCMFs/6/UbZ4VWOdAq8xTmqHDCdyd8zFiGtbpuSODDQ== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-group@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-group/-/victory-group-32.3.2.tgz#c79fa4c087c386f63f3acd40158002762c37d743" + integrity sha512-N47GVC2AW7NA9oV9UNFpRcrX2XSABUU2yoG68chnaZvNrkGxCBlkz3Y6RpxZkEkXo/VX49u/JaDLQOVhhhlM3w== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + react-fast-compare "^2.0.0" + victory-core "^32.3.2" + +victory-legend@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-legend/-/victory-legend-32.3.2.tgz#494ddf3035804650dcd06de744caf5cb32552aa3" + integrity sha512-JAnlrepPUzK7FWFLQPDw/TVtC/ZweUpOYM5fL9rlOw4eX7suTHVu1pjhqOaHLK0CZXOc2uJeJe9L8TZZmRK4Gw== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-line@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-line/-/victory-line-32.3.2.tgz#b5580febf04c0c7ae3abe5b86d8ba08bcf6a699d" + integrity sha512-5Mk5c+7kQw/An+TgCPOzgaYxUxtfAtt9ZGVuwJ7ru02ieL9Ev0S8Q8jJdmj9vIPE4eG60ZuumzovLz+8Mw+elQ== + dependencies: + d3-shape "^1.2.0" + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-pie@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-pie/-/victory-pie-32.3.2.tgz#873e5e38ed381f9a8e97070df772a8746799f7b4" + integrity sha512-naNoPWWHJjt4L6rHZ5U0/L2slUKkGLqQu2OnKU8/Zfbr2pBi2JXfHLFD8RIk6v+H1zApiqpU5ID+pp05u7o7/Q== + dependencies: + d3-shape "^1.0.0" + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-polar-axis@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-polar-axis/-/victory-polar-axis-32.3.2.tgz#fb134ccb1a75410ce904c131d38a33b7b43a3b51" + integrity sha512-2lNMEzJvDLfn720q3CyhyMGZeZRPA3C0fp3WbNyTDx9h6y1L9fDdPHtLSQRURx1UZOLDwHCHdNTD1TtNiH8txg== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-scatter@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-scatter/-/victory-scatter-32.3.2.tgz#7cad3c7bf18f9c7d067a10caf873941904998128" + integrity sha512-5tw4CnLae0NBvQg5le7OS1KhB9O3HpXgntmK/R76+6L1DOUbuIiV+LOe6XyQYlPm1yX+dF8t1P93O3ykbqWyRg== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-selection-container@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-selection-container/-/victory-selection-container-32.3.2.tgz#aec9bd24de3e292032242a2e58e9e4511384726f" + integrity sha512-imJjWYGkRm01SGxrpbbDEW16xZpVMW0/rKFzOr0ZVZHxmP7VkEx24f7futbX+DuXEYNxDO15imLOtMYXA4yhRg== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-shared-events@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-shared-events/-/victory-shared-events-32.3.2.tgz#cf31aadf55e9974669aa1bcb7d9bfdaad9d33d8b" + integrity sha512-Yilbcsh5YO2QHPdpxBIRIDNO9vDKb8ml5AxuUXpJcGgAH8uaASJCKj8VjQGHWH/REnJ9u0pJuC50dB6wtPuiiw== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + react-fast-compare "^2.0.0" + victory-core "^32.3.2" + +victory-stack@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-stack/-/victory-stack-32.3.2.tgz#fa9b2dae04e092795b69c4b190e52ffdf7f81de9" + integrity sha512-6H8Lt9OV6PYYjQI1Z2Rh5ulDhuESmP5+EIli2SZxeUqMWi2z6RbGcKCM46EMv7hTLO7CPH7FyBeTblKZeghbsA== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + react-fast-compare "^2.0.0" + victory-core "^32.3.2" + +victory-tooltip@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-tooltip/-/victory-tooltip-32.3.2.tgz#14c04787a1947e504f3fc032ba4e1f69c2b98644" + integrity sha512-t4HUDrC2pEVSa7wJxDtyEUK0ngvgzmT4uplytVx6AvIS476Q47SYvLU1q667FLCKWbjR1yZHCcPOhO3+79akAw== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-voronoi-container@^32.3.3: + version "32.3.3" + resolved "https://registry.yarnpkg.com/victory-voronoi-container/-/victory-voronoi-container-32.3.3.tgz#c8c99f9ce54c2d6508b35cb242150c71b4c9da9e" + integrity sha512-eNFOJbM3cUZb6t83xBT3O0MT5FGlX9oxO6WMUs7kZUSMGUa5by2vjV9lJ2FdL70Ix1qnMZDpyP2aYs4vMIWGZw== + dependencies: + delaunay-find "0.0.3" + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + victory-tooltip "^32.3.2" + +victory-voronoi@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-voronoi/-/victory-voronoi-32.3.2.tgz#4abe8b170f5f1f1bf9e9bf5e492113b0bc1f0f02" + integrity sha512-Q7yTF0e1srAIfOprDAv2O8oDzmv21Y+6reslZrPOZiNISl5cxqfBQWmZJISVRoVQ/MOAF8jV5UHbq37o5914PQ== + dependencies: + d3-voronoi "^1.1.2" + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory-zoom-container@^32.3.2: + version "32.3.2" + resolved "https://registry.yarnpkg.com/victory-zoom-container/-/victory-zoom-container-32.3.2.tgz#834d022898991b7620856d86a3485eb0e18a473d" + integrity sha512-XWSOwjs+kscRdf1rbonXJK+AKjWANq6E9EfQgSkfvn+nCNoRd06SffczM3EaiSVoo9R8JevEsfywblh6hQ5jQA== + dependencies: + lodash "^4.17.11" + prop-types "^15.5.8" + victory-core "^32.3.2" + +victory@32.3.3: + version "32.3.3" + resolved "https://registry.yarnpkg.com/victory/-/victory-32.3.3.tgz#cd3cb338d572faffdba84cf9be1a7838a0e7d132" + integrity sha512-JlHRC+EpA3Tst8LNMzGV3P1CG0TJ+/U/1ka/NPLIPpWd7ZqXxTmYlpnBTpFLm3tQmWTpiOm2tTQ867LtZ3JGQw== + dependencies: + victory-area "^32.3.2" + victory-axis "^32.3.2" + victory-bar "^32.3.2" + victory-box-plot "^32.3.2" + victory-brush-container "^32.3.2" + victory-brush-line "^32.3.2" + victory-candlestick "^32.3.2" + victory-chart "^32.3.2" + victory-core "^32.3.2" + victory-create-container "^32.3.3" + victory-cursor-container "^32.3.2" + victory-errorbar "^32.3.2" + victory-group "^32.3.2" + victory-legend "^32.3.2" + victory-line "^32.3.2" + victory-pie "^32.3.2" + victory-polar-axis "^32.3.2" + victory-scatter "^32.3.2" + victory-selection-container "^32.3.2" + victory-shared-events "^32.3.2" + victory-stack "^32.3.2" + victory-tooltip "^32.3.2" + victory-voronoi "^32.3.2" + victory-voronoi-container "^32.3.3" + victory-zoom-container "^32.3.2" + vm-browserify@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019"