зеркало из https://github.com/mozilla/treeherder.git
Bug 1519995 - Perfherder graphs react conversion part 3 (#5286)
Create components to handle graph controls, legend and graph container Convert graph functionalty to react and replace jquery.flot with Victory
This commit is contained in:
Родитель
07c8059549
Коммит
47b53c157a
|
@ -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);
|
||||
}
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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.")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
|
@ -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
|
||||
}
|
||||
]
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
<GraphsViewControls
|
||||
updateStateParams={() => {}}
|
||||
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);
|
||||
});
|
|
@ -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)
|
||||
|
|
|
@ -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))))
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,6 +55,10 @@ a {
|
|||
visibility: hidden;
|
||||
}
|
||||
|
||||
.show {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Similar Jobs panel */
|
||||
.checkbox {
|
||||
min-height: 20px;
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
#loading-symbol {
|
||||
position: relative; /* So we can absolutely position the loading overlay */
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
|
@ -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 <i> or <span> tags with <svg> and set up a MutationObserver
|
||||
// to continue doing this as the DOM changes. Remove once using react-fontawesome.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
) && (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon
|
||||
icon={faCog}
|
||||
size="4x"
|
||||
spin
|
||||
title="loading page, please wait"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) && <LoadingSpinner />}
|
||||
{(tableFailureStatus ||
|
||||
graphFailureStatus ||
|
||||
errorMessages.length > 0) && (
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import treeherder from '../treeherder';
|
||||
|
||||
treeherder.component('loading', {
|
||||
bindings: {
|
||||
data: '<',
|
||||
},
|
||||
template: `
|
||||
<div ng-if="$ctrl.data" class="overlay">
|
||||
<div>
|
||||
<span class="fas fa-spinner fa-pulse th-spinner-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
|
@ -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');
|
||||
};
|
||||
}]);
|
|
@ -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);
|
||||
};
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -1,131 +1 @@
|
|||
<div class="container-fluid" ng-cloak>
|
||||
<div class="ph-horizontal-layout">
|
||||
<div id="graph-chooser">
|
||||
<div ng-show="seriesList.length == 0">
|
||||
<p>Nothing here yet</p>
|
||||
</div>
|
||||
<selected-tests-container series-list="seriesList" add-test-data="addTestData" remove-series="removeSeries" show-hide-series="showHideSeries"/>
|
||||
<button id="add-test-data-button" class="btn btn-primary-soft" ng-click="addTestData()">
|
||||
<span class="fas fa-plus" aria-hidden="true"></span> Add <span ng-show="seriesList.length > 0">more</span> test data</button>
|
||||
</div>
|
||||
<div id="data-display">
|
||||
<select ng-model="myTimerange" ng-options="timerange.text for timerange in timeranges track by timerange.value" ng-change="timeRangeChanged()">
|
||||
</select>
|
||||
<hr/>
|
||||
<div id="loading-symbol">
|
||||
<div id="overview-plot"></div>
|
||||
<div id="graph"></div>
|
||||
<loading data="loadingGraphs"></loading>
|
||||
</div>
|
||||
<div id="graph-bottom" ng-show="seriesList.length > 0">
|
||||
Highlight revisions:
|
||||
<span ng-repeat="highlightedRevision in highlightedRevisions track by $index">
|
||||
<input type="text"
|
||||
maxlength="40"
|
||||
ng-change="updateHighlightedRevisions()"
|
||||
placeholder="hg revision"
|
||||
ng-model="highlightedRevisions[$index]">
|
||||
<span class="reset-highlight-button" ng-show="highlightedRevisions[$index].length > 0" ng-click="resetHighlight($index)">✖</span>
|
||||
</input>
|
||||
</span>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-change="updateHighlightedRevisions()" ng-model="highlightAlerts">Highlight alerts</input>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-right-padding"></div>
|
||||
</div>
|
||||
|
||||
<div id="graph-tooltip" ng-class="{locked: selectedDataPoint}">
|
||||
<div class="body">
|
||||
<div>
|
||||
<button id="close-popup" type="button" class="close graphchooser-close"
|
||||
ng-click="closePopup()"><span aria-hidden="true">×</span></button>
|
||||
<p id="tt-series"><span ng-bind="tooltipContent.series.test"/>
|
||||
(<span ng-bind="tooltipContent.project.name"/>)</p>
|
||||
<p id="tt-series2" class="small"><span ng-bind="tooltipContent.series.platform"/></p>
|
||||
</div>
|
||||
<div>
|
||||
<p id="tt-v">
|
||||
{{tooltipContent.value|displayNumber}}
|
||||
<span class="text-muted" ng-show="tooltipContent.series.lowerIsBetter">(lower is better)</span>
|
||||
<span class="text-muted" ng-show="!tooltipContent.series.lowerIsBetter">(higher is better)</span>
|
||||
</p>
|
||||
<p id="tt-dv" class="small">Δ {{tooltipContent.deltaValue|displayNumber}}
|
||||
(<span ng-bind="tooltipContent.deltaPercentValue"/>%)</p>
|
||||
</div>
|
||||
<div>
|
||||
<p ng-show="tooltipContent.revision">
|
||||
<span class="d-inline-block" data-toggle="tooltip" title="Nudge left disabled">
|
||||
<button
|
||||
ng-if="user.isStaff && tooltipContent.alert"
|
||||
ng-click="nudgeAlert(tooltipContent, 'left')"
|
||||
disabled>
|
||||
<span class="fas fa-angle-double-left"
|
||||
ng-if="!nudgingAlert"></span>
|
||||
<span class="fas fa-spinner fa-pulse th-spinner-lg"
|
||||
ng-if="nudgingAlert"></span>
|
||||
</button>
|
||||
</span>
|
||||
<a id="tt-cset" ng-href="{{tooltipContent.pushlogURL}}" target="_blank" rel="noopener">
|
||||
{{tooltipContent.revision| limitTo: 12}}
|
||||
</a>
|
||||
<span class="d-inline-block" data-toggle="tooltip" title="Nudge right disabled">
|
||||
<button
|
||||
ng-if="user.isStaff && tooltipContent.alert"
|
||||
ng-click="nudgeAlert(tooltipContent, 'right')"
|
||||
disabled>
|
||||
<span class="fas fa-angle-double-right"
|
||||
ng-if="!nudgingAlert"></span>
|
||||
<span class="fas fa-spinner fa-pulse th-spinner-lg"
|
||||
ng-if="nudgingAlert"></span>
|
||||
</button>
|
||||
</span>
|
||||
<span ng-show="tooltipContent.prevRevision && tooltipContent.revision">
|
||||
(<span ng-if="tooltipContent.jobId"><a id="tt-cset" ng-href="{{tooltipContent.revision | getRevisionUrl:tooltipContent.project.name}}&selectedJob={{tooltipContent.jobId}}&group_state=expanded" target="_blank" rel="noopener">job</a>, </span><a ng-href="#/comparesubtest?originalProject={{tooltipContent.project.name}}&newProject={{tooltipContent.project.name}}&originalRevision={{tooltipContent.prevRevision}}&newRevision={{tooltipContent.revision}}&originalSignature={{selectedDataPoint.signatureId}}&newSignature={{selectedDataPoint.signatureId}}&framework={{selectedDataPoint.frameworkId}}" target="_blank" rel="noopener">compare</a>)
|
||||
</span>
|
||||
</p>
|
||||
<p ng-if="tooltipContent.alertSummary">
|
||||
<i class="text-warning fas fa-exclamation-circle"></i>
|
||||
<a href="perf.html#/alerts?id={{tooltipContent.alertSummary.id}}">
|
||||
Alert #{{tooltipContent.alertSummary.id}}</a>
|
||||
<span class="text-muted">- {{tooltipContent.alert && (alertIsOfState(tooltipContent.alert, phAlertStatusMap.ACKNOWLEDGED) ? getAlertSummarytStatusText(tooltipContent.alertSummary) : getAlertStatusText(tooltipContent.alert))}}
|
||||
<span ng-show="tooltipContent.alert.related_summary_id">
|
||||
<span ng-if="tooltipContent.alert.related_summary_id !== tooltipContent.alertSummary.id">
|
||||
to <a href="#/alerts?id={{tooltipContent.alert.related_summary_id}}" target="_blank" rel="noopener">alert #{{tooltipContent.alert.related_summary_id}}</a>
|
||||
</span>
|
||||
<span ng-if="tooltipContent.alert.related_summary_id === tooltipContent.alertSummary.id">
|
||||
from <a href="#/alerts?id={{tooltipContent.alert.related_summary_id}}" target="_blank" rel="noopener">alert #{{tooltipContent.alert.related_summary_id}}</a>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-muted" ng-if="!tooltipContent.alertSummary">
|
||||
<span ng-if="!creatingAlert">
|
||||
No alert
|
||||
<span ng-if="user.isStaff">
|
||||
(<a href="" ng-click="createAlert(tooltipContent)" ng-disabled="user.isStaff">create</a>)
|
||||
</span>
|
||||
<span ng-if="!user.isStaff">
|
||||
(log in as a a sheriff to create)
|
||||
</span>
|
||||
</span>
|
||||
<span ng-if="creatingAlert">
|
||||
Creating alert... <i class="fas fa-spinner fa-pulse" title="creating alert"></i>
|
||||
</span>
|
||||
</p>
|
||||
<p ng-hide="tooltipContent.revision">
|
||||
<span ng-hide="tooltipContent.revisionInfoAvailable">Revision info unavailable</span>
|
||||
<span ng-show="tooltipContent.revisionInfoAvailable">Loading revision...</span>
|
||||
</p>
|
||||
<p id="tt-t" class="small" ng-bind="tooltipContent.date"></p>
|
||||
<p id="tt-v" class="small" ng-show="tooltipContent.retriggers > 0">Retriggers: {{tooltipContent.retriggers}}</p>
|
||||
</div>
|
||||
<span ng-hide="selectedDataPoint">Click to lock</span>
|
||||
<span ng-show="selectedDataPoint"> </span>
|
||||
</div>
|
||||
<div class="tip"></div>
|
||||
</div>
|
||||
</div>
|
||||
<graphs-view user="user" />
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title">Add test data</h4>
|
||||
<button type="button" class="close" ng-click="cancel()"><span aria-hidden="true">×</span><span class="sr-only">Close</span></button>
|
||||
</div>
|
||||
<test-data-modal repos='repos' tests-displayed="testsDisplayed" time-range='timeRange' submit-data='submitData' options='options'/>
|
|
@ -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 (
|
||||
<React.Fragment>
|
||||
{!validationComplete && errorMessages.length === 0 && (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon
|
||||
icon={faCog}
|
||||
size="4x"
|
||||
spin
|
||||
title="loading page, please wait"
|
||||
/>
|
||||
</div>
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
|
||||
{errorMessages.length > 0 && (
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
<Container fluid className="pt-5 max-width-default">
|
||||
{loading && (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon
|
||||
icon={faCog}
|
||||
size="4x"
|
||||
spin
|
||||
title="loading page, please wait"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{loading && <LoadingSpinner />}
|
||||
|
||||
{errorMessages.length > 0 && (
|
||||
<Container className="pt-5 px-0 max-width-default">
|
||||
|
|
|
@ -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)}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<DropdownItem onClick={this.copySummary}>Copy Summary</DropdownItem>
|
||||
|
|
|
@ -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 && (
|
||||
<Container fluid className="max-width-default justify-content-center">
|
||||
{dataLoading ? (
|
||||
<div className="loading" aria-label="loading">
|
||||
<FontAwesomeIcon
|
||||
icon={faCog}
|
||||
size="4x"
|
||||
spin
|
||||
title="loading page, please wait"
|
||||
/>
|
||||
</div>
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<Row className="justify-content-center mt-4">
|
||||
<React.Fragment>
|
||||
|
|
|
@ -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 (
|
||||
<Container fluid className="max-width-default">
|
||||
{loading && !failureMessage && (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon
|
||||
icon={faCog}
|
||||
size="4x"
|
||||
spin
|
||||
title="loading page, please wait"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{loading && !failureMessage && <LoadingSpinner />}
|
||||
|
||||
<ErrorBoundary
|
||||
errorClasses={errorMessageClass}
|
||||
message={genericErrorMessage}
|
||||
|
|
|
@ -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 { errorMessageClass } from '../../helpers/constants';
|
||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||
|
@ -10,6 +8,7 @@ import PerfSeriesModel from '../../models/perfSeries';
|
|||
import { getData } from '../../helpers/http';
|
||||
import { createApiUrl, perfSummaryEndpoint } from '../../helpers/url';
|
||||
import { noDataFoundMessage } from '../constants';
|
||||
import LoadingSpinner from '../../shared/LoadingSpinner';
|
||||
|
||||
// TODO remove $stateParams after switching to react router
|
||||
export default class ReplicatesGraph extends React.Component {
|
||||
|
@ -130,14 +129,7 @@ export default class ReplicatesGraph extends React.Component {
|
|||
: undefined;
|
||||
|
||||
return dataLoading ? (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon
|
||||
icon={faCog}
|
||||
size="4x"
|
||||
spin
|
||||
title="loading page, please wait"
|
||||
/>
|
||||
</div>
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
errorClasses={errorMessageClass}
|
||||
|
|
|
@ -51,3 +51,12 @@ export const alertStatusMap = {
|
|||
acknowledged: 4,
|
||||
confirming: 5,
|
||||
};
|
||||
|
||||
export const graphColors = [
|
||||
['scarlet', '#b81752'],
|
||||
['turquoise', '#17a2b8'],
|
||||
['green', '#19a572'],
|
||||
['brown', '#b87e17'],
|
||||
['darkorchid', '#9932cc'],
|
||||
['blue', '#1752b8'],
|
||||
];
|
||||
|
|
|
@ -0,0 +1,222 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import countBy from 'lodash/countBy';
|
||||
import moment from 'moment';
|
||||
import { Button } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import { alertStatusMap, endpoints } from '../constants';
|
||||
import { getJobsUrl, createQueryParams, getApiUrl } from '../../helpers/url';
|
||||
import { create } from '../../helpers/http';
|
||||
import RepositoryModel from '../../models/repository';
|
||||
import { displayNumber, getStatus } from '../helpers';
|
||||
|
||||
const GraphTooltip = ({ dataPoint, testData, user, updateData, projects }) => {
|
||||
// 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 (
|
||||
<div className="body">
|
||||
<div>
|
||||
<p>({testDetails.repository_name})</p>
|
||||
<p className="small">{testDetails.platform}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
{displayNumber(value)}
|
||||
<span className="text-muted">
|
||||
{testDetails.lowerIsBetter
|
||||
? ' (lower is better)'
|
||||
: ' (higher is better)'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="small">
|
||||
Δ {displayNumber(deltaValue.toFixed(1))} (
|
||||
{(100 * deltaPercent).toFixed(1)}%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{prevRevision && (
|
||||
<span>
|
||||
<a href={pushUrl} target="_blank" rel="noopener noreferrer">
|
||||
{dataPointDetails.revision.slice(0, 13)}
|
||||
</a>{' '}
|
||||
(
|
||||
{dataPointDetails.jobId && (
|
||||
<a href={jobsUrl} target="_blank" rel="noopener noreferrer">
|
||||
job
|
||||
</a>
|
||||
)}
|
||||
,{' '}
|
||||
<a
|
||||
href={`#/comparesubtest${createQueryParams({
|
||||
originalProject: testDetails.repository_name,
|
||||
newProject: testDetails.repository_name,
|
||||
originalRevision: prevRevision,
|
||||
newRevision: dataPointDetails.revision,
|
||||
originalSignature: testDetails.signature_id,
|
||||
newSignature: testDetails.signature_id,
|
||||
framework: testDetails.framework_id,
|
||||
})}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
compare
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
{dataPointDetails.alertSummary && (
|
||||
<p>
|
||||
<a
|
||||
href={`perf.html#/alerts?id=${dataPointDetails.alertSummary.id}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="text-warning"
|
||||
icon={faExclamationCircle}
|
||||
size="sm"
|
||||
/>
|
||||
{` Alert # ${dataPointDetails.alertSummary.id}`}
|
||||
</a>
|
||||
<span className="text-muted">
|
||||
{` - ${alertStatus} `}
|
||||
{alert.related_summary_id && (
|
||||
<span>
|
||||
{alert.related_summary_id !== dataPointDetails.alertSummary.id
|
||||
? 'to'
|
||||
: 'from'}
|
||||
<a
|
||||
href={`#/alerts?id=${alert.related_summary_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{` alert # ${alert.related_summary_id}`}</a>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{!dataPointDetails.alertSummary && prevPushId && (
|
||||
<p className="pt-2">
|
||||
{user.isStaff ? (
|
||||
<Button color="info" outline size="sm" onClick={createAlert}>
|
||||
create alert
|
||||
</Button>
|
||||
) : (
|
||||
<span>(log in as a a sheriff to create)</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="small text-white pt-2">{`${moment
|
||||
.utc(dataPointDetails.x)
|
||||
.format('MMM DD hh:mm:ss')} UTC`}</p>
|
||||
{Boolean(retriggerNum) && (
|
||||
<p className="small">{`Retriggers: ${retriggerNum}`}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<React.Fragment>
|
||||
<div
|
||||
data-testid="graph-tooltip"
|
||||
className={`graph-tooltip ${showTooltip ? 'show' : 'hide'} ${
|
||||
lockTooltip ? 'locked' : ''
|
||||
}`}
|
||||
ref={this.tooltip}
|
||||
>
|
||||
<span className="close mr-3 my-2 ml-2" onClick={this.closeTooltip}>
|
||||
<FontAwesomeIcon
|
||||
className="pointer text-white"
|
||||
icon={faTimes}
|
||||
size="xs"
|
||||
title="close tooltip"
|
||||
/>
|
||||
</span>
|
||||
{dataPoint && showTooltip && (
|
||||
<GraphTooltip
|
||||
dataPoint={dataPoint}
|
||||
testData={testData}
|
||||
{...this.props}
|
||||
/>
|
||||
)}
|
||||
<div className="tip" />
|
||||
</div>
|
||||
<Row>
|
||||
<Col className="p-0 col-md-auto">
|
||||
<VictoryChart
|
||||
padding={chartPadding}
|
||||
width={1350}
|
||||
height={150}
|
||||
style={{ parent: { maxHeight: '150px', maxWidth: '1350px' } }}
|
||||
scale={{ x: 'time', y: 'linear' }}
|
||||
domain={entireDomain}
|
||||
domainPadding={{ y: 30 }}
|
||||
containerComponent={
|
||||
<VictoryBrushContainer
|
||||
brushDomain={zoom}
|
||||
onBrushDomainChange={this.updateZoom}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VictoryAxis
|
||||
dependentAxis
|
||||
tickCount={4}
|
||||
style={axisStyle}
|
||||
tickFormat={this.setLeftPadding}
|
||||
/>
|
||||
<VictoryAxis
|
||||
tickCount={10}
|
||||
tickFormat={x => moment.utc(x).format('MMM DD')}
|
||||
style={axisStyle}
|
||||
/>
|
||||
{testData.map(item => (
|
||||
<VictoryLine
|
||||
key={item.name}
|
||||
data={item.visible ? item.data : []}
|
||||
style={{
|
||||
data: { stroke: item.color[1] },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</VictoryChart>
|
||||
</Col>
|
||||
<Col className="p-0 col-md-auto">
|
||||
<SimpleTooltip
|
||||
text={
|
||||
<FontAwesomeIcon
|
||||
className="pointer text-secondary"
|
||||
icon={faQuestionCircle}
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
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."
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col className="p-0 col-md-auto">
|
||||
<VictoryChart
|
||||
padding={chartPadding}
|
||||
width={1350}
|
||||
height={400}
|
||||
style={{ parent: { maxHeight: '400px', maxWidth: '1350px' } }}
|
||||
scale={{ x: 'time', y: 'linear' }}
|
||||
domain={entireDomain}
|
||||
domainPadding={{ y: 40 }}
|
||||
containerComponent={
|
||||
<VictoryZoomContainer
|
||||
zoomDomain={zoom}
|
||||
onZoomDomainChange={this.updateZoom}
|
||||
allowPan={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{highlights.length > 0 &&
|
||||
highlights.map(item => (
|
||||
<VictoryLine
|
||||
key={item}
|
||||
style={{
|
||||
data: { stroke: 'gray', strokeWidth: 1 },
|
||||
}}
|
||||
x={() => item.x}
|
||||
/>
|
||||
))}
|
||||
|
||||
<VictoryScatter
|
||||
style={{
|
||||
data: {
|
||||
fill: data =>
|
||||
(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,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<VictoryAxis
|
||||
dependentAxis
|
||||
tickCount={9}
|
||||
style={axisStyle}
|
||||
tickFormat={this.setLeftPadding}
|
||||
/>
|
||||
<VictoryAxis
|
||||
tickCount={8}
|
||||
tickFormat={x => moment.utc(x).format('MMM DD hh:mm')}
|
||||
style={axisStyle}
|
||||
fixLabelOverlap
|
||||
/>
|
||||
</VictoryChart>
|
||||
</Col>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<ErrorBoundary
|
||||
errorClasses={errorMessageClass}
|
||||
message={genericErrorMessage}
|
||||
>
|
||||
<Container fluid className="pt-5">
|
||||
{loading && <LoadingSpinner />}
|
||||
|
||||
{errorMessages.length > 0 && (
|
||||
<Container className="pb-4 px-0 max-width-default">
|
||||
<ErrorMessages errorMessages={errorMessages} />
|
||||
</Container>
|
||||
)}
|
||||
|
||||
<Row className="justify-content-center">
|
||||
<Col
|
||||
className={`ml-2 ${testData.length ? 'graph-chooser' : 'col-12'}`}
|
||||
>
|
||||
<Container className="graph-legend pl-0 pb-4">
|
||||
{testData.length > 0 &&
|
||||
testData.map(series => (
|
||||
<div
|
||||
key={`${series.name} ${series.repository_name} ${series.platform}`}
|
||||
>
|
||||
<LegendCard
|
||||
series={series}
|
||||
testData={testData}
|
||||
{...this.props}
|
||||
updateState={state => this.setState(state)}
|
||||
updateStateParams={state =>
|
||||
this.setState(state, this.changeParams)
|
||||
}
|
||||
colors={colors}
|
||||
selectedDataPoint={selectedDataPoint}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Container>
|
||||
</Col>
|
||||
<Col
|
||||
className={`pl-0 ${
|
||||
testData.length ? 'custom-col-xxl-auto' : 'col-auto'
|
||||
}`}
|
||||
>
|
||||
<GraphsViewControls
|
||||
timeRange={timeRange}
|
||||
frameworks={frameworks}
|
||||
projects={projects}
|
||||
options={options}
|
||||
getTestData={this.getTestData}
|
||||
testData={testData}
|
||||
showModal={showModal}
|
||||
toggle={() => this.setState({ showModal: !showModal })}
|
||||
graphs={
|
||||
testData.length > 0 && (
|
||||
<GraphsContainer
|
||||
timeRange={timeRange}
|
||||
highlightAlerts={highlightAlerts}
|
||||
highlightedRevisions={highlightedRevisions}
|
||||
zoom={zoom}
|
||||
selectedDataPoint={selectedDataPoint}
|
||||
testData={testData}
|
||||
updateStateParams={state =>
|
||||
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}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<Container fluid className="justify-content-start">
|
||||
{projects.length > 0 && frameworks.length > 0 && (
|
||||
<TestDataModal
|
||||
showModal={showModal}
|
||||
toggle={toggle}
|
||||
{...this.props}
|
||||
/>
|
||||
)}
|
||||
<Row className="pb-3">
|
||||
<Col sm="auto" className="pl-0 py-2 pr-2" key={timeRange}>
|
||||
<UncontrolledDropdown
|
||||
className="mr-0 text-nowrap"
|
||||
title="Time range"
|
||||
aria-label="Time range"
|
||||
>
|
||||
<DropdownToggle caret>{timeRange.text}</DropdownToggle>
|
||||
<DropdownMenuItems
|
||||
options={phTimeRanges.map(item => item.text)}
|
||||
selectedItem={timeRange.text}
|
||||
updateData={value =>
|
||||
updateTimeRange(
|
||||
phTimeRanges.find(item => item.text === value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</UncontrolledDropdown>
|
||||
</Col>
|
||||
<Col sm="auto" className="p-2">
|
||||
<Button color="info" onClick={toggle}>
|
||||
Add test data
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{hasNoData ? (
|
||||
<Row>
|
||||
<p className="lead text-left">
|
||||
Nothing here yet. Add test data to plot graphs.
|
||||
</p>
|
||||
</Row>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{graphs}
|
||||
<Row className="justify-content-start pt-2">
|
||||
{highlightedRevisions.length > 0 &&
|
||||
highlightedRevisions.map((revision, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Col sm="2" className="pl-0 pr-3" key={index}>
|
||||
<Input
|
||||
type="text"
|
||||
name={`revision ${revision}`}
|
||||
placeholder="revision to highlight"
|
||||
value={revision}
|
||||
onChange={event =>
|
||||
this.changeHighlightedRevision(
|
||||
index,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
<Col sm="auto" className="pl-0">
|
||||
<Button
|
||||
color="info"
|
||||
outline
|
||||
onClick={() =>
|
||||
updateStateParams({ highlightAlerts: !highlightAlerts })
|
||||
}
|
||||
active={highlightAlerts}
|
||||
>
|
||||
Highlight alerts
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
|
@ -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 (
|
||||
<FormGroup check className="pl-0 border">
|
||||
<span className="close mr-3 my-2 ml-2" onClick={removeTest}>
|
||||
<FontAwesomeIcon
|
||||
className="pointer"
|
||||
icon={faTimes}
|
||||
size="xs"
|
||||
title=""
|
||||
/>
|
||||
</span>
|
||||
<div className={`${series.color[0]} graph-legend-card p-3`}>
|
||||
<p
|
||||
className={`p-0 mb-0 pointer border-0 ${
|
||||
series.visible ? series.color[0] : 'text-muted'
|
||||
} text-left`}
|
||||
onClick={() => addTestData('addRelatedConfigs')}
|
||||
title="Add related configurations"
|
||||
type="button"
|
||||
>
|
||||
{series.name}
|
||||
</p>
|
||||
<p
|
||||
className={subtitleStyle}
|
||||
onClick={() => addTestData('addRelatedBranches')}
|
||||
title="Add related branches"
|
||||
type="button"
|
||||
>
|
||||
{series.repository_name}
|
||||
</p>
|
||||
<p
|
||||
className={subtitleStyle}
|
||||
onClick={() => addTestData('addRelatedPlatform')}
|
||||
title="Add related platforms"
|
||||
type="button"
|
||||
>
|
||||
{series.platform}
|
||||
</p>
|
||||
<span className="small">{`${series.signatureHash.slice(
|
||||
0,
|
||||
16,
|
||||
)}...`}</span>
|
||||
</div>
|
||||
<Input
|
||||
className="show-hide-check"
|
||||
type="checkbox"
|
||||
checked={series.visible}
|
||||
aria-label="Show/Hide series"
|
||||
title="Show/Hide series"
|
||||
onChange={updateSelectedTest}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<Container className="graph-legend pl-0 pb-4">
|
||||
{seriesList.length > 0 &&
|
||||
seriesList.map(series => (
|
||||
<div key={series.id}>
|
||||
<TestCard series={series} {...props} />
|
||||
</div>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<FormGroup check className="pl-0 border">
|
||||
<span
|
||||
className="close mr-3 my-2 ml-2"
|
||||
onClick={() => removeSeries(series.projectName, series.signature)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="pointer"
|
||||
icon={faTimes}
|
||||
size="xs"
|
||||
title=""
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
className={`${
|
||||
checked && series.color ? series.color[0] : 'border-secondary'
|
||||
} graph-legend-card p-3`}
|
||||
>
|
||||
<p
|
||||
className="p-0 mb-0 border-0 text-left"
|
||||
onClick={() => addTestData('addRelatedConfigs', series.signature)}
|
||||
title="Add related configurations"
|
||||
type="button"
|
||||
>
|
||||
{series.name}
|
||||
</p>
|
||||
<p
|
||||
className={subtitleStyle}
|
||||
onClick={() => addTestData('addRelatedBranches', series.signature)}
|
||||
title="Add related branches"
|
||||
type="button"
|
||||
>
|
||||
{series.projectName}
|
||||
</p>
|
||||
<p
|
||||
className={subtitleStyle}
|
||||
onClick={() => addTestData('addRelatedPlatform', series.signature)}
|
||||
title="Add related branches"
|
||||
type="button"
|
||||
>
|
||||
{series.platform}
|
||||
</p>
|
||||
<span className="small text-muted">{`${series.signature.slice(
|
||||
0,
|
||||
16,
|
||||
)}...`}</span>
|
||||
</div>
|
||||
<Input
|
||||
className="show-hide-check"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
aria-label="Show/Hide series"
|
||||
title="Show/Hide series"
|
||||
onChange={this.updateSelectedTest}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<ModalBody className="container-fluid test-chooser">
|
||||
<Form>
|
||||
<Row className="justify-content-start">
|
||||
{createDropdowns(modalOptions, 'p-2', true)}
|
||||
<Col sm="auto" className="p-2">
|
||||
<Button
|
||||
color="info"
|
||||
outline
|
||||
onClick={() =>
|
||||
this.setState(
|
||||
{ includeSubtests: !includeSubtests },
|
||||
this.processOptions,
|
||||
)
|
||||
}
|
||||
active={includeSubtests}
|
||||
>
|
||||
Include subtests
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="justify-content-start">
|
||||
<Col className="p-2 col-4">
|
||||
<InputFilter
|
||||
disabled={relatedTests.length > 0}
|
||||
updateFilterText={this.updateFilterText}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="p-2 justify-content-start">
|
||||
<Col className="p-0">
|
||||
<Label for="exampleSelect">
|
||||
{relatedTests.length > 0 ? 'Related tests' : 'Tests'}
|
||||
</Label>
|
||||
<Input type="select" name="selectMulti" id="selectTests" multiple>
|
||||
{tests.length > 0 &&
|
||||
tests.sort().map(test => (
|
||||
<option
|
||||
key={test.id}
|
||||
onClick={() => this.updateSelectedTests(test)}
|
||||
title={this.getOriginalTestName(test)}
|
||||
>
|
||||
{this.getOriginalTestName(test)}
|
||||
</option>
|
||||
))}
|
||||
</Input>
|
||||
{showNoRelatedTests && (
|
||||
<p className="text-info pt-2">No related tests found.</p>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="p-2 justify-content-start">
|
||||
<Col className="p-0">
|
||||
<Label for="exampleSelect">
|
||||
Selected tests{' '}
|
||||
<span className="small">(click a test to remove it)</span>
|
||||
</Label>
|
||||
<Input type="select" name="selectMulti" id="selectTests" multiple>
|
||||
{selectedTests.length > 0 &&
|
||||
selectedTests.map(test => (
|
||||
<option
|
||||
key={test.id}
|
||||
onClick={() => this.updateSelectedTests(test, true)}
|
||||
title={this.getFullTestName(test)}
|
||||
>
|
||||
{this.getFullTestName(test)}
|
||||
</option>
|
||||
))}
|
||||
</Input>
|
||||
{selectedTests.length > 6 && (
|
||||
<p className="text-info pt-2">
|
||||
Displaying more than 6 graphs at a time is not supported in
|
||||
the UI.
|
||||
</p>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="p-2">
|
||||
<Col className="py-2 px-0 text-right">
|
||||
<Button
|
||||
color="info"
|
||||
disabled={!selectedTests.length}
|
||||
onClick={() => submitData(selectedTests)}
|
||||
onKeyPress={event => event.preventDefault()}
|
||||
>
|
||||
Plot graphs
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
<Modal size="lg" isOpen={showModal}>
|
||||
<ModalHeader toggle={this.closeModal}>Add Test Data</ModalHeader>
|
||||
<ModalBody className="container-fluid test-chooser">
|
||||
<Form>
|
||||
<Row className="justify-content-start">
|
||||
{createDropdowns(modalOptions, 'p-2', true)}
|
||||
<Col sm="auto" className="p-2">
|
||||
<Button
|
||||
color="info"
|
||||
outline
|
||||
onClick={() =>
|
||||
this.setState(
|
||||
{ includeSubtests: !includeSubtests },
|
||||
this.processOptions,
|
||||
)
|
||||
}
|
||||
active={includeSubtests}
|
||||
>
|
||||
Include subtests
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="justify-content-start">
|
||||
<Col className="p-2 col-4">
|
||||
<InputFilter
|
||||
disabled={relatedTests.length > 0}
|
||||
updateFilterText={this.updateFilterText}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="p-2 justify-content-start">
|
||||
<Col className="p-0">
|
||||
<Label for="exampleSelect">
|
||||
{relatedTests.length > 0 ? 'Related tests' : 'Tests'}
|
||||
</Label>
|
||||
<Input
|
||||
data-testid="tests"
|
||||
type="select"
|
||||
name="selectMulti"
|
||||
id="selectTests"
|
||||
multiple
|
||||
>
|
||||
{tests.length > 0 &&
|
||||
tests.sort().map(test => (
|
||||
<option
|
||||
key={test.id}
|
||||
data-testid={test.id.toString()}
|
||||
onClick={() => this.updateSelectedTests(test)}
|
||||
title={this.getOriginalTestName(test)}
|
||||
>
|
||||
{this.getOriginalTestName(test)}
|
||||
</option>
|
||||
))}
|
||||
</Input>
|
||||
{showNoRelatedTests && (
|
||||
<p className="text-info pt-2">No related tests found.</p>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="p-2 justify-content-start">
|
||||
<Col className="p-0">
|
||||
<Label for="exampleSelect">
|
||||
Selected tests{' '}
|
||||
<span className="small">(click a test to remove it)</span>
|
||||
</Label>
|
||||
<Input
|
||||
data-testid="selectedTests"
|
||||
type="select"
|
||||
name="selectMulti"
|
||||
id="selectTests"
|
||||
multiple
|
||||
>
|
||||
{selectedTests.length > 0 &&
|
||||
selectedTests.map(test => (
|
||||
<option
|
||||
key={test.id}
|
||||
onClick={() => this.updateSelectedTests(test, true)}
|
||||
title={this.getFullTestName(test)}
|
||||
>
|
||||
{this.getFullTestName(test)}
|
||||
</option>
|
||||
))}
|
||||
</Input>
|
||||
{selectedTests.length > 6 && (
|
||||
<p className="text-info pt-2">
|
||||
Displaying more than 6 graphs at a time is not supported in
|
||||
the UI.
|
||||
</p>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="p-2">
|
||||
<Col className="py-2 px-0 text-right">
|
||||
<Button
|
||||
color="info"
|
||||
disabled={!selectedTests.length}
|
||||
onClick={this.submitData}
|
||||
onKeyPress={event => event.preventDefault()}
|
||||
>
|
||||
Plot graphs
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCog } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon
|
||||
icon={faCog}
|
||||
size="4x"
|
||||
spin
|
||||
title="loading page, please wait"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LoadingSpinner;
|
320
yarn.lock
320
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"
|
||||
|
|
Загрузка…
Ссылка в новой задаче