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:
Sarah Clements 2019-09-09 13:19:04 -07:00 коммит произвёл GitHub
Родитель 07c8059549
Коммит 47b53c157a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
43 изменённых файлов: 2537 добавлений и 1928 удалений

Просмотреть файл

@ -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)">&#10006;</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">&times;</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">&Delta; {{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">&nbsp;</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">&times;</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">
&Delta; {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
Просмотреть файл

@ -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"