Bug 1507172 - Use Prettier for formatting JS/JSX (#4276)

Since it's more reliable (and strict) at code formatting than ESLint.
We use it via an ESLint plugin, and so disable the style-related AirBnB
preset rules, leaving the AirBnB guide to handle only correctness and
best practices rules.

It's highly encouraged to use an IDE integration or Git commit hook
to run Prettier (or `yarn lint --fix`) automatically. See:
* https://prettier.io/docs/en/editors.html
* https://prettier.io/docs/en/precommit.html

We may consider enabling a git commit hook out of the box (using eg
Husky) in the future, however they have previously been known to
interfere with partial-staging workflows, so would need to test the
fixes they have made for them thoroughly first.

In future PRs we may also want to start formatting JSON/CSS/Markdown
using Prettier too.
This commit is contained in:
Ed Morley 2018-11-16 08:28:34 +00:00 коммит произвёл GitHub
Родитель 4db0cfa973
Коммит 65b7f4ab45
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
139 изменённых файлов: 6661 добавлений и 3977 удалений

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

@ -1,6 +1,17 @@
module.exports = {
root: true,
extends: 'eslint-config-airbnb',
extends: [
'eslint-config-airbnb',
// We use Prettier instead of AirBnb for style-related rules (see .prettierrc.js).
process.env.NODE_ENV === 'development'
? // Disables the AirBnB style rules but does not enable Prettier
// (to reduce the amount of console noise when using `yarn start`).
'prettier'
: // The above plus enables the prettier rule.
'plugin:prettier/recommended',
// Disable React-related AirBnB style rules.
'prettier/react',
],
parser: 'babel-eslint',
settings: {
react: {
@ -17,17 +28,12 @@ module.exports = {
'class-methods-use-this': 'off',
'consistent-return': 'off',
'default-case': 'off',
'function-paren-newline': 'off',
'implicit-arrow-linebreak': 'off',
// Indentation is disabled pending a switch from 4 to 2 space for JS.
indent: 'off',
'jsx-a11y/anchor-is-valid': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/label-has-associated-control': 'off',
'jsx-a11y/label-has-for': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'max-len': 'off',
'no-alert': 'off',
'no-continue': 'off',
'no-param-reassign': 'off',
@ -35,17 +41,11 @@ module.exports = {
'no-restricted-syntax': 'off',
'no-shadow': 'off',
'no-underscore-dangle': 'off',
'object-curly-newline': 'off',
'operator-linebreak': 'off',
'padded-blocks': 'off',
'prefer-promise-reject-errors': 'off',
'react/button-has-type': 'off',
'react/default-props-match-prop-types': 'off',
'react/destructuring-assignment': 'off',
'react/forbid-prop-types': 'off',
'react/jsx-closing-tag-location': 'off',
'react/jsx-one-expression-per-line': 'off',
'react/jsx-wrap-multilines': 'off',
'react/no-access-state-in-setstate': 'off',
// Override AirBnB's config for this rule to make it more strict.
// https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/order.md
@ -56,4 +56,15 @@ module.exports = {
},
],
},
overrides: [
{
// Exclude our legacy JS from prettier since it will be rewritten when converted to React.
// This directory is already ignored in .prettierignore but have to repeat here due to:
// https://github.com/prettier/eslint-plugin-prettier/issues/126
files: ['ui/js/**'],
rules: {
'prettier/prettier': 'off',
},
},
],
};

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

@ -42,97 +42,104 @@ module.exports = {
tests: 'tests/ui/',
},
use: [
process.env.NODE_ENV === 'development' && ['@neutrinojs/eslint', {
eslint: {
// Treat ESLint errors as warnings so they don't block the webpack build.
// Remove if/when changed in @neutrinojs/eslint.
emitWarning: true,
// We manage our lint config in .eslintrc.js instead of here.
useEslintrc: true,
process.env.NODE_ENV === 'development' && [
'@neutrinojs/eslint',
{
eslint: {
// Treat ESLint errors as warnings so they don't block the webpack build.
// Remove if/when changed in @neutrinojs/eslint.
emitWarning: true,
// We manage our lint config in .eslintrc.js instead of here.
useEslintrc: true,
},
},
}],
['@neutrinojs/react', {
devServer: {
historyApiFallback: false,
open: !process.env.MOZ_HEADLESS,
// Remove when enabled by default (https://github.com/neutrinojs/neutrino/issues/1131).
overlay: true,
proxy: {
// Proxy any paths not recognised by webpack to the specified backend.
'*': {
changeOrigin: true,
headers: {
// Prevent Django CSRF errors, whilst still making it clear
// that the requests were from local development.
referer: `${BACKEND}/webpack-dev-server`,
},
target: BACKEND,
onProxyRes: (proxyRes) => {
// Strip the cookie `secure` attribute, otherwise production's cookies
// will be rejected by the browser when using non-HTTPS localhost:
// https://github.com/nodejitsu/node-http-proxy/pull/1166
const removeSecure = str => str.replace(/; secure/i, '');
const cookieHeader = proxyRes.headers['set-cookie'];
if (cookieHeader) {
proxyRes.headers['set-cookie'] = Array.isArray(cookieHeader)
? cookieHeader.map(removeSecure)
: removeSecure(cookieHeader);
}
],
[
'@neutrinojs/react',
{
devServer: {
historyApiFallback: false,
open: !process.env.MOZ_HEADLESS,
// Remove when enabled by default (https://github.com/neutrinojs/neutrino/issues/1131).
overlay: true,
proxy: {
// Proxy any paths not recognised by webpack to the specified backend.
'*': {
changeOrigin: true,
headers: {
// Prevent Django CSRF errors, whilst still making it clear
// that the requests were from local development.
referer: `${BACKEND}/webpack-dev-server`,
},
target: BACKEND,
onProxyRes: proxyRes => {
// Strip the cookie `secure` attribute, otherwise production's cookies
// will be rejected by the browser when using non-HTTPS localhost:
// https://github.com/nodejitsu/node-http-proxy/pull/1166
const removeSecure = str => str.replace(/; secure/i, '');
const cookieHeader = proxyRes.headers['set-cookie'];
if (cookieHeader) {
proxyRes.headers['set-cookie'] = Array.isArray(cookieHeader)
? cookieHeader.map(removeSecure)
: removeSecure(cookieHeader);
}
},
},
},
// Inside Vagrant filesystem watching has to be performed using polling mode,
// since inotify doesn't work with Virtualbox shared folders.
watchOptions: process.env.USE_WATCH_POLLING && {
// Poll only once a second and ignore the node_modules folder to keep CPU usage down.
poll: 1000,
ignored: /node_modules/,
},
},
// Inside Vagrant filesystem watching has to be performed using polling mode,
// since inotify doesn't work with Virtualbox shared folders.
watchOptions: process.env.USE_WATCH_POLLING && {
// Poll only once a second and ignore the node_modules folder to keep CPU usage down.
poll: 1000,
ignored: /node_modules/,
devtool: {
// Enable source maps for `yarn build` too (but not on CI, since it doubles build times).
production: process.env.CI ? false : 'source-map',
},
html: {
// Disable the default viewport meta tag, since Treeherder doesn't work well at
// small viewport sizes, so shouldn't use `width=device-width` (see bug 1505417).
meta: false,
},
style: {
// Disable Neutrino's CSS modules support, since we don't use it.
modules: false,
},
targets: {
browsers: [
'last 1 Chrome versions',
'last 1 Edge versions',
'last 1 Firefox versions',
'last 1 Safari versions',
],
},
},
devtool: {
// Enable source maps for `yarn build` too (but not on CI, since it doubles build times).
production: process.env.CI ? false : 'source-map',
],
[
'@neutrinojs/copy',
{
patterns: ['ui/contribute.json', 'ui/revision.txt', 'ui/robots.txt'],
},
html: {
// Disable the default viewport meta tag, since Treeherder doesn't work well at
// small viewport sizes, so shouldn't use `width=device-width` (see bug 1505417).
meta: false,
},
style: {
// Disable Neutrino's CSS modules support, since we don't use it.
modules: false,
},
targets: {
browsers: [
'last 1 Chrome versions',
'last 1 Edge versions',
'last 1 Firefox versions',
'last 1 Safari versions',
],
},
}],
['@neutrinojs/copy', {
patterns: [
'ui/contribute.json',
'ui/revision.txt',
'ui/robots.txt',
],
}],
(neutrino) => {
],
neutrino => {
neutrino.config
.plugin('provide')
.use(require.resolve('webpack/lib/ProvidePlugin'), [{
// Required since AngularJS and jquery.flot don't import jQuery themselves.
jQuery: 'jquery',
'window.jQuery': 'jquery',
}]);
.use(require.resolve('webpack/lib/ProvidePlugin'), [
{
// Required since AngularJS and jquery.flot don't import jQuery themselves.
jQuery: 'jquery',
'window.jQuery': 'jquery',
},
]);
if (process.env.NODE_ENV === 'production') {
// Fail the build if these file size thresholds (in bytes) are exceeded,
// to help prevent unknowingly regressing the bundle size (bug 1384255).
neutrino.config.performance
.hints('error')
.maxAssetSize(1.30 * 1024 * 1024)
.maxAssetSize(1.3 * 1024 * 1024)
.maxEntrypointSize(1.64 * 1024 * 1024);
}
},

2
.prettierignore Normal file
Просмотреть файл

@ -0,0 +1,2 @@
# Ignore our legacy JS since it will be rewritten when converted to React.
ui/js/

5
.prettierrc.js Normal file
Просмотреть файл

@ -0,0 +1,5 @@
module.exports = {
endOfLine: 'lf',
singleQuote: true,
trailingComma: 'all',
};

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

@ -34,9 +34,18 @@ to reduce the line length guess-work when adding imports, even though it's not t
UI
--
We use the [Airbnb](https://github.com/airbnb/javascript) style guide for Javascript and validate it with ESlint (see Validating Javascript in the [Installation section](installation.html#validating-javascript)). For CSS, we use [reactstrap](https://reactstrap.github.io/) and Bootstrap's utility classes as much as possible before adding custom CSS to a style sheet. Any custom style that can be made reusable should be named generically and stored in the ``ui/css/treeherder-global.css`` file.
We use Prettier for JS/JSX formatting and the [Airbnb](https://github.com/airbnb/javascript)
guide for non-style related best practices. Both are validated using ESlint (see Validating
Javascript in the [Installation section](installation.html#validating-javascript)).
We recommend that you [add Prettier to your editor/IDE](https://prettier.io/docs/en/editors.html)
and enable "format on save" for the most seamless development workflow.
Imports in JS/JSX must be ordered like so (with newlines between each group):
1. external modules (eg `'react'`)
2. modules from a parent directory (eg `'../foo'`)
3. "sibling" modules from the same or a sibling's directory (eg `'./bar'` or './bar/baz')
For CSS, we use [reactstrap](https://reactstrap.github.io/) and Bootstrap's utility classes as
much as possible before adding custom CSS to a style sheet. Any custom style that can be made
reusable should be named generically and stored in the ``ui/css/treeherder-global.css`` file.

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

@ -55,18 +55,26 @@ production site. You do not need to set up the Vagrant VM unless making backend
Validating JavaScript
---------------------
We run our JavaScript code in the frontend through [eslint] to ensure
We run our JavaScript code in the frontend through [ESLint] to ensure
that new code has a consistent style and doesn't suffer from common
errors. Eslint will run automatically when you build the JavaScript code
or run the development server. A production build will fail if your code
errors. ESLint will run automatically when you build the JavaScript code
or run the development server. A production build will fail if your code
does not match the style requirements.
To run eslint by itself, you may run the lint task:
To run ESLint by itself, you may run the lint task:
```bash
$ yarn lint
```
Or to automatically fix issues found (where possible):
```bash
$ yarn lint --fix
```
See the [code style](code_style.html#ui) section for more details.
Running the unit tests
----------------------
@ -86,7 +94,6 @@ $ yarn test:watch
The tests will perform an initial run and then re-execute each time a project file is changed.
Continue to the [Code Style](code_style.md) doc.
Server and Full-stack Development
=================================
@ -251,6 +258,6 @@ Continue to **Working with the Server** section after looking at the [Code Style
[Node.js]: https://nodejs.org/en/download/current/
[Yarn]: https://yarnpkg.com/en/docs/install
[package.json]: https://github.com/mozilla/treeherder/blob/master/package.json
[eslint]: https://eslint.org
[ESLint]: https://eslint.org
[Jasmine]: https://jasmine.github.io/
[enzyme]: http://airbnb.io/enzyme/

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

@ -18,13 +18,9 @@ webpackConfig.node.Buffer = true;
webpackConfig.optimization.splitChunks = false;
webpackConfig.optimization.runtimeChunk = false;
module.exports = (config) => {
module.exports = config => {
config.set({
plugins: [
'karma-webpack',
'karma-firefox-launcher',
'karma-jasmine',
],
plugins: ['karma-webpack', 'karma-firefox-launcher', 'karma-jasmine'],
browsers: ['FirefoxHeadless'],
frameworks: ['jasmine'],
files: [

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

@ -80,8 +80,10 @@
"enzyme-adapter-react-16": "1.7.0",
"eslint": "5.9.0",
"eslint-config-airbnb": "17.1.0",
"eslint-config-prettier": "3.3.0",
"eslint-plugin-import": "2.14.0",
"eslint-plugin-jsx-a11y": "6.1.2",
"eslint-plugin-prettier": "3.0.0",
"eslint-plugin-react": "7.11.1",
"fetch-mock": "7.2.5",
"jasmine-core": "3.3.0",
@ -90,6 +92,7 @@
"karma-firefox-launcher": "1.1.0",
"karma-jasmine": "2.0.0",
"karma-webpack": "4.0.0-beta.0",
"prettier": "1.15.2",
"webpack-dev-server": "3.1.10"
},
"scripts": {

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

@ -9,52 +9,57 @@ import FilterModel from '../../../../ui/models/filter';
const { getJSONFixture } = window;
describe('Pushes context', () => {
const repoName = 'mozilla-inbound';
const repoName = 'mozilla-inbound';
beforeEach(() => {
jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock';
jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock';
fetchMock.get(getProjectUrl('/resultset/?full=true&count=10', repoName),
getJSONFixture('push_list.json'),
);
fetchMock.get(
getProjectUrl('/resultset/?full=true&count=10', repoName),
getJSONFixture('push_list.json'),
);
fetchMock.get(
getProjectUrl('/jobs/?return_type=list&count=2000&result_set_id=1', repoName),
getJSONFixture('job_list/job_1.json'),
);
fetchMock.get(
getProjectUrl(
'/jobs/?return_type=list&count=2000&result_set_id=1',
repoName,
),
getJSONFixture('job_list/job_1.json'),
);
fetchMock.get(
getProjectUrl('/jobs/?return_type=list&count=2000&result_set_id=2', repoName),
getJSONFixture('job_list/job_2.json'),
);
});
fetchMock.get(
getProjectUrl(
'/jobs/?return_type=list&count=2000&result_set_id=2',
repoName,
),
getJSONFixture('job_list/job_2.json'),
);
});
afterEach(() => {
fetchMock.reset();
});
afterEach(() => {
fetchMock.reset();
});
/*
/*
Tests Pushes context
*/
it('should have 2 pushes', async () => {
const pushes = mount(
<PushesClass
filterModel={new FilterModel()}
notify={() => {}}
><div /></PushesClass>,
);
await pushes.instance().fetchPushes(10);
expect(pushes.state('pushList').length).toBe(2);
});
it('should have 2 pushes', async () => {
const pushes = mount(
<PushesClass filterModel={new FilterModel()} notify={() => {}}>
<div />
</PushesClass>,
);
await pushes.instance().fetchPushes(10);
expect(pushes.state('pushList').length).toBe(2);
});
it('should have id of 1 in current repo', async () => {
const pushes = mount(
<PushesClass
filterModel={new FilterModel()}
notify={() => {}}
><div /></PushesClass>,
);
await pushes.instance().fetchPushes(10);
expect(pushes.state('pushList')[0].id).toBe(1);
});
it('should have id of 1 in current repo', async () => {
const pushes = mount(
<PushesClass filterModel={new FilterModel()} notify={() => {}}>
<div />
</PushesClass>,
);
await pushes.instance().fetchPushes(10);
expect(pushes.state('pushList')[0].id).toBe(1);
});
});

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

@ -3,32 +3,33 @@ import angular from 'angular';
const { inject } = window;
describe('getRevisionUrl filter', () => {
let $filter;
beforeEach(angular.mock.module('treeherder'));
beforeEach(inject((_$filter_) => {
$filter = _$filter_;
}));
let $filter;
beforeEach(angular.mock.module('treeherder'));
beforeEach(inject(_$filter_ => {
$filter = _$filter_;
}));
it('escapes some html symbols', () => {
const getRevisionUrl = $filter('getRevisionUrl');
expect(getRevisionUrl('1234567890ab', 'mozilla-inbound'))
.toEqual('/#/jobs?repo=mozilla-inbound&revision=1234567890ab');
});
it('escapes some html symbols', () => {
const getRevisionUrl = $filter('getRevisionUrl');
expect(getRevisionUrl('1234567890ab', 'mozilla-inbound')).toEqual(
'/#/jobs?repo=mozilla-inbound&revision=1234567890ab',
);
});
});
describe('displayNumber filter', () => {
let $filter;
beforeEach(angular.mock.module('treeherder'));
beforeEach(inject((_$filter_) => {
$filter = _$filter_;
}));
let $filter;
beforeEach(angular.mock.module('treeherder'));
beforeEach(inject(_$filter_ => {
$filter = _$filter_;
}));
it('returns expected values', () => {
const displayPrecision = $filter('displayNumber');
const infinitySymbol = '\u221e';
expect(displayPrecision('123.53222')).toEqual('123.53');
expect(displayPrecision('123123123.53222')).toEqual('123,123,123.53');
expect(displayPrecision(1 / 0)).toEqual(infinitySymbol);
expect(displayPrecision(Number.NaN)).toEqual('N/A');
});
it('returns expected values', () => {
const displayPrecision = $filter('displayNumber');
const infinitySymbol = '\u221e';
expect(displayPrecision('123.53222')).toEqual('123.53');
expect(displayPrecision('123123123.53222')).toEqual('123,123,123.53');
expect(displayPrecision(1 / 0)).toEqual(infinitySymbol);
expect(displayPrecision(Number.NaN)).toEqual('N/A');
});
});

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

@ -8,21 +8,31 @@ describe('FilterModel', () => {
});
describe('parsing an old url', () => {
it('should parse the repo with defaults', () => {
window.location.hash = '?repo=mozilla-inbound';
const urlParams = FilterModel.getUrlParamsWithDefaults();
expect(urlParams).toEqual({
repo: ['mozilla-inbound'],
resultStatus: ['testfailed', 'busted', 'exception', 'success', 'retry', 'usercancel', 'running', 'pending', 'runnable'],
resultStatus: [
'testfailed',
'busted',
'exception',
'success',
'retry',
'usercancel',
'running',
'pending',
'runnable',
],
classifiedState: ['classified', 'unclassified'],
tier: ['1', '2'],
});
});
it('should parse resultStatus params', () => {
window.location.hash = '?repo=mozilla-inbound&filter-resultStatus=testfailed&' +
window.location.hash =
'?repo=mozilla-inbound&filter-resultStatus=testfailed&' +
'filter-resultStatus=busted&filter-resultStatus=exception&' +
'filter-resultStatus=success&filter-resultStatus=retry' +
'&filter-resultStatus=runnable';
@ -30,22 +40,46 @@ describe('FilterModel', () => {
expect(urlParams).toEqual({
repo: ['mozilla-inbound'],
resultStatus: ['testfailed', 'busted', 'exception', 'success', 'retry', 'runnable'],
resultStatus: [
'testfailed',
'busted',
'exception',
'success',
'retry',
'runnable',
],
classifiedState: ['classified', 'unclassified'],
tier: ['1', '2'],
});
});
it('should parse searchStr params with tier and groupState intact', () => {
window.location.hash = '?repo=mozilla-inbound&filter-searchStr=Linux%20x64%20debug%20build-linux64-base-toolchains%2Fdebug%20(Bb)&filter-tier=1&group_state=expanded';
window.location.hash =
'?repo=mozilla-inbound&filter-searchStr=Linux%20x64%20debug%20build-linux64-base-toolchains%2Fdebug%20(Bb)&filter-tier=1&group_state=expanded';
const urlParams = FilterModel.getUrlParamsWithDefaults();
expect(urlParams).toEqual({
repo: ['mozilla-inbound'],
resultStatus: ['testfailed', 'busted', 'exception', 'success', 'retry', 'usercancel', 'running', 'pending', 'runnable'],
resultStatus: [
'testfailed',
'busted',
'exception',
'success',
'retry',
'usercancel',
'running',
'pending',
'runnable',
],
classifiedState: ['classified', 'unclassified'],
tier: ['1'],
searchStr: ['linux', 'x64', 'debug', 'build-linux64-base-toolchains/debug', '(bb)'],
searchStr: [
'linux',
'x64',
'debug',
'build-linux64-base-toolchains/debug',
'(bb)',
],
group_state: ['expanded'],
});
});
@ -56,7 +90,17 @@ describe('FilterModel', () => {
expect(urlParams).toEqual({
repo: ['mozilla-inbound'],
resultStatus: ['testfailed', 'busted', 'exception', 'success', 'retry', 'usercancel', 'running', 'pending', 'runnable'],
resultStatus: [
'testfailed',
'busted',
'exception',
'success',
'retry',
'usercancel',
'running',
'pending',
'runnable',
],
classifiedState: ['classified', 'unclassified'],
tier: ['1', '2'],
job_type_name: ['mochi'],
@ -66,26 +110,51 @@ describe('FilterModel', () => {
describe('parsing a new url', () => {
it('should parse resultStatus and searchStr', () => {
window.location.hash = '?repo=mozilla-inbound&resultStatus=testfailed,busted,exception,success,retry,runnable&' +
'searchStr=linux,x64,debug,build-linux64-base-toolchains%2Fdebug,(bb)';
window.location.hash =
'?repo=mozilla-inbound&resultStatus=testfailed,busted,exception,success,retry,runnable&' +
'searchStr=linux,x64,debug,build-linux64-base-toolchains%2Fdebug,(bb)';
const urlParams = FilterModel.getUrlParamsWithDefaults();
expect(urlParams).toEqual({
repo: ['mozilla-inbound'],
resultStatus: ['testfailed', 'busted', 'exception', 'success', 'retry', 'runnable'],
resultStatus: [
'testfailed',
'busted',
'exception',
'success',
'retry',
'runnable',
],
classifiedState: ['classified', 'unclassified'],
tier: ['1', '2'],
searchStr: ['linux', 'x64', 'debug', 'build-linux64-base-toolchains/debug', '(bb)'],
searchStr: [
'linux',
'x64',
'debug',
'build-linux64-base-toolchains/debug',
'(bb)',
],
});
});
it('should preserve the case in email addresses', () => {
window.location.hash = '?repo=mozilla-inbound&author=VYV03354@nifty.ne.jp';
window.location.hash =
'?repo=mozilla-inbound&author=VYV03354@nifty.ne.jp';
const urlParams = FilterModel.getUrlParamsWithDefaults();
expect(urlParams).toEqual({
repo: ['mozilla-inbound'],
resultStatus: ['testfailed', 'busted', 'exception', 'success', 'retry', 'usercancel', 'running', 'pending', 'runnable'],
resultStatus: [
'testfailed',
'busted',
'exception',
'success',
'retry',
'usercancel',
'running',
'pending',
'runnable',
],
classifiedState: ['classified', 'unclassified'],
tier: ['1', '2'],
author: ['VYV03354@nifty.ne.jp'],

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

@ -18,7 +18,10 @@ describe('JobModel', () => {
describe('getList', () => {
beforeEach(() => {
fetchMock.get(getProjectUrl('/jobs/'), getJSONFixture('job_list/job_1.json'));
fetchMock.get(
getProjectUrl('/jobs/'),
getJSONFixture('job_list/job_1.json'),
);
});
it('should return a promise', () => {
@ -29,8 +32,14 @@ describe('JobModel', () => {
describe('pagination', () => {
beforeEach(() => {
fetchMock.get(getProjectUrl('/jobs/?count=2'), getJSONFixture('job_list/pagination/page_1.json'));
fetchMock.get(getProjectUrl('/jobs/?count=2&offset=2'), getJSONFixture('job_list/pagination/page_2.json'));
fetchMock.get(
getProjectUrl('/jobs/?count=2'),
getJSONFixture('job_list/pagination/page_1.json'),
);
fetchMock.get(
getProjectUrl('/jobs/?count=2&offset=2'),
getJSONFixture('job_list/pagination/page_2.json'),
);
});
it('should return a page of results by default', async () => {
@ -40,7 +49,11 @@ describe('JobModel', () => {
});
it('should return all the pages when fetch_all==true', async () => {
const jobList = await JobModel.getList(repoName, { count: 2 }, { fetch_all: true });
const jobList = await JobModel.getList(
repoName,
{ count: 2 },
{ fetch_all: true },
);
expect(jobList.length).toBe(3);
expect(jobList[2].id).toBe(3);

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

@ -7,8 +7,10 @@ import { isReftest } from '../../../../ui/helpers/job';
import { BugFilerClass } from '../../../../ui/job-view/details/BugFiler';
describe('BugFiler', () => {
const fullLog = 'https://queue.taskcluster.net/v1/task/AGs4CgN_RnCTb943uQn8NQ/runs/0/artifacts/public/logs/live_backing.log';
const parsedLog = 'http://localhost:5000/logviewer.html#?job_id=89017089&repo=mozilla-inbound';
const fullLog =
'https://queue.taskcluster.net/v1/task/AGs4CgN_RnCTb943uQn8NQ/runs/0/artifacts/public/logs/live_backing.log';
const parsedLog =
'http://localhost:5000/logviewer.html#?job_id=89017089&repo=mozilla-inbound';
const reftest = '';
const selectedJob = {
job_group_name: 'Mochitests executed by TaskCluster',
@ -16,9 +18,18 @@ describe('BugFiler', () => {
};
const suggestions = [
{ search: 'ShutdownLeaks | process() called before end of test suite' },
{ search: 'browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application terminated with exit code 11' },
{ search: 'browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application crashed [@ js::GCMarker::eagerlyMarkChildren]' },
{ search: 'leakcheck | default process: missing output line for total leaks!' },
{
search:
'browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application terminated with exit code 11',
},
{
search:
'browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application crashed [@ js::GCMarker::eagerlyMarkChildren]',
},
{
search:
'leakcheck | default process: missing output line for total leaks!',
},
{ search: '# TBPL FAILURE #' },
];
const successCallback = () => {};
@ -43,18 +54,20 @@ describe('BugFiler', () => {
fetchMock.get(
`${bzBaseUrl}rest/prod_comp_search/firefox%20::%20search?limit=5`,
{ products: [
{ product: 'Firefox' },
{ component: 'Search', product: 'Firefox' },
{ product: 'Marketplace' },
{ component: 'Search', product: 'Marketplace' },
{ product: 'Firefox for Android' },
{ component: 'Search Activity', product: 'Firefox for Android' },
{ product: 'Firefox OS' },
{ component: 'Gaia::Search', product: 'Firefox OS' },
{ product: 'Cloud Services' },
{ component: 'Operations: Storage', product: 'Cloud Services' },
] },
{
products: [
{ product: 'Firefox' },
{ component: 'Search', product: 'Firefox' },
{ product: 'Marketplace' },
{ component: 'Search', product: 'Marketplace' },
{ product: 'Firefox for Android' },
{ component: 'Search Activity', product: 'Firefox for Android' },
{ product: 'Firefox OS' },
{ component: 'Gaia::Search', product: 'Firefox OS' },
{ product: 'Cloud Services' },
{ component: 'Operations: Storage', product: 'Cloud Services' },
],
},
);
});
@ -62,10 +75,12 @@ describe('BugFiler', () => {
fetchMock.reset();
});
const getBugFilerForSummary = (summary) => {
const getBugFilerForSummary = summary => {
const suggestion = {
summary,
search_terms: ['browser_searchbar_smallpanel_keyboard_navigation.js", "[@ js::GCMarker::eagerlyMarkChildren]'],
search_terms: [
'browser_searchbar_smallpanel_keyboard_navigation.js", "[@ js::GCMarker::eagerlyMarkChildren]',
],
search: summary,
};
@ -86,129 +101,191 @@ describe('BugFiler', () => {
};
it('parses a crash suggestion', () => {
const summary = 'PROCESS-CRASH | browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application crashed [@ js::GCMarker::eagerlyMarkChildren]';
const summary =
'PROCESS-CRASH | browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js | application crashed [@ js::GCMarker::eagerlyMarkChildren]';
const bugFiler = getBugFilerForSummary(summary);
const { parsedSummary } = bugFiler.state();
expect(parsedSummary[0][0]).toEqual('browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js');
expect(parsedSummary[0][0]).toEqual(
'browser/components/search/test/browser_searchbar_smallpanel_keyboard_navigation.js',
);
});
it('should parse mochitest-bc summaries', () => {
const rawSummary = 'browser/components/sessionstore/test/browser_625016.js | observe1: 1 window in data written to disk - Got 0, expected 1';
const rawSummary =
'browser/components/sessionstore/test/browser_625016.js | observe1: 1 window in data written to disk - Got 0, expected 1';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('browser/components/sessionstore/test/browser_625016.js');
expect(summary[0][1]).toBe('observe1: 1 window in data written to disk - Got 0, expected 1');
expect(summary[0][0]).toBe(
'browser/components/sessionstore/test/browser_625016.js',
);
expect(summary[0][1]).toBe(
'observe1: 1 window in data written to disk - Got 0, expected 1',
);
expect(summary[1]).toBe('browser_625016.js');
});
it('should parse accessibility summaries', () => {
const rawSummary = 'chrome://mochitests/content/a11y/accessible/tests/mochitest/states/test_expandable.xul' +
const rawSummary =
'chrome://mochitests/content/a11y/accessible/tests/mochitest/states/test_expandable.xul' +
' | uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at ' +
'searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('accessible/tests/mochitest/states/test_expandable.xul');
expect(summary[0][1]).toBe('uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at ' +
'searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9');
expect(summary[0][0]).toBe(
'accessible/tests/mochitest/states/test_expandable.xul',
);
expect(summary[0][1]).toBe(
'uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at ' +
'searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9',
);
expect(summary[1]).toBe('test_expandable.xul');
});
it('should parse xpcshell summaries', () => {
const rawSummary = 'xpcshell-child-process.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
const rawSummary =
'xpcshell-child-process.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js');
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]');
expect(summary[0][0]).toBe(
'dom/indexedDB/test/unit/test_rename_objectStore_errors.js',
);
expect(summary[0][1]).toBe(
'application crashed [@ mozalloc_abort(char const*)]',
);
expect(summary[1]).toBe('test_rename_objectStore_errors.js');
});
it('should parse xpcshell unpack summaries', () => {
const rawSummary = 'xpcshell-unpack.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
const rawSummary =
'xpcshell-unpack.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js');
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]');
expect(summary[0][0]).toBe(
'dom/indexedDB/test/unit/test_rename_objectStore_errors.js',
);
expect(summary[0][1]).toBe(
'application crashed [@ mozalloc_abort(char const*)]',
);
expect(summary[1]).toBe('test_rename_objectStore_errors.js');
});
it('should parse xpcshell dom summaries', () => {
const rawSummary = 'xpcshell.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
const rawSummary =
'xpcshell.ini:dom/indexedDB/test/unit/test_rename_objectStore_errors.js | application crashed [@ mozalloc_abort(char const*)]';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js');
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]');
expect(summary[0][0]).toBe(
'dom/indexedDB/test/unit/test_rename_objectStore_errors.js',
);
expect(summary[0][1]).toBe(
'application crashed [@ mozalloc_abort(char const*)]',
);
expect(summary[1]).toBe('test_rename_objectStore_errors.js');
});
it('should parse Windows reftests on C drive summaries', () => {
const rawSummary = 'file:///C:/slave/test/build/tests/reftest/tests/layout/reftests/w3c-css/submitted/variables/variable-supports-12.html | application timed out after 330 seconds with no output';
const rawSummary =
'file:///C:/slave/test/build/tests/reftest/tests/layout/reftests/w3c-css/submitted/variables/variable-supports-12.html | application timed out after 330 seconds with no output';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('layout/reftests/w3c-css/submitted/variables/variable-supports-12.html');
expect(summary[0][1]).toBe('application timed out after 330 seconds with no output');
expect(summary[0][0]).toBe(
'layout/reftests/w3c-css/submitted/variables/variable-supports-12.html',
);
expect(summary[0][1]).toBe(
'application timed out after 330 seconds with no output',
);
expect(summary[1]).toBe('variable-supports-12.html');
});
it('should parse Linux reftest summaries', () => {
const rawSummary = 'file:///home/worker/workspace/build/tests/reftest/tests/image/test/reftest/encoders-lossless/size-7x7.png | application timed out after 330 seconds with no output';
const rawSummary =
'file:///home/worker/workspace/build/tests/reftest/tests/image/test/reftest/encoders-lossless/size-7x7.png | application timed out after 330 seconds with no output';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('image/test/reftest/encoders-lossless/size-7x7.png');
expect(summary[0][1]).toBe('application timed out after 330 seconds with no output');
expect(summary[0][0]).toBe(
'image/test/reftest/encoders-lossless/size-7x7.png',
);
expect(summary[0][1]).toBe(
'application timed out after 330 seconds with no output',
);
expect(summary[1]).toBe('size-7x7.png');
});
it('should parse Windows reftests on Z drive summaries', () => {
const rawSummary = 'file:///Z:/task_1491428153/build/tests/reftest/tests/layout/reftests/font-face/src-list-local-full.html == file:///Z:/task_1491428153/build/tests/reftest/tests/layout/reftests/font-face/src-list-local-full-ref.html | image comparison, max difference: 255, number of differing pixels: 5184';
const rawSummary =
'file:///Z:/task_1491428153/build/tests/reftest/tests/layout/reftests/font-face/src-list-local-full.html == file:///Z:/task_1491428153/build/tests/reftest/tests/layout/reftests/font-face/src-list-local-full-ref.html | image comparison, max difference: 255, number of differing pixels: 5184';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('layout/reftests/font-face/src-list-local-full.html == layout/reftests/font-face/src-list-local-full-ref.html');
expect(summary[0][1]).toBe('image comparison, max difference: 255, number of differing pixels: 5184');
expect(summary[0][0]).toBe(
'layout/reftests/font-face/src-list-local-full.html == layout/reftests/font-face/src-list-local-full-ref.html',
);
expect(summary[0][1]).toBe(
'image comparison, max difference: 255, number of differing pixels: 5184',
);
expect(summary[1]).toBe('src-list-local-full.html');
});
it('should parse android reftests summaries', () => {
const rawSummary = 'http://10.0.2.2:8854/tests/layout/reftests/css-display/display-contents-style-inheritance-1.html == http://10.0.2.2:8854/tests/layout/reftests/css-display/display-contents-style-inheritance-1-ref.html | image comparison, max difference: 255, number of differing pixels: 699';
const rawSummary =
'http://10.0.2.2:8854/tests/layout/reftests/css-display/display-contents-style-inheritance-1.html == http://10.0.2.2:8854/tests/layout/reftests/css-display/display-contents-style-inheritance-1-ref.html | image comparison, max difference: 255, number of differing pixels: 699';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('layout/reftests/css-display/display-contents-style-inheritance-1.html == layout/reftests/css-display/display-contents-style-inheritance-1-ref.html');
expect(summary[0][1]).toBe('image comparison, max difference: 255, number of differing pixels: 699');
expect(summary[0][0]).toBe(
'layout/reftests/css-display/display-contents-style-inheritance-1.html == layout/reftests/css-display/display-contents-style-inheritance-1-ref.html',
);
expect(summary[0][1]).toBe(
'image comparison, max difference: 255, number of differing pixels: 699',
);
expect(summary[1]).toBe('display-contents-style-inheritance-1.html');
});
it('should parse reftest unexpected pass summaries', () => {
const rawSummary = 'REFTEST TEST-UNEXPECTED-PASS | file:///home/worker/workspace/build/tests/reftest/tests/layout/' +
const rawSummary =
'REFTEST TEST-UNEXPECTED-PASS | file:///home/worker/workspace/build/tests/reftest/tests/layout/' +
'reftests/backgrounds/vector/empty/wide--cover--width.html == file:///home/worker/workspace/' +
'build/tests/reftest/tests/layout/reftests/backgrounds/vector/empty/ref-wide-lime.html | image comparison';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('TEST-UNEXPECTED-PASS');
expect(summary[0][1]).toBe('layout/reftests/backgrounds/vector/empty/wide--cover--width.html == layout/reftests/backgrounds/vector/empty/ref-wide-lime.html');
expect(summary[0][1]).toBe(
'layout/reftests/backgrounds/vector/empty/wide--cover--width.html == layout/reftests/backgrounds/vector/empty/ref-wide-lime.html',
);
expect(summary[0][2]).toBe('image comparison');
expect(summary[1]).toBe('wide--cover--width.html');
});
it('should parse finding the filename when the `TEST-FOO` is not omitted', () => {
const rawSummary = 'TEST-UNEXPECTED-CRASH | /service-workers/service-worker/xhr.https.html | expected OK';
const rawSummary =
'TEST-UNEXPECTED-CRASH | /service-workers/service-worker/xhr.https.html | expected OK';
const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('TEST-UNEXPECTED-CRASH');
expect(summary[0][1]).toBe('/service-workers/service-worker/xhr.https.html');
expect(summary[0][1]).toBe(
'/service-workers/service-worker/xhr.https.html',
);
expect(summary[0][2]).toBe('expected OK');
expect(summary[1]).toBe('xhr.https.html');
});
it('should strip omitted leads from thisFailure', () => {
const suggestions = [
{ bugs: {},
{
bugs: {},
search_terms: [],
search: 'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -' },
{ bugs: {},
search:
'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -',
},
{
bugs: {},
search_terms: [],
search: 'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Found a tab after previous test timed out: about:blank -' },
{ bugs: {},
search:
'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Found a tab after previous test timed out: about:blank -',
},
{
bugs: {},
search_terms: [],
search: 'REFTEST TEST-UNEXPECTED-PASS | flee | floo' },
search: 'REFTEST TEST-UNEXPECTED-PASS | flee | floo',
},
];
const bugFiler = mount(
<BugFilerClass
@ -226,8 +303,10 @@ describe('BugFiler', () => {
);
const { thisFailure } = bugFiler.state();
expect(thisFailure).toBe('browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -\n' +
'browser/extensions/pdfjs/test/browser_pdfjs_views.js | Found a tab after previous test timed out: about:blank -\n' +
'TEST-UNEXPECTED-PASS | flee | floo');
expect(thisFailure).toBe(
'browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -\n' +
'browser/extensions/pdfjs/test/browser_pdfjs_views.js | Found a tab after previous test timed out: about:blank -\n' +
'TEST-UNEXPECTED-PASS | flee | floo',
);
});
});

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

@ -13,7 +13,6 @@ describe('JobGroup component', () => {
const filterModel = new FilterModel();
const pushGroupState = 'collapsed';
beforeEach(() => {
jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock';
countGroup = getJSONFixture('mappedGroup.json');
@ -40,9 +39,9 @@ describe('JobGroup component', () => {
'<span class="platform-group" data-group-key="313281W-e10s1linux64debug"><span class="disabled job-group" title="Web platform tests with e10s">' +
'<button class="btn group-symbol">W-e10s</button>' +
'<span class="group-content">' +
'<span class="group-job-list"><button data-job-id="166315800" title="success | test-linux64/debug-web-platform-tests-reftests-e10s-1 - (18 mins)" class="btn btn-green filter-shown job-btn btn-xs">Wr1</button></span>' +
'<span class="group-count-list"><button class="btn-dkgray-count btn group-btn btn-xs job-group-count filter-shown" title="2 running jobs in group">2</button>' +
'</span></span></span></span>',
'<span class="group-job-list"><button data-job-id="166315800" title="success | test-linux64/debug-web-platform-tests-reftests-e10s-1 - (18 mins)" class="btn btn-green filter-shown job-btn btn-xs">Wr1</button></span>' +
'<span class="group-count-list"><button class="btn-dkgray-count btn group-btn btn-xs job-group-count filter-shown" title="2 running jobs in group">2</button>' +
'</span></span></span></span>',
);
});
@ -65,9 +64,9 @@ describe('JobGroup component', () => {
'<span class="platform-group" data-group-key="313281W-e10s1linux64debug"><span class="disabled job-group" title="Web platform tests with e10s">' +
'<button class="btn group-symbol">W-e10s</button>' +
'<span class="group-content">' +
'<span class="group-job-list"><button data-job-id="166315800" title="success | test-linux64/debug-web-platform-tests-reftests-e10s-1 - (18 mins)" class="btn btn-green filter-shown job-btn btn-xs">Wr1</button></span>' +
'<span class="group-count-list"><button class="btn-dkgray-count btn group-btn btn-xs job-group-count filter-shown" title="2 running jobs in group">2</button>' +
'</span></span></span></span>',
'<span class="group-job-list"><button data-job-id="166315800" title="success | test-linux64/debug-web-platform-tests-reftests-e10s-1 - (18 mins)" class="btn btn-green filter-shown job-btn btn-xs">Wr1</button></span>' +
'<span class="group-count-list"><button class="btn-dkgray-count btn group-btn btn-xs job-group-count filter-shown" title="2 running jobs in group">2</button>' +
'</span></span></span></span>',
);
});
@ -88,13 +87,13 @@ describe('JobGroup component', () => {
expect(jobGroup.html()).toEqual(
'<span class="platform-group" data-group-key="313281W-e10s1linux64debug"><span class="disabled job-group" title="Web platform tests with e10s">' +
'<button class="btn group-symbol">W-e10s</button>' +
'<span class="group-content">' +
'<span class="group-job-list">' +
'<button data-job-id="166315799" title="running | test-linux64/debug-web-platform-tests-wdspec-e10s - " class="btn btn-dkgray filter-shown job-btn btn-xs">Wd</button>' +
'<button data-job-id="166315800" title="success | test-linux64/debug-web-platform-tests-reftests-e10s-1 - (18 mins)" class="btn btn-green filter-shown job-btn btn-xs">Wr1</button>' +
'<button data-job-id="166315797" title="running | test-linux64/debug-web-platform-tests-e10s-1 - " class="btn btn-dkgray filter-shown job-btn btn-xs">wpt1</button>' +
'</span>' +
'<span class="group-count-list"></span></span></span></span>',
'<span class="group-content">' +
'<span class="group-job-list">' +
'<button data-job-id="166315799" title="running | test-linux64/debug-web-platform-tests-wdspec-e10s - " class="btn btn-dkgray filter-shown job-btn btn-xs">Wd</button>' +
'<button data-job-id="166315800" title="success | test-linux64/debug-web-platform-tests-reftests-e10s-1 - (18 mins)" class="btn btn-green filter-shown job-btn btn-xs">Wr1</button>' +
'<button data-job-id="166315797" title="running | test-linux64/debug-web-platform-tests-e10s-1 - " class="btn btn-dkgray filter-shown job-btn btn-xs">wpt1</button>' +
'</span>' +
'<span class="group-count-list"></span></span></span></span>',
);
});
@ -116,13 +115,13 @@ describe('JobGroup component', () => {
expect(jobGroup.html()).toEqual(
'<span class="platform-group" data-group-key="313281W-e10s1linux64debug"><span class="disabled job-group" title="Web platform tests with e10s">' +
'<button class="btn group-symbol">W-e10s</button>' +
'<span class="group-content">' +
'<span class="group-job-list">' +
'<button data-job-id="166315799" title="running | test-linux64/debug-web-platform-tests-wdspec-e10s - " class="btn btn-dkgray filter-shown job-btn btn-xs">Wd</button>' +
'<button data-job-id="166315800" title="success | test-linux64/debug-web-platform-tests-reftests-e10s-1 - (18 mins)" class="btn btn-green filter-shown job-btn btn-xs">Wr1</button>' +
'<button data-job-id="166315797" title="running | test-linux64/debug-web-platform-tests-e10s-1 - " class="btn btn-dkgray filter-shown job-btn btn-xs">wpt1</button>' +
'</span>' +
'<span class="group-count-list"></span></span></span></span>',
'<span class="group-content">' +
'<span class="group-job-list">' +
'<button data-job-id="166315799" title="running | test-linux64/debug-web-platform-tests-wdspec-e10s - " class="btn btn-dkgray filter-shown job-btn btn-xs">Wd</button>' +
'<button data-job-id="166315800" title="success | test-linux64/debug-web-platform-tests-reftests-e10s-1 - (18 mins)" class="btn btn-green filter-shown job-btn btn-xs">Wr1</button>' +
'<button data-job-id="166315797" title="running | test-linux64/debug-web-platform-tests-e10s-1 - " class="btn btn-dkgray filter-shown job-btn btn-xs">wpt1</button>' +
'</span>' +
'<span class="group-count-list"></span></span></span></span>',
);
});
@ -144,11 +143,11 @@ describe('JobGroup component', () => {
'<span class="platform-group" data-group-key="313293SM1linux64opt"><span class="disabled job-group" title="Spidermonkey builds">' +
'<button class="btn group-symbol">SM</button>' +
'<span class="group-content"><span class="group-job-list">' +
'<button data-job-id="166316707" title="retry | spidermonkey-sm-msan-linux64/opt - (0 mins)" class="btn btn-dkblue filter-shown job-btn btn-xs">msan</button>' +
'<button data-job-id="166316707" title="retry | spidermonkey-sm-msan-linux64/opt - (0 mins)" class="btn btn-dkblue filter-shown job-btn btn-xs">msan</button>' +
'</span>' +
'<span class="group-count-list">' +
'<button class="btn-green-count btn group-btn btn-xs job-group-count filter-shown" title="6 success jobs in group">6</button>' +
'</span></span></span></span>',
'<button class="btn-green-count btn group-btn btn-xs job-group-count filter-shown" title="6 success jobs in group">6</button>' +
'</span></span></span></span>',
);
});
@ -171,12 +170,12 @@ describe('JobGroup component', () => {
'<span class="platform-group" data-group-key="313293SM1linux64opt"><span class="disabled job-group" title="Spidermonkey builds">' +
'<button class="btn group-symbol">SM</button>' +
'<span class="group-content"><span class="group-job-list">' +
'<button data-job-id="166316707" title="retry | spidermonkey-sm-msan-linux64/opt - (0 mins)" class="btn btn-dkblue filter-shown job-btn btn-xs">msan</button>' +
'<button data-job-id="166321182" title="success | spidermonkey-sm-msan-linux64/opt - (10 mins)" class="btn btn-green filter-shown job-btn btn-xs">msan</button>' +
'<button data-job-id="166316707" title="retry | spidermonkey-sm-msan-linux64/opt - (0 mins)" class="btn btn-dkblue filter-shown job-btn btn-xs">msan</button>' +
'<button data-job-id="166321182" title="success | spidermonkey-sm-msan-linux64/opt - (10 mins)" class="btn btn-green filter-shown job-btn btn-xs">msan</button>' +
'</span>' +
'<span class="group-count-list">' +
'<button class="btn-green-count btn group-btn btn-xs job-group-count filter-shown" title="5 success jobs in group">5</button>' +
'</span></span></span></span>',
'<button class="btn-green-count btn group-btn btn-xs job-group-count filter-shown" title="5 success jobs in group">5</button>' +
'</span></span></span></span>',
);
});
@ -199,12 +198,12 @@ describe('JobGroup component', () => {
'<span class="platform-group" data-group-key="313293SM1linux64opt"><span class="disabled job-group" title="Spidermonkey builds">' +
'<button class="btn group-symbol">SM</button>' +
'<span class="group-content"><span class="group-job-list">' +
'<button data-job-id="166316707" title="retry | spidermonkey-sm-msan-linux64/opt - (0 mins)" class="btn btn-dkblue filter-shown job-btn btn-xs">msan</button>' +
'<button data-job-id="166321182" title="success | spidermonkey-sm-msan-linux64/opt - (10 mins)" class="btn btn-green filter-shown job-btn btn-xs">msan</button>' +
'<button data-job-id="166316707" title="retry | spidermonkey-sm-msan-linux64/opt - (0 mins)" class="btn btn-dkblue filter-shown job-btn btn-xs">msan</button>' +
'<button data-job-id="166321182" title="success | spidermonkey-sm-msan-linux64/opt - (10 mins)" class="btn btn-green filter-shown job-btn btn-xs">msan</button>' +
'</span>' +
'<span class="group-count-list">' +
'<button class="btn-green-count btn group-btn btn-xs job-group-count filter-shown" title="5 success jobs in group">5</button>' +
'</span></span></span></span>',
'<button class="btn-green-count btn group-btn btn-xs job-group-count filter-shown" title="5 success jobs in group">5</button>' +
'</span></span></span></span>',
);
});
});

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

@ -12,7 +12,6 @@ describe('Revision list component', () => {
let mockData;
beforeEach(() => {
const repo = new RepositoryModel({
id: 2,
repository_group: {
@ -27,48 +26,53 @@ describe('Revision list component', () => {
description: '',
active_status: 'active',
performance_alerts_enabled: true,
pushlogURL: 'https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml',
pushlogURL:
'https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml',
});
const push = {
id: 151371,
revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff',
revision_count: 3,
author: 'ryanvm@gmail.com',
push_timestamp: 1481326280,
repository_id: 2,
revisions: [{
result_set_id: 151371,
repository_id: 2,
revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff',
author: 'André Bargull <andre.bargull@gmail.com>',
comments: 'Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem',
}, {
result_set_id: 151371,
repository_id: 2,
revision: '07d6bf74b7a2552da91b5e2fce0fa0bc3b457394',
author: 'André Bargull <andre.bargull@gmail.com>',
comments: 'Bug 1319926 - Part 1: Warn when deprecated String generics methods are used. r=jandem',
}, {
result_set_id: 151371,
repository_id: 2,
revision: 'e83eaf2380c65400dc03c6f3615d4b2cef669af3',
author: 'Frédéric Wang <fred.wang@free.fr>',
comments: 'Bug 1322743 - Add STIX Two Math to the list of math fonts. r=karlt',
}],
};
mockData = {
push,
repo,
};
const push = {
id: 151371,
revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff',
revision_count: 3,
author: 'ryanvm@gmail.com',
push_timestamp: 1481326280,
repository_id: 2,
revisions: [
{
result_set_id: 151371,
repository_id: 2,
revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff',
author: 'André Bargull <andre.bargull@gmail.com>',
comments:
'Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem',
},
{
result_set_id: 151371,
repository_id: 2,
revision: '07d6bf74b7a2552da91b5e2fce0fa0bc3b457394',
author: 'André Bargull <andre.bargull@gmail.com>',
comments:
'Bug 1319926 - Part 1: Warn when deprecated String generics methods are used. r=jandem',
},
{
result_set_id: 151371,
repository_id: 2,
revision: 'e83eaf2380c65400dc03c6f3615d4b2cef669af3',
author: 'Frédéric Wang <fred.wang@free.fr>',
comments:
'Bug 1322743 - Add STIX Two Math to the list of math fonts. r=karlt',
},
],
};
mockData = {
push,
repo,
};
});
it('renders the correct number of revisions in a list', () => {
const wrapper = mount(
<RevisionList
repo={mockData.repo}
push={mockData.push}
/>,
<RevisionList repo={mockData.repo} push={mockData.push} />,
);
expect(wrapper.find(Revision).length).toEqual(mockData.push.revision_count);
});
@ -77,14 +81,10 @@ describe('Revision list component', () => {
mockData.push.revision_count = 21;
const wrapper = mount(
<RevisionList
repo={mockData.repo}
push={mockData.push}
/>,
<RevisionList repo={mockData.repo} push={mockData.push} />,
);
expect(wrapper.find(MoreRevisionsLink).length).toEqual(1);
});
});
describe('Revision item component', () => {
@ -105,14 +105,16 @@ describe('Revision item component', () => {
description: '',
active_status: 'active',
performance_alerts_enabled: true,
pushlogURL: 'https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml',
pushlogURL:
'https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml',
});
const revision = {
result_set_id: 151371,
repository_id: 2,
revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff',
author: 'André Bargull <andre.bargull@gmail.com>',
comments: 'Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem',
comments:
'Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem',
};
mockData = {
@ -123,21 +125,21 @@ describe('Revision item component', () => {
it('renders a linked revision', () => {
const wrapper = mount(
<Revision
repo={mockData.repo}
revision={mockData.revision}
/>);
<Revision repo={mockData.repo} revision={mockData.revision} />,
);
const link = wrapper.find('a').first();
expect(link.props().href).toEqual(mockData.repo.getRevisionHref(mockData.revision.revision));
expect(link.props().title).toEqual(`Open revision ${mockData.revision.revision} on ${mockData.repo.url}`);
expect(link.props().href).toEqual(
mockData.repo.getRevisionHref(mockData.revision.revision),
);
expect(link.props().title).toEqual(
`Open revision ${mockData.revision.revision} on ${mockData.repo.url}`,
);
});
it('renders the contributors\' initials', () => {
it("renders the contributors' initials", () => {
const wrapper = mount(
<Revision
repo={mockData.repo}
revision={mockData.revision}
/>);
<Revision repo={mockData.repo} revision={mockData.revision} />,
);
const initials = wrapper.find('.user-push-initials');
expect(initials.length).toEqual(1);
expect(initials.text()).toEqual('AB');
@ -145,30 +147,28 @@ describe('Revision item component', () => {
it('linkifies bug IDs in the comments', () => {
const wrapper = mount(
<Revision
repo={mockData.repo}
revision={mockData.revision}
/>);
<Revision repo={mockData.repo} revision={mockData.revision} />,
);
const comment = wrapper.find('.revision-comment em');
expect(comment.html()).toEqual('<em><span class="Linkify"><a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1319926" target="_blank" rel="noopener noreferrer">Bug 1319926</a> - Part 2: Collect telemetry about deprecated String generics methods. r=jandem</span></em>');
expect(comment.html()).toEqual(
'<em><span class="Linkify"><a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1319926" target="_blank" rel="noopener noreferrer">Bug 1319926</a> - Part 2: Collect telemetry about deprecated String generics methods. r=jandem</span></em>',
);
});
it('marks the revision as backed out if the words "Back/Backed out" appear in the comments', () => {
mockData.revision.comments = 'Backed out changeset a6e2d96c1274 (bug 1322565) for eslint failure';
mockData.revision.comments =
'Backed out changeset a6e2d96c1274 (bug 1322565) for eslint failure';
let wrapper = mount(
<Revision
repo={mockData.repo}
revision={mockData.revision}
/>);
<Revision repo={mockData.repo} revision={mockData.revision} />,
);
expect(wrapper.find({ 'data-tags': 'backout' }).length).toEqual(1);
mockData.revision.comments = 'Back out changeset a6e2d96c1274 (bug 1322565) for eslint failure';
mockData.revision.comments =
'Back out changeset a6e2d96c1274 (bug 1322565) for eslint failure';
wrapper = mount(
<Revision
repo={mockData.repo}
revision={mockData.revision}
/>);
<Revision repo={mockData.repo} revision={mockData.revision} />,
);
expect(wrapper.find({ 'data-tags': 'backout' }).length).toEqual(1);
});
});
@ -192,32 +192,32 @@ describe('initials filter', () => {
it('initializes a one-word name', () => {
const name = 'Starscream';
const initials = mount(
<Initials
title={`${name}: ${email}`}
author={name}
/>);
expect(initials.html()).toEqual('<span title="Starscream: foo@bar.baz"><span class="user-push-icon"><i class="fa fa-user-o" aria-hidden="true"></i></span><div class="icon-superscript user-push-initials">S</div></span>');
<Initials title={`${name}: ${email}`} author={name} />,
);
expect(initials.html()).toEqual(
'<span title="Starscream: foo@bar.baz"><span class="user-push-icon"><i class="fa fa-user-o" aria-hidden="true"></i></span><div class="icon-superscript user-push-initials">S</div></span>',
);
});
it('initializes a two-word name', () => {
const name = 'Optimus Prime';
const initials = mount(
<Initials
title={`${name}: ${email}`}
author={name}
/>);
<Initials title={`${name}: ${email}`} author={name} />,
);
const userPushInitials = initials.find('.user-push-initials');
expect(userPushInitials.html()).toEqual('<div class="icon-superscript user-push-initials">OP</div>');
expect(userPushInitials.html()).toEqual(
'<div class="icon-superscript user-push-initials">OP</div>',
);
});
it('initializes a three-word name', () => {
const name = 'Some Other Transformer';
const initials = mount(
<Initials
title={`${name}: ${email}`}
author={name}
/>);
<Initials title={`${name}: ${email}`} author={name} />,
);
const userPushInitials = initials.find('.user-push-initials');
expect(userPushInitials.html()).toEqual('<div class="icon-superscript user-push-initials">ST</div>');
expect(userPushInitials.html()).toEqual(
'<div class="icon-superscript user-push-initials">ST</div>',
);
});
});

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

@ -1,17 +1,17 @@
export const escapeId = id => (
id.replace(/(:|\[|\]|\?|,|\.|\s+)/g, '-')
);
export const escapeId = id => id.replace(/(:|\[|\]|\?|,|\.|\s+)/g, '-');
export const getPlatformRowId = (repoName, pushId, platformName, platformOptions) => (
export const getPlatformRowId = (
repoName,
pushId,
platformName,
platformOptions,
) =>
// ensure there are no invalid characters in the id (like spaces, etc)
escapeId(`${repoName}${pushId}${platformName}${platformOptions}`)
);
escapeId(`${repoName}${pushId}${platformName}${platformOptions}`);
export const getPushTableId = (repoName, pushId, revision) => (
escapeId(`${repoName}${pushId}${revision}`)
);
export const getPushTableId = (repoName, pushId, revision) =>
escapeId(`${repoName}${pushId}${revision}`);
export const getGroupMapKey = (grSymbol, grTier, plName, plOpt) => (
export const getGroupMapKey = (grSymbol, grTier, plName, plOpt) =>
// Build string key for groupMap entries
escapeId(`${grSymbol}${grTier}${plName}${plOpt}`)
);
escapeId(`${grSymbol}${grTier}${plName}${plOpt}`);

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

@ -8,12 +8,14 @@ export const webAuth = new WebAuth({
domain: 'auth.mozilla.auth0.com',
responseType: 'id_token token',
audience: 'login.taskcluster.net',
redirectUri: `${window.location.protocol}//${window.location.host}${loginCallbackUrl}`,
redirectUri: `${window.location.protocol}//${
window.location.host
}${loginCallbackUrl}`,
scope: 'taskcluster-credentials openid profile email',
});
export const userSessionFromAuthResult = (authResult) => {
const expiresAt = JSON.stringify((authResult.expiresIn * 1000) + Date.now());
export const userSessionFromAuthResult = authResult => {
const expiresAt = JSON.stringify(authResult.expiresIn * 1000 + Date.now());
const userSession = {
idToken: authResult.idToken,
accessToken: authResult.accessToken,
@ -31,7 +33,7 @@ export const userSessionFromAuthResult = (authResult) => {
};
// Wrapper around webAuth's renewAuth
export const renew = () => (
export const renew = () =>
new Promise((resolve, reject) => {
webAuth.renewAuth({}, (error, authResult) => {
if (error) {
@ -40,11 +42,10 @@ export const renew = () => (
return resolve(authResult);
});
})
);
});
// Wrapper around webAuth's parseHash
export const parseHash = options => (
export const parseHash = options =>
new Promise((resolve, reject) => {
webAuth.parseHash(options, (error, authResult) => {
if (error) {
@ -53,7 +54,11 @@ export const parseHash = options => (
return resolve(authResult);
});
})
);
});
export const loggedOutUser = { isStaff: false, username: '', email: '', isLoggedIn: false };
export const loggedOutUser = {
isStaff: false,
username: '',
email: '',
isLoggedIn: false,
};

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

@ -22,13 +22,12 @@ export const stringOverlap = function stringOverlap(str1, str2) {
const tokenCounts = tokens.map(tokens => countBy(tokens, x => x));
const overlap = Object.keys(tokenCounts[0])
.reduce((overlap, x) => {
if (Object.prototype.hasOwnProperty.call(tokenCounts[1], x)) {
overlap += 2 * Math.min(tokenCounts[0][x], tokenCounts[1][x]);
}
return overlap;
}, 0);
const overlap = Object.keys(tokenCounts[0]).reduce((overlap, x) => {
if (Object.prototype.hasOwnProperty.call(tokenCounts[1], x)) {
overlap += 2 * Math.min(tokenCounts[0][x], tokenCounts[1][x]);
}
return overlap;
}, 0);
return overlap / (tokens[0].length + tokens[1].length);
};
@ -37,10 +36,12 @@ export const highlightLogLine = function highlightLogLine(logLine) {
const parts = logLine.split(' | ', 3);
return (
<span>
{parts[0].startsWith('TEST-UNEXPECTED') && <span>
<strong className="failure-line-status">{parts[0]}</strong>
<strong>{parts[1]}</strong>
</span>}
{parts[0].startsWith('TEST-UNEXPECTED') && (
<span>
<strong className="failure-line-status">{parts[0]}</strong>
<strong>{parts[1]}</strong>
</span>
)}
{!parts[0].startsWith('TEST-UNEXPECTED') && logLine}
</span>
);

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

@ -5,181 +5,181 @@ import closedTreeFavicon from '../img/tree_closed.png';
// to a specific helper or into the classes that use them.
export const thPlatformMap = {
linux32: 'Linux',
'linux32-devedition': 'Linux DevEdition',
'linux32-qr': 'Linux QuantumRender',
'linux32-nightly': 'Linux Nightly',
'linux32-stylo': 'Linux Stylo',
'linux32-stylo-disabled': 'Linux Stylo Disabled',
linux64: 'Linux x64',
'linux64-asan': 'Linux x64 asan',
'linux64-add-on-devel': 'Linux x64 addon',
'linux64-devedition': 'Linux x64 DevEdition',
'linux64-qr': 'Linux x64 QuantumRender',
'linux64-nightly': 'Linux x64 Nightly',
'linux64-stylo': 'Linux x64 Stylo',
'linux64-stylo-disabled': 'Linux x64 Stylo Disabled',
'linux64-stylo-sequential': 'Linux x64 Stylo-Seq',
'linux64-ccov': 'Linux x64 CCov',
'linux64-jsdcov': 'Linux x64 JSDCov',
'linux64-noopt': 'Linux x64 NoOpt',
'linux64-dmd': 'Linux x64 DMD',
'osx-10-6': 'OS X 10.6',
'osx-10-7': 'OS X 10.7',
'osx-10-7-add-on-devel': 'OS X 10.7 addon',
'osx-10-7-devedition': 'OS X 10.7 DevEdition',
'osx-10-8': 'OS X 10.8',
'osx-10-9': 'OS X 10.9',
'osx-10-10': 'OS X 10.10',
'osx-10-10-devedition': 'OS X 10.10 DevEdition',
'osx-10-10-dmd': 'OS X 10.10 DMD',
'osx-10-11': 'OS X 10.11',
'osx-10-7-noopt': 'OS X 10.7 NoOpt',
'osx-cross': 'OS X Cross Compiled',
'osx-cross-noopt': 'OS X Cross Compiled NoOpt',
'osx-cross-add-on-devel': 'OS X Cross Compiled addon',
'osx-cross-devedition': 'OS X Cross Compiled DevEdition',
'macosx64-qr': 'OS X 10.10 QuantumRender',
'macosx64-stylo': 'OS X 10.10 Stylo',
'macosx64-stylo-disabled': 'OS X 10.10 Stylo Disabled',
'macosx64-devedition': 'OS X 10.10 DevEdition',
'macosx64-nightly': 'OS X 10.10 Nightly',
windowsxp: 'Windows XP',
'windowsxp-devedition': 'Windows XP DevEdition',
'windows7-32': 'Windows 7',
'windows7-32-vm': 'Windows 7 VM',
'windows7-32-devedition': 'Windows 7 DevEdition',
'windows7-32-stylo-disabled': 'Windows 7 Stylo Disabled',
'windows7-32-vm-devedition': 'Windows 7 VM DevEdition',
'windows7-32-nightly': 'Windows 7 VM Nightly',
'windows7-32-stylo': 'Windows 7 VM Stylo',
'windows7-64': 'Windows 7 x64',
'windows8-32': 'Windows 8',
'windows8-64': 'Windows 8 x64',
'windows8-64-devedition': 'Windows 8 x64 DevEdition',
'windows10-32': 'Windows 10',
'windows10-64': 'Windows 10 x64',
'windows10-64-vm': 'Windows 10 x64 VM',
'windows10-64-devedition': 'Windows 10 x64 DevEdition',
'windows10-64-nightly': 'Windows 10 x64 Nightly',
'windows10-64-stylo': 'Windows 10 x64 Stylo',
'windows10-64-stylo-disabled': 'Windows 10 x64 Stylo Disabled',
'windows10-64-qr': 'Windows 10 x64 QuantumRender',
'windows2012-32': 'Windows 2012',
'windows2012-32-add-on-devel': 'Windows 2012 addon',
'windows2012-32-noopt': 'Windows 2012 NoOpt',
'windows2012-32-devedition': 'Windows 2012 DevEdition',
'windows2012-32-dmd': 'Windows 2012 DMD',
'windows2012-64': 'Windows 2012 x64',
'windows2012-64-add-on-devel': 'Windows 2012 x64 addon',
'windows2012-64-noopt': 'Windows 2012 x64 NoOpt',
'windows2012-64-devedition': 'Windows 2012 x64 DevEdition',
'windows2012-64-dmd': 'Windows 2012 x64 DMD',
'windows-mingw32': 'Windows MinGW',
linux32: 'Linux',
'linux32-devedition': 'Linux DevEdition',
'linux32-qr': 'Linux QuantumRender',
'linux32-nightly': 'Linux Nightly',
'linux32-stylo': 'Linux Stylo',
'linux32-stylo-disabled': 'Linux Stylo Disabled',
linux64: 'Linux x64',
'linux64-asan': 'Linux x64 asan',
'linux64-add-on-devel': 'Linux x64 addon',
'linux64-devedition': 'Linux x64 DevEdition',
'linux64-qr': 'Linux x64 QuantumRender',
'linux64-nightly': 'Linux x64 Nightly',
'linux64-stylo': 'Linux x64 Stylo',
'linux64-stylo-disabled': 'Linux x64 Stylo Disabled',
'linux64-stylo-sequential': 'Linux x64 Stylo-Seq',
'linux64-ccov': 'Linux x64 CCov',
'linux64-jsdcov': 'Linux x64 JSDCov',
'linux64-noopt': 'Linux x64 NoOpt',
'linux64-dmd': 'Linux x64 DMD',
'osx-10-6': 'OS X 10.6',
'osx-10-7': 'OS X 10.7',
'osx-10-7-add-on-devel': 'OS X 10.7 addon',
'osx-10-7-devedition': 'OS X 10.7 DevEdition',
'osx-10-8': 'OS X 10.8',
'osx-10-9': 'OS X 10.9',
'osx-10-10': 'OS X 10.10',
'osx-10-10-devedition': 'OS X 10.10 DevEdition',
'osx-10-10-dmd': 'OS X 10.10 DMD',
'osx-10-11': 'OS X 10.11',
'osx-10-7-noopt': 'OS X 10.7 NoOpt',
'osx-cross': 'OS X Cross Compiled',
'osx-cross-noopt': 'OS X Cross Compiled NoOpt',
'osx-cross-add-on-devel': 'OS X Cross Compiled addon',
'osx-cross-devedition': 'OS X Cross Compiled DevEdition',
'macosx64-qr': 'OS X 10.10 QuantumRender',
'macosx64-stylo': 'OS X 10.10 Stylo',
'macosx64-stylo-disabled': 'OS X 10.10 Stylo Disabled',
'macosx64-devedition': 'OS X 10.10 DevEdition',
'macosx64-nightly': 'OS X 10.10 Nightly',
windowsxp: 'Windows XP',
'windowsxp-devedition': 'Windows XP DevEdition',
'windows7-32': 'Windows 7',
'windows7-32-vm': 'Windows 7 VM',
'windows7-32-devedition': 'Windows 7 DevEdition',
'windows7-32-stylo-disabled': 'Windows 7 Stylo Disabled',
'windows7-32-vm-devedition': 'Windows 7 VM DevEdition',
'windows7-32-nightly': 'Windows 7 VM Nightly',
'windows7-32-stylo': 'Windows 7 VM Stylo',
'windows7-64': 'Windows 7 x64',
'windows8-32': 'Windows 8',
'windows8-64': 'Windows 8 x64',
'windows8-64-devedition': 'Windows 8 x64 DevEdition',
'windows10-32': 'Windows 10',
'windows10-64': 'Windows 10 x64',
'windows10-64-vm': 'Windows 10 x64 VM',
'windows10-64-devedition': 'Windows 10 x64 DevEdition',
'windows10-64-nightly': 'Windows 10 x64 Nightly',
'windows10-64-stylo': 'Windows 10 x64 Stylo',
'windows10-64-stylo-disabled': 'Windows 10 x64 Stylo Disabled',
'windows10-64-qr': 'Windows 10 x64 QuantumRender',
'windows2012-32': 'Windows 2012',
'windows2012-32-add-on-devel': 'Windows 2012 addon',
'windows2012-32-noopt': 'Windows 2012 NoOpt',
'windows2012-32-devedition': 'Windows 2012 DevEdition',
'windows2012-32-dmd': 'Windows 2012 DMD',
'windows2012-64': 'Windows 2012 x64',
'windows2012-64-add-on-devel': 'Windows 2012 x64 addon',
'windows2012-64-noopt': 'Windows 2012 x64 NoOpt',
'windows2012-64-devedition': 'Windows 2012 x64 DevEdition',
'windows2012-64-dmd': 'Windows 2012 x64 DMD',
'windows-mingw32': 'Windows MinGW',
'android-2-2-armv6': 'Android 2.2 Armv6',
'android-2-2': 'Android 2.2',
'android-2-3-armv6': 'Android 2.3 Armv6',
'android-2-3': 'Android 2.3',
'android-2-3-armv7-api9': 'Android 2.3 API9',
'android-4-0': 'Android 4.0',
'android-4-0-armv7-api10': 'Android 4.0 API10+',
'android-4-0-armv7-api11': 'Android 4.0 API11+',
'android-4-0-armv7-api15': 'Android 4.0 API15+',
'android-4-0-armv7-api15-old-id': 'Android 4.0 API15+ OldId',
'android-4-0-armv7-api16': 'Android 4.0 API16+',
'android-em-4-0-armv7-api16': 'Android 4.0 API16+',
'android-4-0-armv7-api16-old-id': 'Android 4.0 API16+ OldId',
'android-4-2-x86': 'Android 4.2 x86',
'android-em-4-2-x86': 'Android 4.2 x86',
'android-4-2-x86-old-id': 'Android 4.2 x86 OldId',
'android-4-2': 'Android 4.2',
'android-4-2-armv7-api11': 'Android 4.2 API11+',
'android-4-2-armv7-api15': 'Android 4.2 API15+',
'android-4-2-armv7-api16': 'Android 4.2 API16+',
'android-em-4-2-armv7-api16': 'Android 4.2 API16+',
'android-4-3': 'Android 4.3',
'android-4-3-armv7-api11': 'Android 4.3 API11+',
'android-4-3-armv7-api15': 'Android 4.3 API15+',
'android-4-3-armv7-api16': 'Android 4.3 API16+',
'android-em-4-3-armv7-api16': 'Android 4.3 API16+',
'android-em-4-3-armv7-api16-ccov': 'Android 4.3 API16+ CCov',
'android-4-4': 'Android 4.4',
'android-4-4-armv7-api11': 'Android 4.4 API11+',
'android-4-4-armv7-api15': 'Android 4.4 API15+',
'android-4-4-armv7-api16': 'Android 4.4 API16+',
'android-5-0-aarch64': 'Android 5.0 AArch64',
'android-5-0-armv7-api11': 'Android 5.0 API11+',
'android-5-0-armv7-api15': 'Android 5.0 API15+',
'android-5-0-armv8-api15': 'Android 5.0 API15+',
'android-5-0-armv8-api16': 'Android 5.0 API16+',
'android-5-0-x86_64': 'Android 5.0 x86-64',
'android-5-1-armv7-api15': 'Android 5.1 API15+',
'android-6-0-armv8-api15': 'Android 6.0 API15+',
'android-6-0-armv8-api16': 'Android 6.0 API16+',
'android-em-7-0-x86': 'Android 7.0 x86',
'android-7-1-armv8-api15': 'Android 7.1 API15+',
'android-7-1-armv8-api16': 'Android 7.1 API16+',
'b2gdroid-4-0-armv7-api11': 'B2GDroid 4.0 API11+',
'b2gdroid-4-0-armv7-api15': 'B2GDroid 4.0 API15+',
'android-4-0-armv7-api11-partner1': 'Android API11+ partner1',
'android-4-0-armv7-api15-partner1': 'Android API15+ partner1',
'android-api-15-gradle': 'Android API15+ Gradle',
'android-api-16-gradle': 'Android API16+ Gradle',
'android-hw-g5-7-0-arm7-api-16': 'Android 7.0 MotoG5',
'android-hw-p2-8-0-arm7-api-16': 'Android 8.0 Pixel2',
'android-hw-p2-8-0-android-aarch64': 'Android 8.0 Pixel2 AArch64',
Android: 'Android',
'android-2-2-armv6': 'Android 2.2 Armv6',
'android-2-2': 'Android 2.2',
'android-2-3-armv6': 'Android 2.3 Armv6',
'android-2-3': 'Android 2.3',
'android-2-3-armv7-api9': 'Android 2.3 API9',
'android-4-0': 'Android 4.0',
'android-4-0-armv7-api10': 'Android 4.0 API10+',
'android-4-0-armv7-api11': 'Android 4.0 API11+',
'android-4-0-armv7-api15': 'Android 4.0 API15+',
'android-4-0-armv7-api15-old-id': 'Android 4.0 API15+ OldId',
'android-4-0-armv7-api16': 'Android 4.0 API16+',
'android-em-4-0-armv7-api16': 'Android 4.0 API16+',
'android-4-0-armv7-api16-old-id': 'Android 4.0 API16+ OldId',
'android-4-2-x86': 'Android 4.2 x86',
'android-em-4-2-x86': 'Android 4.2 x86',
'android-4-2-x86-old-id': 'Android 4.2 x86 OldId',
'android-4-2': 'Android 4.2',
'android-4-2-armv7-api11': 'Android 4.2 API11+',
'android-4-2-armv7-api15': 'Android 4.2 API15+',
'android-4-2-armv7-api16': 'Android 4.2 API16+',
'android-em-4-2-armv7-api16': 'Android 4.2 API16+',
'android-4-3': 'Android 4.3',
'android-4-3-armv7-api11': 'Android 4.3 API11+',
'android-4-3-armv7-api15': 'Android 4.3 API15+',
'android-4-3-armv7-api16': 'Android 4.3 API16+',
'android-em-4-3-armv7-api16': 'Android 4.3 API16+',
'android-em-4-3-armv7-api16-ccov': 'Android 4.3 API16+ CCov',
'android-4-4': 'Android 4.4',
'android-4-4-armv7-api11': 'Android 4.4 API11+',
'android-4-4-armv7-api15': 'Android 4.4 API15+',
'android-4-4-armv7-api16': 'Android 4.4 API16+',
'android-5-0-aarch64': 'Android 5.0 AArch64',
'android-5-0-armv7-api11': 'Android 5.0 API11+',
'android-5-0-armv7-api15': 'Android 5.0 API15+',
'android-5-0-armv8-api15': 'Android 5.0 API15+',
'android-5-0-armv8-api16': 'Android 5.0 API16+',
'android-5-0-x86_64': 'Android 5.0 x86-64',
'android-5-1-armv7-api15': 'Android 5.1 API15+',
'android-6-0-armv8-api15': 'Android 6.0 API15+',
'android-6-0-armv8-api16': 'Android 6.0 API16+',
'android-em-7-0-x86': 'Android 7.0 x86',
'android-7-1-armv8-api15': 'Android 7.1 API15+',
'android-7-1-armv8-api16': 'Android 7.1 API16+',
'b2gdroid-4-0-armv7-api11': 'B2GDroid 4.0 API11+',
'b2gdroid-4-0-armv7-api15': 'B2GDroid 4.0 API15+',
'android-4-0-armv7-api11-partner1': 'Android API11+ partner1',
'android-4-0-armv7-api15-partner1': 'Android API15+ partner1',
'android-api-15-gradle': 'Android API15+ Gradle',
'android-api-16-gradle': 'Android API16+ Gradle',
'android-hw-g5-7-0-arm7-api-16': 'Android 7.0 MotoG5',
'android-hw-p2-8-0-arm7-api-16': 'Android 8.0 Pixel2',
'android-hw-p2-8-0-android-aarch64': 'Android 8.0 Pixel2 AArch64',
Android: 'Android',
'b2g-linux32': 'B2G Desktop Linux',
'b2g-linux64': 'B2G Desktop Linux x64',
'b2g-osx': 'B2G Desktop OS X',
'b2g-win32': 'B2G Desktop Windows',
'b2g-emu-ics': 'B2G ICS Emulator',
'b2g-emu-jb': 'B2G JB Emulator',
'b2g-emu-kk': 'B2G KK Emulator',
'b2g-emu-x86-kk': 'B2G KK Emulator x86',
'b2g-emu-l': 'B2G L Emulator',
'b2g-device-image': 'B2G Device Image',
'mulet-linux32': 'Mulet Linux',
'mulet-linux64': 'Mulet Linux x64',
'mulet-osx': 'Mulet OS X',
'mulet-win32': 'Mulet Windows',
'b2g-linux32': 'B2G Desktop Linux',
'b2g-linux64': 'B2G Desktop Linux x64',
'b2g-osx': 'B2G Desktop OS X',
'b2g-win32': 'B2G Desktop Windows',
'b2g-emu-ics': 'B2G ICS Emulator',
'b2g-emu-jb': 'B2G JB Emulator',
'b2g-emu-kk': 'B2G KK Emulator',
'b2g-emu-x86-kk': 'B2G KK Emulator x86',
'b2g-emu-l': 'B2G L Emulator',
'b2g-device-image': 'B2G Device Image',
'mulet-linux32': 'Mulet Linux',
'mulet-linux64': 'Mulet Linux x64',
'mulet-osx': 'Mulet OS X',
'mulet-win32': 'Mulet Windows',
'graphene-linux64': 'Graphene Linux x64',
'graphene-osx': 'Graphene OS X',
'graphene-win64': 'Graphene Windows x64',
'horizon-linux64': 'Horizon Linux x64',
'horizon-osx': 'Horizon OS X',
'horizon-win64': 'Horizon Windows x64',
'graphene-linux64': 'Graphene Linux x64',
'graphene-osx': 'Graphene OS X',
'graphene-win64': 'Graphene Windows x64',
'horizon-linux64': 'Horizon Linux x64',
'horizon-osx': 'Horizon OS X',
'horizon-win64': 'Horizon Windows x64',
'gecko-decision': 'Gecko Decision Task',
'firefox-release': 'Firefox Release Tasks',
'devedition-release': 'Devedition Release Tasks',
'fennec-release': 'Fennec Release Tasks',
'thunderbird-release': 'Thunderbird Release Tasks',
lint: 'Linting',
'release-mozilla-release-': 'Balrog Publishing',
'taskcluster-images': 'Docker Images',
packages: 'Packages',
toolchains: 'Toolchains',
diff: 'Diffoscope',
other: 'Other',
'gecko-decision': 'Gecko Decision Task',
'firefox-release': 'Firefox Release Tasks',
'devedition-release': 'Devedition Release Tasks',
'fennec-release': 'Fennec Release Tasks',
'thunderbird-release': 'Thunderbird Release Tasks',
lint: 'Linting',
'release-mozilla-release-': 'Balrog Publishing',
'taskcluster-images': 'Docker Images',
packages: 'Packages',
toolchains: 'Toolchains',
diff: 'Diffoscope',
other: 'Other',
};
// Platforms where the `opt` should be dropped from
export const thSimplePlatforms = [
'gecko-decision',
'firefox-release',
'devedition-release',
'fennec-release',
'thunderbird-release',
'lint',
'release-mozilla-release-',
'taskcluster-images',
'packages',
'toolchains',
'diff',
'gecko-decision',
'firefox-release',
'devedition-release',
'fennec-release',
'thunderbird-release',
'lint',
'release-mozilla-release-',
'taskcluster-images',
'packages',
'toolchains',
'diff',
];
export const thFailureResults = ['testfailed', 'busted', 'exception'];
@ -226,7 +226,8 @@ export const thJobNavSelectors = {
},
UNCLASSIFIED_FAILURES: {
name: 'unclassified failures',
selector: '.selected-job, .job-btn.btn-red, .job-btn.btn-orange, .job-btn.btn-purple, .job-btn.autoclassified',
selector:
'.selected-job, .job-btn.btn-red, .job-btn.btn-orange, .job-btn.btn-purple, .job-btn.autoclassified',
},
};
@ -261,7 +262,8 @@ export const phTimeRanges = [
{ value: 2592000, text: 'Last 30 days' },
{ value: 5184000, text: 'Last 60 days' },
{ value: 7776000, text: 'Last 90 days' },
{ value: 31536000, text: 'Last year' }];
{ value: 31536000, text: 'Last year' },
];
export const phDefaultTimeRangeValue = 1209600;
@ -272,10 +274,10 @@ export const phTimeRangeValues = {
export const phDefaultFramework = 'talos';
export const phFrameworksWithRelatedBranches = [
1, // talos
10, // raptor
11, // js-bench
12, // devtools
1, // talos
10, // raptor
11, // js-bench
12, // devtools
];
export const phAlertSummaryStatusMap = {

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

@ -28,9 +28,10 @@ export const toShortDateStr = function toDateStr(timestamp) {
export const getSearchWords = function getHighlighterArray(text) {
const tokens = text.split(/[^a-zA-Z0-9_-]+/);
return tokens.reduce((acc, token) => (
token.length > 1 ? [...acc, token] : acc
), []);
return tokens.reduce(
(acc, token) => (token.length > 1 ? [...acc, token] : acc),
[],
);
};
export const getPercentComplete = function getPercentComplete(counts) {
@ -38,5 +39,5 @@ export const getPercentComplete = function getPercentComplete(counts) {
const inProgress = pending + running;
const total = completed + inProgress;
return total > 0 ? Math.floor(((completed / total) * 100)) : 0;
return total > 0 ? Math.floor((completed / total) * 100) : 0;
};

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

@ -11,19 +11,20 @@ Helper method for constructing an error message from the server side.
found in the error object.
*/
export const formatModelError = function formatModelError(e, message) {
// Generic error message when we encounter 401 status codes from the
// server.
const AUTH_ERROR_MSG = 'Please login to Treeherder to complete this action';
// Generic error message when we encounter 401 status codes from the
// server.
const AUTH_ERROR_MSG = 'Please login to Treeherder to complete this action';
// If we failed to authenticate for some reason return a nicer error message.
if (e.status === 401 || e.status === 403) {
return AUTH_ERROR_MSG;
}
// If there is nothing in the server message use the HTTP response status.
const errorMessage = `${(e.data && e.data.detail) || e.status} ${e.statusText}`;
return `${message}: ${errorMessage}`;
// If we failed to authenticate for some reason return a nicer error message.
if (e.status === 401 || e.status === 403) {
return AUTH_ERROR_MSG;
}
// If there is nothing in the server message use the HTTP response status.
const errorMessage = `${(e.data && e.data.detail) || e.status} ${
e.statusText
}`;
return `${message}: ${errorMessage}`;
};
/**
@ -37,7 +38,7 @@ export const formatTaskclusterError = function formatTaskclusterError(e) {
const errorMessage = err.message || err.toString();
if (errorMessage.indexOf('----') !== -1) {
return `${TC_ERROR_PREFIX}${errorMessage.split('----')[0]}`;
return `${TC_ERROR_PREFIX}${errorMessage.split('----')[0]}`;
}
return `${TC_ERROR_PREFIX}${errorMessage}`;

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

@ -20,7 +20,10 @@ export const thFieldChoices = {
machine_name: { name: 'machine name', matchType: thMatchType.substr },
platform: { name: 'platform', matchType: thMatchType.substr },
tier: { name: 'tier', matchType: thMatchType.exactstr },
failure_classification_id: { name: 'failure classification', matchType: thMatchType.choice },
failure_classification_id: {
name: 'failure classification',
matchType: thMatchType.choice,
},
// text search across multiple fields
searchStr: { name: 'search string', matchType: thMatchType.searchStr },
};

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

@ -1,9 +1,6 @@
import $ from 'jquery';
import {
thFailureResults,
thPlatformMap,
} from './constants';
import { thFailureResults, thPlatformMap } from './constants';
import { getGroupMapKey } from './aggregateId';
const btnClasses = {
@ -34,7 +31,10 @@ export const getStatus = function getStatus(job) {
// Get the CSS class for job buttons as well as jobs that show in the pinboard.
// These also apply to result "groupings" like ``failures`` and ``in progress``
// for the colored filter chicklets on the nav bar.
export const getBtnClass = function getBtnClass(resultStatus, failureClassificationId) {
export const getBtnClass = function getBtnClass(
resultStatus,
failureClassificationId,
) {
let btnClass = btnClasses[resultStatus] || 'btn-default';
// handle if a job is classified
@ -54,14 +54,17 @@ export const getJobBtnClass = function getJobBtnClass(job) {
};
export const isReftest = function isReftest(job) {
return [job.job_group_name, job.job_type_name]
.some(name => name.toLowerCase().includes('reftest'));
return [job.job_group_name, job.job_type_name].some(name =>
name.toLowerCase().includes('reftest'),
);
};
export const isPerfTest = function isPerfTest(job) {
return [job.job_group_name, job.job_type_name]
.some(name => name.toLowerCase().includes('talos') ||
name.toLowerCase().includes('raptor'));
return [job.job_group_name, job.job_type_name].some(
name =>
name.toLowerCase().includes('talos') ||
name.toLowerCase().includes('raptor'),
);
};
export const isClassified = function isClassified(job) {
@ -69,14 +72,15 @@ export const isClassified = function isClassified(job) {
};
export const isUnclassifiedFailure = function isUnclassifiedFailure(job) {
return (thFailureResults.includes(job.result) &&
!isClassified(job));
return thFailureResults.includes(job.result) && !isClassified(job);
};
// Fetch the React instance of an object from a DOM element.
// Credit for this approach goes to SO: https://stackoverflow.com/a/48335220/333614
export const findInstance = function findInstance(el) {
const key = Object.keys(el).find(key => key.startsWith('__reactInternalInstance$'));
const key = Object.keys(el).find(key =>
key.startsWith('__reactInternalInstance$'),
);
if (key) {
const fiberNode = el[key];
return fiberNode && fiberNode.return && fiberNode.return.stateNode;
@ -86,7 +90,9 @@ export const findInstance = function findInstance(el) {
// Fetch the React instance of the currently selected job.
export const findSelectedInstance = function findSelectedInstance() {
const selectedEl = $('.th-view-content').find('.job-btn.selected-job').first();
const selectedEl = $('.th-view-content')
.find('.job-btn.selected-job')
.first();
if (selectedEl.length) {
return findInstance(selectedEl[0]);
}
@ -95,16 +101,19 @@ export const findSelectedInstance = function findSelectedInstance() {
// Check if the element is visible on screen or not.
const isOnScreen = function isOnScreen(el) {
const viewport = {};
viewport.top = $(window).scrollTop() + $('#global-navbar-container').height() + 30;
viewport.top =
$(window).scrollTop() + $('#global-navbar-container').height() + 30;
const filterbarheight = $('.active-filters-bar').height();
viewport.top = filterbarheight > 0 ? viewport.top + filterbarheight : viewport.top;
viewport.top =
filterbarheight > 0 ? viewport.top + filterbarheight : viewport.top;
const updatebarheight = $('.update-alert-panel').height();
viewport.top = updatebarheight > 0 ? viewport.top + updatebarheight : viewport.top;
viewport.top =
updatebarheight > 0 ? viewport.top + updatebarheight : viewport.top;
viewport.bottom = $(window).height() - $('#details-panel').height() - 20;
const bounds = {};
bounds.top = el.offset().top;
bounds.bottom = bounds.top + el.outerHeight();
return ((bounds.top <= viewport.bottom) && (bounds.bottom >= viewport.top));
return bounds.top <= viewport.bottom && bounds.bottom >= viewport.top;
};
// Scroll the element into view.
@ -128,11 +137,16 @@ export const scrollToElement = function scrollToElement(el, duration) {
export const findGroupElement = function findGroupElement(job) {
const { push_id, job_group_symbol, tier, platform, platform_option } = job;
const groupMapKey = getGroupMapKey(push_id, job_group_symbol, tier, platform, platform_option);
const groupMapKey = getGroupMapKey(
push_id,
job_group_symbol,
tier,
platform,
platform_option,
);
const viewContent = $('.th-view-content');
return viewContent.find(
`span[data-group-key='${groupMapKey}']`).first();
return viewContent.find(`span[data-group-key='${groupMapKey}']`).first();
};
export const findGroupInstance = function findGroupInstance(job) {
@ -146,8 +160,9 @@ export const findGroupInstance = function findGroupInstance(job) {
// Fetch the React instance based on the jobId, and if scrollTo
// is true, then scroll it into view.
export const findJobInstance = function findJobInstance(jobId, scrollTo) {
const jobEl = $('.th-view-content').find(
`button[data-job-id='${jobId}']`).first();
const jobEl = $('.th-view-content')
.find(`button[data-job-id='${jobId}']`)
.first();
if (jobEl.length) {
if (scrollTo) {
@ -161,15 +176,18 @@ export const getSearchStr = function getSearchStr(job) {
// we want to join the group and type information together
// so we can search for it as one token (useful when
// we want to do a search on something like `fxup-esr(`)
const symbolInfo = (job.job_group_symbol === '?') ? '' : job.job_group_symbol;
const symbolInfo = job.job_group_symbol === '?' ? '' : job.job_group_symbol;
return [
thPlatformMap[job.platform] || job.platform,
job.platform_option,
(job.job_group_name === 'unknown') ? undefined : job.job_group_name,
job.job_group_name === 'unknown' ? undefined : job.job_group_name,
job.job_type_name,
`${symbolInfo}(${job.job_type_symbol})`,
].filter(item => typeof item !== 'undefined').join(' ').toLowerCase();
]
.filter(item => typeof item !== 'undefined')
.join(' ')
.toLowerCase();
};
export const getHoverText = function getHoverText(job) {

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

@ -21,7 +21,11 @@ export const setLocation = function setLocation(params, hashPrefix = '/jobs') {
window.location.hash = `#${hashPrefix}${createQueryParams(params)}`;
};
export const setUrlParam = function setUrlParam(field, value, hashPrefix = '/jobs') {
export const setUrlParam = function setUrlParam(
field,
value,
hashPrefix = '/jobs',
) {
const params = getAllUrlParams();
if (value) {

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

@ -2,7 +2,9 @@ export const thTitleSuffixLimit = 70;
export const parseAuthor = function parseAuthor(author) {
const userTokens = author.split(/[<>]+/);
const name = userTokens[0].trim().replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1));
const name = userTokens[0]
.trim()
.replace(/\w\S*/g, txt => txt.charAt(0).toUpperCase() + txt.substr(1));
const email = userTokens.length > 1 ? userTokens[1] : '';
return { name, email };
};

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

@ -29,12 +29,11 @@ const taskcluster = (() => {
return {
getAgent: tcAgent,
// When the access token is refreshed, simply update it on the credential agent
getQueue: () => (
getQueue: () =>
new Queue({
credentialAgent: tcAgent(),
rootUrl: tcRootUrl,
})
),
}),
updateAgent: () => {
const userSession = localStorage.getItem('userSession');

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

@ -15,7 +15,8 @@ export const getUserSessionUrl = function getUserSessionUrl(oidcProvider) {
};
export const createQueryParams = function createQueryParams(params) {
const query = params instanceof URLSearchParams ? params : new URLSearchParams(params);
const query =
params instanceof URLSearchParams ? params : new URLSearchParams(params);
return `?${query.toString()}`;
};
@ -45,7 +46,11 @@ export const getReftestUrl = function getReftestUrl(logUrl) {
// which is a "project" endpoint that requires the project name. We shouldn't
// need that since the ids are unique across projects.
// Bug 1441938 - The project_bound_router is not needed and cumbersome in some cases
export const getLogViewerUrl = function getLogViewerUrl(job_id, repoName, line_number) {
export const getLogViewerUrl = function getLogViewerUrl(
job_id,
repoName,
line_number,
) {
const rv = `logviewer.html#?job_id=${job_id}&repo=${repoName}`;
return line_number ? `${rv}&lineNumber=${line_number}` : rv;
};
@ -92,9 +97,10 @@ export const graphsEndpoint = 'failurecount/';
export const parseQueryParams = function parseQueryParams(search) {
const params = new URLSearchParams(search);
return [...params.entries()].reduce((acc, [key, value]) => (
{ ...acc, [key]: value }
), {});
return [...params.entries()].reduce(
(acc, [key, value]) => ({ ...acc, [key]: value }),
{},
);
};
// TODO: Combine this with getApiUrl().

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

@ -6,16 +6,16 @@ import MainView from './MainView';
import BugDetailsView from './BugDetailsView';
class App extends React.Component {
constructor(props) {
constructor(props) {
super(props);
this.updateAppState = this.updateAppState.bind(this);
// keep track of the mainviews graph and table data so the API won't be
// called again when navigating back from bugdetailsview.
this.state = {
graphData: null,
tableData: null,
};
graphData: null,
tableData: null,
};
}
updateAppState(state) {
@ -27,29 +27,34 @@ class App extends React.Component {
<HashRouter>
<main>
<Switch>
(<Route
<Route
exact
path="/main"
render={props =>
(<MainView
{...props}
mainGraphData={this.state.graphData}
mainTableData={this.state.tableData}
updateAppState={this.updateAppState}
/>)}
/>)
(<Route
path="/main?startday=:startday&endday=:endday&tree=:tree"
render={props =>
(<MainView
render={props => (
<MainView
{...props}
mainGraphData={this.state.graphData}
mainTableData={this.state.tableData}
updateAppState={this.updateAppState}
/>)}
/>)
/>
)}
/>
<Route
path="/main?startday=:startday&endday=:endday&tree=:tree"
render={props => (
<MainView
{...props}
mainGraphData={this.state.graphData}
mainTableData={this.state.tableData}
updateAppState={this.updateAppState}
/>
)}
/>
<Route path="/bugdetails" component={BugDetailsView} />
<Route path="/bugdetails?startday=:startday&endday=:endday&tree=:tree&bug=bug" component={BugDetailsView} />
<Route
path="/bugdetails?startday=:startday&endday=:endday&tree=:tree&bug=bug"
component={BugDetailsView}
/>
<Redirect from="/" to="/main" />
</Switch>
</main>

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

@ -8,18 +8,38 @@ import { getBugUrl } from '../helpers/url';
// in bugdetailsview to navigate back to mainview displays this console warning:
// "Hash history go(n) causes a full page reload in this browser"
function BugColumn({ tree, startday, endday, data, location, graphData, tableData, updateAppState }) {
function BugColumn({
tree,
startday,
endday,
data,
location,
graphData,
tableData,
updateAppState,
}) {
const { id, summary } = data;
return (
<div>
<a className="ml-1" target="_blank" rel="noopener noreferrer" href={getBugUrl(id)}>{id}</a>
<a
className="ml-1"
target="_blank"
rel="noopener noreferrer"
href={getBugUrl(id)}
>
{id}
</a>
&nbsp;
<span className="ml-1 small-text bug-details" onClick={() => updateAppState({ graphData, tableData })}>
<span
className="ml-1 small-text bug-details"
onClick={() => updateAppState({ graphData, tableData })}
>
<Link
to={{ pathname: '/bugdetails',
search: `?startday=${startday}&endday=${endday}&tree=${tree}&bug=${id}`,
state: { startday, endday, tree, id, summary, location },
}}
to={{
pathname: '/bugdetails',
search: `?startday=${startday}&endday=${endday}&tree=${tree}&bug=${id}`,
state: { startday, endday, tree, id, summary, location },
}}
>
details
</Link>
@ -38,14 +58,10 @@ BugColumn.propTypes = {
tree: PropTypes.string.isRequired,
location: PropTypes.shape({}),
graphData: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.shape({}),
),
PropTypes.arrayOf(PropTypes.shape({})),
PropTypes.shape({}),
]),
tableData: PropTypes.arrayOf(
PropTypes.shape({}),
),
tableData: PropTypes.arrayOf(PropTypes.shape({})),
updateAppState: PropTypes.func.isRequired,
};

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

@ -12,11 +12,23 @@ import Layout from './Layout';
import withView from './View';
import DateOptions from './DateOptions';
const BugDetailsView = (props) => {
const { graphData, tableData, initialParamsSet, startday, endday, updateState, bug,
summary, errorMessages, lastLocation, tableFailureStatus, graphFailureStatus } = props;
const BugDetailsView = props => {
const {
graphData,
tableData,
initialParamsSet,
startday,
endday,
updateState,
bug,
summary,
errorMessages,
lastLocation,
tableFailureStatus,
graphFailureStatus,
} = props;
const columns = [
const columns = [
{
Header: 'Push Time',
accessor: 'push_time',
@ -28,14 +40,19 @@ const BugDetailsView = (props) => {
{
Header: 'Revision',
accessor: 'revision',
Cell: _props =>
(<a
href={getJobsUrl({ repo: _props.original.tree, revision: _props.value, selectedJob: _props.original.job_id })}
Cell: _props => (
<a
href={getJobsUrl({
repo: _props.original.tree,
revision: _props.value,
selectedJob: _props.original.job_id,
})}
target="_blank"
rel="noopener noreferrer"
>
{_props.value}
</a>),
</a>
),
},
{
Header: 'Platform',
@ -79,47 +96,64 @@ const BugDetailsView = (props) => {
header={
<React.Fragment>
<Row>
<Col xs="12"><span className="pull-left"><Link to={(lastLocation || '/')}><Icon name="arrow-left" className="pr-1" />
back</Link></span>
<Col xs="12">
<span className="pull-left">
<Link to={lastLocation || '/'}>
<Icon name="arrow-left" className="pr-1" />
back
</Link>
</span>
</Col>
</Row>
{!errorMessages.length && !tableFailureStatus && !graphFailureStatus &&
<React.Fragment>
<Row>
<Col xs="12" className="mx-auto"><h1>Details for Bug {!bug ? '' : bug}</h1></Col>
</Row>
<Row>
<Col xs="12" className="mx-auto"><p className="subheader">{`${prettyDate(startday)} to ${prettyDate(endday)} UTC`}</p>
</Col>
</Row>
{summary &&
<Row>
<Col xs="4" className="mx-auto"><p className="text-secondary text-center">{summary}</p></Col>
</Row>}
{tableData.length > 0 &&
<Row>
<Col xs="12" className="mx-auto"><p className="text-secondary">{tableData.length} total failures</p></Col>
</Row>}
</React.Fragment>}
{!errorMessages.length && !tableFailureStatus && !graphFailureStatus && (
<React.Fragment>
<Row>
<Col xs="12" className="mx-auto">
<h1>Details for Bug {!bug ? '' : bug}</h1>
</Col>
</Row>
<Row>
<Col xs="12" className="mx-auto">
<p className="subheader">{`${prettyDate(
startday,
)} to ${prettyDate(endday)} UTC`}</p>
</Col>
</Row>
{summary && (
<Row>
<Col xs="4" className="mx-auto">
<p className="text-secondary text-center">{summary}</p>
</Col>
</Row>
)}
{tableData.length > 0 && (
<Row>
<Col xs="12" className="mx-auto">
<p className="text-secondary">
{tableData.length} total failures
</p>
</Col>
</Row>
)}
</React.Fragment>
)}
</React.Fragment>
}
table={
bug && initialParamsSet &&
<ReactTable
data={tableData}
showPageSizeOptions
columns={columns}
className="-striped"
getTrProps={tableRowStyling}
showPaginationTop
defaultPageSize={50}
/>
}
datePicker={
<DateOptions
updateState={updateState}
/>
bug &&
initialParamsSet && (
<ReactTable
data={tableData}
showPageSizeOptions
columns={columns}
className="-striped"
getTrProps={tableRowStyling}
showPaginationTop
defaultPageSize={50}
/>
)
}
datePicker={<DateOptions updateState={updateState} />}
/>
);
};

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

@ -4,7 +4,6 @@ import { Tooltip } from 'reactstrap';
import { getLogViewerUrl } from '../helpers/url';
export default class BugLogColumn extends React.Component {
constructor(props) {
super(props);
@ -36,7 +35,9 @@ export default class BugLogColumn extends React.Component {
return (
<div>
<span ref={this.updateTarget}>
{`${original.lines.length} unexpected-fail${original.lines.length > 1 ? 's' : ''}`}
{`${original.lines.length} unexpected-fail${
original.lines.length > 1 ? 's' : ''
}`}
<br />
<a
className="small-text"
@ -48,20 +49,23 @@ export default class BugLogColumn extends React.Component {
</a>
</span>
{target && original.lines.length > 0 &&
<Tooltip
placement="left"
isOpen={tooltipOpen}
target={target}
toggle={this.toggle}
className="tooltip"
>
<ul>
{original.lines.map(line => (
<li key={line} className="failure_li text-truncate">{line}</li>
))}
</ul>
</Tooltip>}
{target && original.lines.length > 0 && (
<Tooltip
placement="left"
isOpen={tooltipOpen}
target={target}
toggle={this.toggle}
className="tooltip"
>
<ul>
{original.lines.map(line => (
<li key={line} className="failure_li text-truncate">
{line}
</li>
))}
</ul>
</Tooltip>
)}
</div>
);
}

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

@ -38,7 +38,11 @@ export default class DateOptions extends React.Component {
// bug history is max 4 months
from = 120;
}
const startday = ISODate(moment().utc().subtract(from, 'days'));
const startday = ISODate(
moment()
.utc()
.subtract(from, 'days'),
);
const endday = ISODate(moment().utc());
this.props.updateState({ startday, endday });
}
@ -46,23 +50,29 @@ export default class DateOptions extends React.Component {
render() {
const { updateState } = this.props;
const { dropdownOpen, dateRange } = this.state;
const dateOptions = ['last 7 days', 'last 30 days', 'custom range', 'entire history'];
const dateOptions = [
'last 7 days',
'last 30 days',
'custom range',
'entire history',
];
return (
<div className="d-inline-block">
<ButtonDropdown className="mr-3" isOpen={dropdownOpen} toggle={this.toggle}>
<DropdownToggle caret>
date range
</DropdownToggle>
<ButtonDropdown
className="mr-3"
isOpen={dropdownOpen}
toggle={this.toggle}
>
<DropdownToggle caret>date range</DropdownToggle>
<DropdownMenuItems
options={dateOptions}
updateData={this.updateDateRange}
/>
</ButtonDropdown>
{dateRange === 'custom range' &&
<DateRangePicker
updateState={updateState}
/>}
{dateRange === 'custom range' && (
<DateRangePicker updateState={updateState} />
)}
</div>
);
}

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

@ -80,7 +80,7 @@ export default class DateRangePicker extends React.Component {
<span className="ml-1 mr-1">-</span>
<span className="InputFromTo-to">
<DayPickerInput
ref={(element) => {
ref={element => {
this.to = element;
}}
value={to}
@ -98,7 +98,9 @@ export default class DateRangePicker extends React.Component {
onDayChange={this.toChange}
/>
</span>
<Button color="secondary" className="ml-2" onClick={this.updateData}>update</Button>
<Button color="secondary" className="ml-2" onClick={this.updateData}>
update
</Button>
</div>
);
}

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

@ -3,17 +3,20 @@ import Icon from 'react-fontawesome';
import PropTypes from 'prop-types';
import { DropdownMenu, DropdownItem } from 'reactstrap';
const DropdownMenuItems = ({ selectedItem, updateData, options }) =>
(
const DropdownMenuItems = ({ selectedItem, updateData, options }) => (
<DropdownMenu>
{options.map(item =>
(<DropdownItem key={item} onClick={event => updateData(event.target.innerText)}>
{options.map(item => (
<DropdownItem
key={item}
onClick={event => updateData(event.target.innerText)}
>
<Icon
name="check"
className={`pr-1 ${selectedItem === item ? '' : 'hide'}`}
/>
{item}
</DropdownItem>))}
</DropdownItem>
))}
</DropdownMenu>
);

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

@ -5,13 +5,17 @@ import { Alert } from 'reactstrap';
import { processErrorMessage } from './helpers';
const ErrorMessages = ({ failureMessage, failureStatus, errorMessages }) => {
const messages = errorMessages.length ? errorMessages : processErrorMessage(failureMessage, failureStatus);
const messages = errorMessages.length
? errorMessages
: processErrorMessage(failureMessage, failureStatus);
return (
<div>
{messages.map(message =>
<Alert color="danger" key={message}>{message}</Alert>,
)}
{messages.map(message => (
<Alert color="danger" key={message}>
{message}
</Alert>
))}
</div>
);
};
@ -19,9 +23,7 @@ const ErrorMessages = ({ failureMessage, failureStatus, errorMessages }) => {
ErrorMessages.propTypes = {
failureMessage: PropTypes.oneOfType([
PropTypes.object,
PropTypes.arrayOf(
PropTypes.string,
),
PropTypes.arrayOf(PropTypes.string),
]),
failureStatus: PropTypes.number,
errorMessages: PropTypes.array,
@ -30,9 +32,7 @@ ErrorMessages.propTypes = {
ErrorMessages.defaultProps = {
failureMessage: null,
failureStatus: null,
errorMessages: PropTypes.arrayOf(
PropTypes.string,
),
errorMessages: PropTypes.arrayOf(PropTypes.string),
};
export default ErrorMessages;

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

@ -17,7 +17,6 @@ import PropTypes from 'prop-types';
// };
export default class Graph extends React.Component {
componentDidUpdate() {
const { specs, data } = this.props;
if (specs.data !== data) {
@ -48,7 +47,8 @@ export default class Graph extends React.Component {
Graph.propTypes = {
specs: PropTypes.shape({
legend: PropTypes.oneOfType([
PropTypes.string, PropTypes.arrayOf(PropTypes.string),
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
}).isRequired,
data: PropTypes.oneOfType([
@ -65,7 +65,8 @@ Graph.propTypes = {
date: PropTypes.shape({ Date: PropTypes.string }),
value: PropTypes.number,
}),
), PropTypes.arrayOf(
),
PropTypes.arrayOf(
PropTypes.shape({
date: PropTypes.shape({ Date: PropTypes.string }),
value: PropTypes.number,

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

@ -25,26 +25,25 @@ export default class GraphsContainer extends React.Component {
return (
<React.Fragment>
<Row className="pt-5">
<Graph
specs={graphOneSpecs}
data={graphOneData}
/>
<Graph specs={graphOneSpecs} data={graphOneData} />
</Row>
<Row>
<Col xs="12" className="mx-auto pb-5">
<Button color="secondary" onClick={this.toggleGraph} className="d-inline-block mr-3">
<Button
color="secondary"
onClick={this.toggleGraph}
className="d-inline-block mr-3"
>
{`${showGraphTwo ? 'less' : 'more'} graphs`}
</Button>
{children}
</Col>
</Row>
{showGraphTwo &&
<Row className="pt-5">
<Graph
specs={graphTwoSpecs}
data={graphTwoData}
/>
</Row>}
{showGraphTwo && (
<Row className="pt-5">
<Graph specs={graphTwoSpecs} data={graphTwoData} />
</Row>
)}
</React.Fragment>
);
}
@ -63,7 +62,8 @@ GraphsContainer.propTypes = {
date: PropTypes.shape({ Date: PropTypes.string }),
value: PropTypes.number,
}),
), PropTypes.arrayOf(
),
PropTypes.arrayOf(
PropTypes.shape({
date: PropTypes.shape({ Date: PropTypes.string }),
value: PropTypes.number,

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

@ -10,47 +10,68 @@ import GraphsContainer from './GraphsContainer';
import ErrorMessages from './ErrorMessages';
import { prettyErrorMessages, errorMessageClass } from './constants';
const Layout = (props) => {
const Layout = props => {
const {
graphData,
tableData,
errorMessages,
tree,
isFetchingTable,
isFetchingGraphs,
tableFailureStatus,
graphFailureStatus,
updateState,
graphOneData,
graphTwoData,
table,
datePicker,
header,
} = props;
const { graphData, tableData, errorMessages, tree, isFetchingTable,
isFetchingGraphs, tableFailureStatus, graphFailureStatus, updateState,
graphOneData, graphTwoData, table, datePicker, header } = props;
let failureMessage = null;
if (tableFailureStatus) {
failureMessage = tableData;
} else if (graphFailureStatus) {
failureMessage = graphData;
}
let failureMessage = null;
if (tableFailureStatus) {
failureMessage = tableData;
} else if (graphFailureStatus) {
failureMessage = graphData;
}
return (
<Container fluid style={{ marginBottom: '5rem', marginTop: '5rem', maxWidth: '1200px' }}>
<Navigation
updateState={updateState}
tree={tree}
/>
<Container
fluid
style={{ marginBottom: '5rem', marginTop: '5rem', maxWidth: '1200px' }}
>
<Navigation updateState={updateState} tree={tree} />
{(isFetchingGraphs || isFetchingTable) &&
!(tableFailureStatus || graphFailureStatus || errorMessages.length > 0) &&
<div className="loading">
<Icon spin name="cog" size="4x" />
</div>}
{(tableFailureStatus || graphFailureStatus || errorMessages.length > 0) &&
!(
tableFailureStatus ||
graphFailureStatus ||
errorMessages.length > 0
) && (
<div className="loading">
<Icon spin name="cog" size="4x" />
</div>
)}
{(tableFailureStatus ||
graphFailureStatus ||
errorMessages.length > 0) && (
<ErrorMessages
failureMessage={failureMessage}
failureStatus={tableFailureStatus || graphFailureStatus}
errorMessages={errorMessages}
/>}
/>
)}
{header}
<ErrorBoundary
errorClasses={errorMessageClass}
message={prettyErrorMessages.default}
>
{graphOneData && graphTwoData &&
<GraphsContainer
graphOneData={graphOneData}
graphTwoData={graphTwoData}
>
{datePicker}
</GraphsContainer>}
{graphOneData && graphTwoData && (
<GraphsContainer
graphOneData={graphOneData}
graphTwoData={graphTwoData}
>
{datePicker}
</GraphsContainer>
)}
</ErrorBoundary>
<ErrorBoundary
@ -59,7 +80,8 @@ const Layout = (props) => {
>
{table}
</ErrorBoundary>
</Container>);
</Container>
);
};
Container.propTypes = {
@ -71,32 +93,17 @@ Layout.propTypes = {
location: PropTypes.shape({
search: PropTypes.string,
}).isRequired,
datePicker: PropTypes.oneOfType([
PropTypes.shape({}), PropTypes.bool,
]),
header: PropTypes.oneOfType([
PropTypes.shape({}), PropTypes.bool,
]),
table: PropTypes.oneOfType([
PropTypes.shape({}), PropTypes.bool,
]),
graphOneData: PropTypes.arrayOf(
PropTypes.shape({}),
),
datePicker: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.bool]),
header: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.bool]),
table: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.bool]),
graphOneData: PropTypes.arrayOf(PropTypes.shape({})),
graphTwoData: PropTypes.arrayOf(
PropTypes.arrayOf(
PropTypes.shape({}),
), PropTypes.arrayOf(
PropTypes.shape({}),
),
),
tableData: PropTypes.arrayOf(
PropTypes.shape({}),
PropTypes.arrayOf(PropTypes.shape({})),
PropTypes.arrayOf(PropTypes.shape({})),
),
tableData: PropTypes.arrayOf(PropTypes.shape({})),
graphData: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.shape({}),
),
PropTypes.arrayOf(PropTypes.shape({})),
PropTypes.shape({}),
]),
tree: PropTypes.string,

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

@ -7,14 +7,28 @@ import ReactTable from 'react-table';
import { bugsEndpoint } from '../helpers/url';
import BugColumn from './BugColumn';
import { calculateMetrics, prettyDate, ISODate, tableRowStyling } from './helpers';
import {
calculateMetrics,
prettyDate,
ISODate,
tableRowStyling,
} from './helpers';
import withView from './View';
import Layout from './Layout';
import DateRangePicker from './DateRangePicker';
const MainView = (props) => {
const { graphData, tableData, initialParamsSet, startday, endday, updateState,
tree, location, updateAppState } = props;
const MainView = props => {
const {
graphData,
tableData,
initialParamsSet,
startday,
endday,
updateState,
tree,
location,
updateAppState,
} = props;
const textFilter = (filter, row) => {
const text = row[filter.id];
@ -31,17 +45,18 @@ const MainView = (props) => {
headerClassName: 'bug-column-header',
className: 'bug-column',
maxWidth: 150,
Cell: _props =>
(<BugColumn
data={_props.original}
tree={tree}
startday={startday}
endday={endday}
location={location}
graphData={graphData}
tableData={tableData}
updateAppState={updateAppState}
/>),
Cell: _props => (
<BugColumn
data={_props.original}
tree={tree}
startday={startday}
endday={endday}
location={location}
graphData={graphData}
tableData={tableData}
updateAppState={updateAppState}
/>
),
},
{
Header: 'Count',
@ -69,7 +84,12 @@ const MainView = (props) => {
let totalRuns = 0;
if (graphData.length) {
({ graphOneData, graphTwoData, totalFailures, totalRuns } = calculateMetrics(graphData));
({
graphOneData,
graphTwoData,
totalFailures,
totalRuns,
} = calculateMetrics(graphData));
}
return (
@ -78,39 +98,45 @@ const MainView = (props) => {
graphOneData={graphOneData}
graphTwoData={graphTwoData}
header={
initialParamsSet &&
<React.Fragment>
<Row>
<Col xs="12" className="mx-auto pt-3"><h1>Intermittent Test Failures</h1></Col>
</Row>
<Row>
<Col xs="12" className="mx-auto"><p className="subheader">{`${prettyDate(startday)} to ${prettyDate(endday)} UTC`}</p>
</Col>
</Row>
<Row>
<Col xs="12" className="mx-auto"><p className="text-secondary">{totalFailures} bugs in {totalRuns} pushes</p>
</Col>
</Row>
</React.Fragment>
initialParamsSet && (
<React.Fragment>
<Row>
<Col xs="12" className="mx-auto pt-3">
<h1>Intermittent Test Failures</h1>
</Col>
</Row>
<Row>
<Col xs="12" className="mx-auto">
<p className="subheader">{`${prettyDate(
startday,
)} to ${prettyDate(endday)} UTC`}</p>
</Col>
</Row>
<Row>
<Col xs="12" className="mx-auto">
<p className="text-secondary">
{totalFailures} bugs in {totalRuns} pushes
</p>
</Col>
</Row>
</React.Fragment>
)
}
table={
initialParamsSet &&
<ReactTable
data={tableData}
showPageSizeOptions
columns={columns}
className="-striped"
getTrProps={tableRowStyling}
showPaginationTop
defaultPageSize={50}
filterable
/>
}
datePicker={
<DateRangePicker
updateState={updateState}
/>
initialParamsSet && (
<ReactTable
data={tableData}
showPageSizeOptions
columns={columns}
className="-striped"
getTrProps={tableRowStyling}
showPaginationTop
defaultPageSize={50}
filterable
/>
)
}
datePicker={<DateRangePicker updateState={updateState} />}
/>
);
};
@ -121,7 +147,11 @@ MainView.propTypes = {
const defaultState = {
tree: 'trunk',
startday: ISODate(moment().utc().subtract(7, 'days')),
startday: ISODate(
moment()
.utc()
.subtract(7, 'days'),
),
endday: ISODate(moment().utc()),
route: '/main',
endpoint: bugsEndpoint,

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

@ -1,6 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Collapse, Navbar, Nav, UncontrolledDropdown, DropdownToggle } from 'reactstrap';
import {
Collapse,
Navbar,
Nav,
UncontrolledDropdown,
DropdownToggle,
} from 'reactstrap';
import DropdownMenuItems from './DropdownMenuItems';
import { treeOptions } from './constants';

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

@ -1,136 +1,71 @@
import React from 'react';
import PropTypes from 'prop-types';
import { graphsEndpoint, parseQueryParams, createQueryParams, createApiUrl,
bugzillaBugsApi } from '../helpers/url';
import {
graphsEndpoint,
parseQueryParams,
createQueryParams,
createApiUrl,
bugzillaBugsApi,
} from '../helpers/url';
import { getData } from '../helpers/http';
import { updateQueryParams, validateQueryParams, mergeData, formatBugs } from './helpers';
import {
updateQueryParams,
validateQueryParams,
mergeData,
formatBugs,
} from './helpers';
const withView = defaultState => WrappedComponent =>
class View extends React.Component {
constructor(props) {
super(props);
super(props);
this.updateData = this.updateData.bind(this);
this.setQueryParams = this.setQueryParams.bind(this);
this.checkQueryValidation = this.checkQueryValidation.bind(this);
this.getTableData = this.getTableData.bind(this);
this.getGraphData = this.getGraphData.bind(this);
this.updateState = this.updateState.bind(this);
this.getBugDetails = this.getBugDetails.bind(this);
this.updateData = this.updateData.bind(this);
this.setQueryParams = this.setQueryParams.bind(this);
this.checkQueryValidation = this.checkQueryValidation.bind(this);
this.getTableData = this.getTableData.bind(this);
this.getGraphData = this.getGraphData.bind(this);
this.updateState = this.updateState.bind(this);
this.getBugDetails = this.getBugDetails.bind(this);
this.default = (this.props.location.state || defaultState);
this.state = {
errorMessages: [],
initialParamsSet: false,
tree: (this.default.tree || null),
startday: (this.default.startday || null),
endday: (this.default.endday || null),
bug: (this.default.id || null),
summary: (this.default.summary || null),
tableData: [],
tableFailureStatus: null,
isFetchingTable: false,
graphData: [],
graphFailureStatus: null,
isFetchingGraphs: false,
lastLocation: (this.default.location || null),
};
}
componentDidMount() {
this.setQueryParams();
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// update all data if the user edits dates, tree or bug via the query params
if (prevProps.location.search !== location.search) {
this.checkQueryValidation(parseQueryParams(location.search), this.state.initialParamsSet);
}
}
setQueryParams() {
const { location, history } = this.props;
const { startday, endday, tree, bug } = this.state;
const params = { startday, endday, tree };
if (bug) {
params.bug = bug;
this.default = this.props.location.state || defaultState;
this.state = {
errorMessages: [],
initialParamsSet: false,
tree: this.default.tree || null,
startday: this.default.startday || null,
endday: this.default.endday || null,
bug: this.default.id || null,
summary: this.default.summary || null,
tableData: [],
tableFailureStatus: null,
isFetchingTable: false,
graphData: [],
graphFailureStatus: null,
isFetchingGraphs: false,
lastLocation: this.default.location || null,
};
}
if (location.search !== '' && !location.state) {
// update data based on the params or show error if params are missing
this.checkQueryValidation(parseQueryParams(location.search));
} else {
// if the query params are not specified for mainview, set params based on default state
if (location.search === '') {
const queryString = createQueryParams(params);
updateQueryParams(defaultState.route, queryString, history, location);
componentDidMount() {
this.setQueryParams();
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// update all data if the user edits dates, tree or bug via the query params
if (prevProps.location.search !== location.search) {
this.checkQueryValidation(
parseQueryParams(location.search),
this.state.initialParamsSet,
);
}
this.setState({ initialParamsSet: true });
this.getGraphData(createApiUrl(graphsEndpoint, params));
this.getTableData(createApiUrl(defaultState.endpoint, params));
}
}
async getBugDetails(url) {
const { data, failureStatus } = await getData(url);
if (!failureStatus && data.bugs.length === 1) {
this.setState({ summary: data.bugs[0].summary });
}
}
async getTableData(url) {
this.setState({ tableFailureStatus: null, isFetchingTable: true });
const { data, failureStatus } = await getData(url);
let mergedData = null;
if (defaultState.route === '/main' && !failureStatus && data.length) {
const bugIds = formatBugs(data);
const bugzillaData = await this.batchBugRequests(bugIds);
mergedData = mergeData(data, bugzillaData);
}
this.setState({ tableData: mergedData || data, tableFailureStatus: failureStatus, isFetchingTable: false });
}
async getGraphData(url) {
this.setState({ graphFailureStatus: null, isFetchingGraphs: true });
const { data, failureStatus } = await getData(url);
this.setState({ graphData: data, graphFailureStatus: failureStatus, isFetchingGraphs: false });
}
async batchBugRequests(bugIds) {
const urlParams = {
include_fields: 'id,status,summary,whiteboard',
};
// TODO: bump up the max to ~1200 when this bug is fixed:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1497721
let min = 0;
let max = 800;
let bugsList = [];
const results = [];
while (bugIds.length >= min) {
const batch = bugIds.slice(min, max + 1);
urlParams.id = batch.join();
results.push(getData(bugzillaBugsApi('bug', urlParams)));
min = max;
max += 800;
}
for (const result of await Promise.all(results)) {
bugsList = [...bugsList, ...result.data.bugs];
}
return bugsList;
}
updateState(updatedObj) {
this.setState(updatedObj, () => {
setQueryParams() {
const { location, history } = this.props;
const { startday, endday, tree, bug } = this.state;
const params = { startday, endday, tree };
@ -138,62 +73,156 @@ const withView = defaultState => WrappedComponent =>
params.bug = bug;
}
this.getGraphData(createApiUrl(graphsEndpoint, params));
this.getTableData(createApiUrl(defaultState.endpoint, params));
if (location.search !== '' && !location.state) {
// update data based on the params or show error if params are missing
this.checkQueryValidation(parseQueryParams(location.search));
} else {
// if the query params are not specified for mainview, set params based on default state
if (location.search === '') {
const queryString = createQueryParams(params);
updateQueryParams(defaultState.route, queryString, history, location);
}
// update query params if dates or tree are updated
const queryString = createQueryParams(params);
updateQueryParams(defaultState.route, queryString, this.props.history, this.props.location);
});
}
updateData(params, urlChanged = false) {
const { mainGraphData, mainTableData } = this.props;
if (mainGraphData && mainTableData && !urlChanged) {
this.setState({ graphData: mainGraphData, tableData: mainTableData });
} else {
this.getGraphData(createApiUrl(graphsEndpoint, params));
this.getTableData(createApiUrl(defaultState.endpoint, params));
this.setState({ initialParamsSet: true });
this.getGraphData(createApiUrl(graphsEndpoint, params));
this.getTableData(createApiUrl(defaultState.endpoint, params));
}
}
if (params.bug && this.state.tableData.length) {
this.getBugDetails(bugzillaBugsApi('bug', { include_fields: 'summary', id: params.bug }));
async getBugDetails(url) {
const { data, failureStatus } = await getData(url);
if (!failureStatus && data.bugs.length === 1) {
this.setState({ summary: data.bugs[0].summary });
}
}
}
checkQueryValidation(params, urlChanged = false) {
const { errorMessages, initialParamsSet, summary } = this.state;
const messages = validateQueryParams(params, defaultState.route === '/bugdetails');
const updates = {};
async getTableData(url) {
this.setState({ tableFailureStatus: null, isFetchingTable: true });
const { data, failureStatus } = await getData(url);
let mergedData = null;
if (messages.length > 0) {
this.setState({ errorMessages: messages });
} else {
if (errorMessages.length) {
updates.errorMessages = [];
}
if (!initialParamsSet) {
updates.initialParamsSet = true;
}
if (summary) {
// reset summary
updates.summary = null;
if (defaultState.route === '/main' && !failureStatus && data.length) {
const bugIds = formatBugs(data);
const bugzillaData = await this.batchBugRequests(bugIds);
mergedData = mergeData(data, bugzillaData);
}
this.setState({ ...updates, ...params });
this.updateData(params, urlChanged);
this.setState({
tableData: mergedData || data,
tableFailureStatus: failureStatus,
isFetchingTable: false,
});
}
}
render() {
const updateState = { updateState: this.updateState };
const newProps = { ...this.props, ...this.state, ...updateState };
return (
<WrappedComponent {...newProps} />
);
}
};
async getGraphData(url) {
this.setState({ graphFailureStatus: null, isFetchingGraphs: true });
const { data, failureStatus } = await getData(url);
this.setState({
graphData: data,
graphFailureStatus: failureStatus,
isFetchingGraphs: false,
});
}
async batchBugRequests(bugIds) {
const urlParams = {
include_fields: 'id,status,summary,whiteboard',
};
// TODO: bump up the max to ~1200 when this bug is fixed:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1497721
let min = 0;
let max = 800;
let bugsList = [];
const results = [];
while (bugIds.length >= min) {
const batch = bugIds.slice(min, max + 1);
urlParams.id = batch.join();
results.push(getData(bugzillaBugsApi('bug', urlParams)));
min = max;
max += 800;
}
for (const result of await Promise.all(results)) {
bugsList = [...bugsList, ...result.data.bugs];
}
return bugsList;
}
updateState(updatedObj) {
this.setState(updatedObj, () => {
const { startday, endday, tree, bug } = this.state;
const params = { startday, endday, tree };
if (bug) {
params.bug = bug;
}
this.getGraphData(createApiUrl(graphsEndpoint, params));
this.getTableData(createApiUrl(defaultState.endpoint, params));
// update query params if dates or tree are updated
const queryString = createQueryParams(params);
updateQueryParams(
defaultState.route,
queryString,
this.props.history,
this.props.location,
);
});
}
updateData(params, urlChanged = false) {
const { mainGraphData, mainTableData } = this.props;
if (mainGraphData && mainTableData && !urlChanged) {
this.setState({ graphData: mainGraphData, tableData: mainTableData });
} else {
this.getGraphData(createApiUrl(graphsEndpoint, params));
this.getTableData(createApiUrl(defaultState.endpoint, params));
}
if (params.bug && this.state.tableData.length) {
this.getBugDetails(
bugzillaBugsApi('bug', { include_fields: 'summary', id: params.bug }),
);
}
}
checkQueryValidation(params, urlChanged = false) {
const { errorMessages, initialParamsSet, summary } = this.state;
const messages = validateQueryParams(
params,
defaultState.route === '/bugdetails',
);
const updates = {};
if (messages.length > 0) {
this.setState({ errorMessages: messages });
} else {
if (errorMessages.length) {
updates.errorMessages = [];
}
if (!initialParamsSet) {
updates.initialParamsSet = true;
}
if (summary) {
// reset summary
updates.summary = null;
}
this.setState({ ...updates, ...params });
this.updateData(params, urlChanged);
}
}
render() {
const updateState = { updateState: this.updateState };
const newProps = { ...this.props, ...this.state, ...updateState };
return <WrappedComponent {...newProps} />;
}
};
withView.propTypes = {
history: PropTypes.shape({}).isRequired,

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

@ -51,9 +51,11 @@ export const prettyErrorMessages = {
startday: 'startday is required and must be in YYYY-MM-DD format.',
endday: 'endday is required and must be in YYYY-MM-DD format.',
bug_ui: 'bug is required and must be a valid integer.',
tree_ui: 'tree is required and must be a valid repository or repository group.',
tree_ui:
'tree is required and must be a valid repository or repository group.',
default: 'Something went wrong.',
status503: 'There was a problem retrieving the data. Please try again in a minute.',
status503:
'There was a problem retrieving the data. Please try again in a minute.',
};
export const errorMessageClass = 'text-danger py-4 d-block';

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

@ -47,7 +47,7 @@ export const calculateMetrics = function calculateMetricsForGraphs(data) {
for (let i = 0; i < data.length; i++) {
const failures = data[i].failure_count;
const testRuns = data[i].test_runs;
const freq = (testRuns < 1 || failures < 1) ? 0 : failures / testRuns;
const freq = testRuns < 1 || failures < 1 ? 0 : failures / testRuns;
// metrics graphics only accepts JS Date objects
const date = moment(data[i].date).toDate();
@ -57,19 +57,29 @@ export const calculateMetrics = function calculateMetricsForGraphs(data) {
dateTestRunCounts.push({ date, value: testRuns });
dateFreqs.push({ date, value: freq });
}
return { graphOneData: dateFreqs, graphTwoData: [dateCounts, dateTestRunCounts], totalFailures, totalRuns };
return {
graphOneData: dateFreqs,
graphTwoData: [dateCounts, dateTestRunCounts],
totalFailures,
totalRuns,
};
};
export const updateQueryParams = function updateHistoryWithQueryParams(view, queryParams, history, location) {
history.replace({ pathname: view, search: queryParams });
// we do this so the api's won't be called twice (location/history updates will trigger a lifecycle hook)
location.search = queryParams;
export const updateQueryParams = function updateHistoryWithQueryParams(
view,
queryParams,
history,
location,
) {
history.replace({ pathname: view, search: queryParams });
// we do this so the api's won't be called twice (location/history updates will trigger a lifecycle hook)
location.search = queryParams;
};
export const sortData = function sortData(data, sortBy, desc) {
data.sort((a, b) => {
const item1 = (desc ? b[sortBy] : a[sortBy]);
const item2 = (desc ? a[sortBy] : b[sortBy]);
const item1 = desc ? b[sortBy] : a[sortBy];
const item2 = desc ? a[sortBy] : b[sortBy];
if (item1 < item2) {
return -1;
@ -82,7 +92,10 @@ export const sortData = function sortData(data, sortBy, desc) {
return data;
};
export const processErrorMessage = function processErrorMessage(errorMessage, status) {
export const processErrorMessage = function processErrorMessage(
errorMessage,
status,
) {
const messages = [];
if (status === 503) {
@ -101,7 +114,10 @@ export const processErrorMessage = function processErrorMessage(errorMessage, st
return messages || [prettyErrorMessages.default];
};
export const validateQueryParams = function validateQueryParams(params, bugRequired = false) {
export const validateQueryParams = function validateQueryParams(
params,
bugRequired = false,
) {
const messages = [];
const dateFormat = /\d{4}[-]\d{2}[-]\d{2}/;

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

@ -27,7 +27,11 @@ const DEFAULT_DETAILS_PCT = 40;
const REVISION_POLL_INTERVAL = 1000 * 60 * 5;
const REVISION_POLL_DELAYED_INTERVAL = 1000 * 60 * 60;
const HIDDEN_URL_PARAMS = [
'repo', 'classifiedState', 'resultStatus', 'selectedJob', 'searchStr',
'repo',
'classifiedState',
'resultStatus',
'selectedJob',
'searchStr',
];
const getWindowHeight = function getWindowHeight() {
@ -83,13 +87,14 @@ class App extends React.Component {
this.handleUrlChanges = this.handleUrlChanges.bind(this);
this.showOnScreenShortcuts = this.showOnScreenShortcuts.bind(this);
RepositoryModel.getList().then((repos) => {
const currentRepo = repos.find(repo => repo.name === repoName) || this.state.currentRepo;
RepositoryModel.getList().then(repos => {
const currentRepo =
repos.find(repo => repo.name === repoName) || this.state.currentRepo;
this.setState({ currentRepo, repos });
});
ClassificationTypeModel.getList().then((classificationTypes) => {
ClassificationTypeModel.getList().then(classificationTypes => {
this.setState({
classificationTypes,
classificationMap: ClassificationTypeModel.getMap(classificationTypes),
@ -100,29 +105,38 @@ class App extends React.Component {
window.addEventListener('hashchange', this.handleUrlChanges, false);
// Get the current Treeherder revision and poll to notify on updates.
this.fetchDeployedRevision().then((revision) => {
this.fetchDeployedRevision().then(revision => {
this.setState({ serverRev: revision });
this.updateInterval = setInterval(() => {
this.fetchDeployedRevision()
.then((revision) => {
const { serverChangedTimestamp, serverRev, serverChanged } = this.state;
this.fetchDeployedRevision().then(revision => {
const {
serverChangedTimestamp,
serverRev,
serverChanged,
} = this.state;
if (serverChanged) {
if (Date.now() - serverChangedTimestamp > REVISION_POLL_DELAYED_INTERVAL) {
this.setState({ serverChangedDelayed: true });
// Now that we know there's an update, stop polling.
clearInterval(this.updateInterval);
}
if (serverChanged) {
if (
Date.now() - serverChangedTimestamp >
REVISION_POLL_DELAYED_INTERVAL
) {
this.setState({ serverChangedDelayed: true });
// Now that we know there's an update, stop polling.
clearInterval(this.updateInterval);
}
// This request returns the treeherder git revision running on the server
// If this differs from the version chosen during the UI page load, show a warning
if (serverRev && serverRev !== revision) {
this.setState({ serverRev: revision });
if (serverChanged === false) {
this.setState({ serverChangedTimestamp: Date.now(), serverChanged: true });
}
}
// This request returns the treeherder git revision running on the server
// If this differs from the version chosen during the UI page load, show a warning
if (serverRev && serverRev !== revision) {
this.setState({ serverRev: revision });
if (serverChanged === false) {
this.setState({
serverChangedTimestamp: Date.now(),
serverChanged: true,
});
}
});
}
});
}, REVISION_POLL_INTERVAL);
});
}
@ -136,8 +150,10 @@ class App extends React.Component {
const defaultPushListPct = hasSelectedJob ? 100 - DEFAULT_DETAILS_PCT : 100;
// calculate the height of the details panel to use if it has not been
// resized by the user.
const defaultDetailsHeight = defaultPushListPct < 100 ?
DEFAULT_DETAILS_PCT / 100 * getWindowHeight() : 0;
const defaultDetailsHeight =
defaultPushListPct < 100
? (DEFAULT_DETAILS_PCT / 100) * getWindowHeight()
: 0;
return {
defaultPushListPct,
@ -196,16 +212,29 @@ class App extends React.Component {
handleSplitChange(latestSplitSize) {
this.setState({
latestSplitPct: latestSplitSize / getWindowHeight() * 100,
latestSplitPct: (latestSplitSize / getWindowHeight()) * 100,
});
}
render() {
const {
user, isFieldFilterVisible, serverChangedDelayed,
defaultPushListPct, defaultDetailsHeight, latestSplitPct, serverChanged,
currentRepo, repoName, repos, classificationTypes, classificationMap,
filterModel, hasSelectedJob, revision, duplicateJobsVisible, groupCountsExpanded,
user,
isFieldFilterVisible,
serverChangedDelayed,
defaultPushListPct,
defaultDetailsHeight,
latestSplitPct,
serverChanged,
currentRepo,
repoName,
repos,
classificationTypes,
classificationMap,
filterModel,
hasSelectedJob,
revision,
duplicateJobsVisible,
groupCountsExpanded,
showShortCuts,
} = this.state;
@ -220,16 +249,21 @@ class App extends React.Component {
// we resize. Therefore, we must calculate the new
// height of the DetailsPanel based on the current height of the PushList.
// Reported this upstream: https://github.com/tomkp/react-split-pane/issues/282
const pushListPct = latestSplitPct === undefined || !hasSelectedJob ?
defaultPushListPct :
latestSplitPct;
const detailsHeight = latestSplitPct === undefined || !hasSelectedJob ?
defaultDetailsHeight :
getWindowHeight() * (1 - latestSplitPct / 100);
const filterBarFilters = Object.entries(filterModel.urlParams).reduce((acc, [field, value]) => (
HIDDEN_URL_PARAMS.includes(field) || matchesDefaults(field, value) ?
acc : [...acc, { field, value }]
), []);
const pushListPct =
latestSplitPct === undefined || !hasSelectedJob
? defaultPushListPct
: latestSplitPct;
const detailsHeight =
latestSplitPct === undefined || !hasSelectedJob
? defaultDetailsHeight
: getWindowHeight() * (1 - latestSplitPct / 100);
const filterBarFilters = Object.entries(filterModel.urlParams).reduce(
(acc, [field, value]) =>
HIDDEN_URL_PARAMS.includes(field) || matchesDefaults(field, value)
? acc
: [...acc, { field, value }],
[],
);
return (
<div id="global-container" className="height-minus-navbars">
@ -260,16 +294,22 @@ class App extends React.Component {
onChange={size => this.handleSplitChange(size)}
>
<div className="d-flex flex-column w-100">
{(isFieldFilterVisible || !!filterBarFilters.length) && <ActiveFilters
classificationTypes={classificationTypes}
filterModel={filterModel}
filterBarFilters={filterBarFilters}
isFieldFilterVisible={isFieldFilterVisible}
toggleFieldFilterVisible={this.toggleFieldFilterVisible}
/>}
{serverChangedDelayed && <UpdateAvailable
updateButtonClick={this.updateButtonClick}
/>}
{(isFieldFilterVisible || !!filterBarFilters.length) && (
<ActiveFilters
classificationTypes={classificationTypes}
filterModel={filterModel}
filterBarFilters={filterBarFilters}
isFieldFilterVisible={isFieldFilterVisible}
toggleFieldFilterVisible={
this.toggleFieldFilterVisible
}
/>
)}
{serverChangedDelayed && (
<UpdateAvailable
updateButtonClick={this.updateButtonClick}
/>
)}
<div id="th-global-content" className="th-global-content">
<span className="th-view-content" tabIndex={-1}>
<PushList
@ -294,16 +334,18 @@ class App extends React.Component {
/>
</SplitPane>
<NotificationList />
{showShortCuts && <div
id="onscreen-overlay"
onClick={() => this.showOnScreenShortcuts(false)}
>
<div id="onscreen-shortcuts">
<div className="col-8">
<ShortcutTable />
{showShortCuts && (
<div
id="onscreen-overlay"
onClick={() => this.showOnScreenShortcuts(false)}
>
<div id="onscreen-shortcuts">
<div className="col-8">
<ShortcutTable />
</div>
</div>
</div>
</div>}
)}
</KeyboardShortcuts>
</SelectedJob>
</PinnedJobs>

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

@ -5,9 +5,7 @@ import Ajv from 'ajv';
import jsonSchemaDefaults from 'json-schema-defaults';
import jsyaml from 'js-yaml';
import { slugid } from 'taskcluster-client-web';
import {
Button, Modal, ModalHeader, ModalBody, ModalFooter,
} from 'reactstrap';
import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap';
import { formatTaskclusterError } from '../helpers/errorMessage';
import TaskclusterModel from '../models/taskcluster';
@ -41,19 +39,30 @@ class CustomJobActions extends React.PureComponent {
this.close = this.close.bind(this);
this.triggerAction = this.triggerAction.bind(this);
getGeckoDecisionTaskId(pushId).then((decisionTaskId) => {
TaskclusterModel.load(decisionTaskId, job).then((results) => {
const { originalTask, originalTaskId, staticActionVariables, actions } = results;
const actionOptions = actions.map(action => ({ value: action, label: action.title }));
this.setState({
getGeckoDecisionTaskId(pushId).then(decisionTaskId => {
TaskclusterModel.load(decisionTaskId, job).then(results => {
const {
originalTask,
originalTaskId,
actions,
staticActionVariables,
actionOptions,
selectedActionOption: actionOptions[0],
}, () => this.updateSelectedAction(actions[0]));
actions,
} = results;
const actionOptions = actions.map(action => ({
value: action,
label: action.title,
}));
this.setState(
{
originalTask,
originalTaskId,
actions,
staticActionVariables,
actionOptions,
selectedActionOption: actionOptions[0],
},
() => this.updateSelectedAction(actions[0]),
);
});
this.setState({ decisionTaskId });
});
@ -87,8 +96,14 @@ class CustomJobActions extends React.PureComponent {
triggerAction() {
this.setState({ triggering: true });
const {
ajv, validate, payload, decisionTaskId, originalTaskId, originalTask,
selectedActionOption, staticActionVariables,
ajv,
validate,
payload,
decisionTaskId,
originalTaskId,
originalTask,
selectedActionOption,
staticActionVariables,
} = this.state;
const { notify } = this.props;
const action = selectedActionOption.value;
@ -111,34 +126,40 @@ class CustomJobActions extends React.PureComponent {
}
TaskclusterModel.submit({
action,
actionTaskId: slugid(),
decisionTaskId,
taskId: originalTaskId,
task: originalTask,
input,
staticActionVariables,
}).then((taskId) => {
this.setState({ triggering: false });
let message = 'Custom action request sent successfully:';
let url = `https://tools.taskcluster.net/tasks/${taskId}`;
action,
actionTaskId: slugid(),
decisionTaskId,
taskId: originalTaskId,
task: originalTask,
input,
staticActionVariables,
}).then(
taskId => {
this.setState({ triggering: false });
let message = 'Custom action request sent successfully:';
let url = `https://tools.taskcluster.net/tasks/${taskId}`;
// For the time being, we are redirecting specific actions to
// specific urls that are different than usual. At this time, we are
// only directing loaner tasks to the loaner UI in the tools site.
// It is possible that we may make this a part of the spec later.
const loaners = ['docker-worker-linux-loaner', 'generic-worker-windows-loaner'];
if (loaners.includes(action.name)) {
message = 'Visit Taskcluster Tools site to access loaner:';
url = `${url}/connect`;
}
notify(message, 'success', { linkText: 'Open in Taskcluster', url });
this.close();
}, (e) => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
this.setState({ triggering: false });
this.close();
});
// For the time being, we are redirecting specific actions to
// specific urls that are different than usual. At this time, we are
// only directing loaner tasks to the loaner UI in the tools site.
// It is possible that we may make this a part of the spec later.
const loaners = [
'docker-worker-linux-loaner',
'generic-worker-windows-loaner',
];
if (loaners.includes(action.name)) {
message = 'Visit Taskcluster Tools site to access loaner:';
url = `${url}/connect`;
}
notify(message, 'success', { linkText: 'Open in Taskcluster', url });
this.close();
},
e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
this.setState({ triggering: false });
this.close();
},
);
}
close() {
@ -154,62 +175,79 @@ class CustomJobActions extends React.PureComponent {
render() {
const { isLoggedIn, toggle } = this.props;
const {
triggering, selectedActionOption, schema, actions, actionOptions, payload,
triggering,
selectedActionOption,
schema,
actions,
actionOptions,
payload,
} = this.state;
const isOpen = true;
const selectedAction = selectedActionOption.value;
return (
<Modal isOpen={isOpen} toggle={this.close} size="lg">
<ModalHeader toggle={this.close}>Custom Taskcluster Job Actions</ModalHeader>
<ModalHeader toggle={this.close}>
Custom Taskcluster Job Actions
</ModalHeader>
<ModalBody>
{!actions && <div>
<p className="blink"> Getting available actions...</p>
</div>}
{!!actions && <div>
<div className="form-group">
<label>Action</label>
<Select
aria-describedby="selectedActionHelp"
value={selectedActionOption}
onChange={this.onChangeAction}
options={actionOptions}
/>
<p
id="selectedActionHelp"
className="help-block"
>{selectedAction.description}</p>
{selectedAction.kind === 'hook' && <p>This action triggers hook&nbsp;
<code>{selectedAction.hookGroupId}/{selectedAction.hookId}</code>
</p>}
{!actions && (
<div>
<p className="blink"> Getting available actions...</p>
</div>
<div className="row">
{!!selectedAction.schema && <React.Fragment>
<div className="col-s-12 col-md-6 form-group">
<label>Payload</label>
<textarea
value={payload}
className="form-control pre"
rows="10"
onChange={evt => this.onChangePayload(evt.target.value)}
spellCheck="false"
/>
</div>
<div className="col-s-12 col-md-6 form-group">
<label>Schema</label>
<textarea
className="form-control pre"
rows="10"
readOnly
value={schema}
/>
</div>
</React.Fragment>}
)}
{!!actions && (
<div>
<div className="form-group">
<label>Action</label>
<Select
aria-describedby="selectedActionHelp"
value={selectedActionOption}
onChange={this.onChangeAction}
options={actionOptions}
/>
<p id="selectedActionHelp" className="help-block">
{selectedAction.description}
</p>
{selectedAction.kind === 'hook' && (
<p>
This action triggers hook&nbsp;
<code>
{selectedAction.hookGroupId}/{selectedAction.hookId}
</code>
</p>
)}
</div>
<div className="row">
{!!selectedAction.schema && (
<React.Fragment>
<div className="col-s-12 col-md-6 form-group">
<label>Payload</label>
<textarea
value={payload}
className="form-control pre"
rows="10"
onChange={evt => this.onChangePayload(evt.target.value)}
spellCheck="false"
/>
</div>
<div className="col-s-12 col-md-6 form-group">
<label>Schema</label>
<textarea
className="form-control pre"
rows="10"
readOnly
value={schema}
/>
</div>
</React.Fragment>
)}
</div>
</div>
</div>}
)}
</ModalBody>
<ModalFooter>
{isLoggedIn ?
{isLoggedIn ? (
<Button
color="secondary"
className={`btn btn-primary-soft ${triggering ? 'disabled' : ''}`}
@ -218,10 +256,13 @@ class CustomJobActions extends React.PureComponent {
>
<span className="fa fa-check-square-o" aria-hidden="true" />
<span>{triggering ? 'Triggering' : 'Trigger'}</span>
</Button> :
</Button>
) : (
<p className="help-block"> Custom actions require login </p>
}
<Button color="secondary" onClick={toggle}>Cancel</Button>
)}
<Button color="secondary" onClick={toggle}>
Cancel
</Button>
</ModalFooter>
</Modal>
);

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

@ -59,7 +59,9 @@ class KeyboardShortcuts extends React.Component {
// open panels and selected job
clearScreen() {
const {
clearSelectedJob, showOnScreenShortcuts, notifications,
clearSelectedJob,
showOnScreenShortcuts,
notifications,
clearOnScreenNotifications,
} = this.props;
@ -134,7 +136,9 @@ class KeyboardShortcuts extends React.Component {
if (selectedJob) {
window.dispatchEvent(
new CustomEvent(thEvents.jobRetrigger, { detail: { job: selectedJob } }),
new CustomEvent(thEvents.jobRetrigger, {
detail: { job: selectedJob },
}),
);
}
}
@ -172,11 +176,15 @@ class KeyboardShortcuts extends React.Component {
return;
}
if ((element.tagName === 'INPUT' &&
element.type !== 'radio' && element.type !== 'checkbox') ||
if (
(element.tagName === 'INPUT' &&
element.type !== 'radio' &&
element.type !== 'checkbox') ||
element.tagName === 'SELECT' ||
element.tagName === 'TEXTAREA' ||
element.isContentEditable || ev.key === 'shift') {
element.isContentEditable ||
ev.key === 'shift'
) {
return;
}
@ -186,19 +194,26 @@ class KeyboardShortcuts extends React.Component {
}
render() {
const { filterModel, changeSelectedJob, showOnScreenShortcuts } = this.props;
const {
filterModel,
changeSelectedJob,
showOnScreenShortcuts,
} = this.props;
const handlers = {
addRelatedBug: ev => this.doKey(ev, this.addRelatedBug),
pinEditComment: ev => this.doKey(ev, this.pinEditComment),
quickFilter: ev => this.doKey(ev, this.quickFilter),
clearFilter: ev => this.doKey(ev, this.clearFilter),
toggleInProgress: ev => this.doKey(ev, filterModel.toggleInProgress),
nextUnclassified: ev => this.doKey(ev, () => changeSelectedJob('next', true)),
previousUnclassified: ev => this.doKey(ev, () => changeSelectedJob('previous', true)),
nextUnclassified: ev =>
this.doKey(ev, () => changeSelectedJob('next', true)),
previousUnclassified: ev =>
this.doKey(ev, () => changeSelectedJob('previous', true)),
openLogviewer: ev => this.doKey(ev, this.openLogviewer),
jobRetrigger: ev => this.doKey(ev, this.jobRetrigger),
selectNextTab: ev => this.doKey(ev, this.selectNextTab),
toggleUnclassifiedFailures: ev => this.doKey(ev, filterModel.toggleUnclassifiedFailures),
toggleUnclassifiedFailures: ev =>
this.doKey(ev, filterModel.toggleUnclassifiedFailures),
clearPinboard: ev => this.doKey(ev, this.clearPinboard),
previousJob: ev => this.doKey(ev, () => changeSelectedJob('previous')),
nextJob: ev => this.doKey(ev, () => changeSelectedJob('next')),
@ -249,4 +264,6 @@ KeyboardShortcuts.defaultProps = {
selectedJob: null,
};
export default withPinnedJobs(withSelectedJob(withNotifications(KeyboardShortcuts)));
export default withPinnedJobs(
withSelectedJob(withNotifications(KeyboardShortcuts)),
);

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

@ -69,10 +69,15 @@ export class PinnedJobsClass extends React.Component {
const { notify } = this.props;
if (MAX_SIZE - Object.keys(pinnedJobs).length > 0) {
this.setValue({
pinnedJobs: { ...pinnedJobs, [job.id]: job },
isPinBoardVisible: true,
}, () => { if (callback) callback(); });
this.setValue(
{
pinnedJobs: { ...pinnedJobs, [job.id]: job },
isPinBoardVisible: true,
},
() => {
if (callback) callback();
},
);
this.pulsePinCount();
} else {
notify(COUNT_ERROR, 'danger');
@ -91,21 +96,26 @@ export class PinnedJobsClass extends React.Component {
const { notify } = this.props;
const spaceRemaining = MAX_SIZE - Object.keys(pinnedJobs).length;
const showError = jobsToPin.length > spaceRemaining;
const newPinnedJobs = jobsToPin.slice(0, spaceRemaining).reduce((acc, job) => ({ ...acc, [job.id]: job }), {});
const newPinnedJobs = jobsToPin
.slice(0, spaceRemaining)
.reduce((acc, job) => ({ ...acc, [job.id]: job }), {});
if (!spaceRemaining) {
notify(COUNT_ERROR, 'danger', { sticky: true });
return;
}
this.setValue({
pinnedJobs: { ...pinnedJobs, ...newPinnedJobs },
isPinBoardVisible: true,
}, () => {
if (showError) {
notify(COUNT_ERROR, 'danger', { sticky: true });
}
});
this.setValue(
{
pinnedJobs: { ...pinnedJobs, ...newPinnedJobs },
isPinBoardVisible: true,
},
() => {
if (showError) {
notify(COUNT_ERROR, 'danger', { sticky: true });
}
},
);
}
addBug(bug, job) {
@ -114,7 +124,7 @@ export class PinnedJobsClass extends React.Component {
pinnedJobBugs[bug.id] = bug;
this.setValue({ pinnedJobBugs: { ...pinnedJobBugs } });
if (job) {
this.pinJob(job);
this.pinJob(job);
}
}

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

@ -5,7 +5,11 @@ import keyBy from 'lodash/keyBy';
import isEqual from 'lodash/isEqual';
import max from 'lodash/max';
import { thDefaultRepo, thEvents, thMaxPushFetchSize } from '../../helpers/constants';
import {
thDefaultRepo,
thEvents,
thMaxPushFetchSize,
} from '../../helpers/constants';
import { parseQueryParams } from '../../helpers/url';
import {
getAllUrlParams,
@ -68,7 +72,9 @@ export class PushesClass extends React.Component {
this.updateJobMap = this.updateJobMap.bind(this);
this.getAllShownJobs = this.getAllShownJobs.bind(this);
this.fetchPushes = this.fetchPushes.bind(this);
this.recalculateUnclassifiedCounts = this.recalculateUnclassifiedCounts.bind(this);
this.recalculateUnclassifiedCounts = this.recalculateUnclassifiedCounts.bind(
this,
);
this.setRevisionTips = this.setRevisionTips.bind(this);
this.addPushes = this.addPushes.bind(this);
this.getPush = this.getPush.bind(this);
@ -125,26 +131,30 @@ export class PushesClass extends React.Component {
const params = parseQueryParams(getQueryString());
return reloadOnChangeParameters.reduce(
(acc, prop) => (params[prop] ? { ...acc, [prop]: params[prop] } : acc), {});
(acc, prop) => (params[prop] ? { ...acc, [prop]: params[prop] } : acc),
{},
);
}
getAllShownJobs(pushId) {
const { jobMap } = this.state;
const jobList = Object.values(jobMap);
return pushId ?
jobList.filter(job => job.push_id === pushId && job.visible) :
jobList.filter(job => job.visible);
return pushId
? jobList.filter(job => job.push_id === pushId && job.visible)
: jobList.filter(job => job.visible);
}
setRevisionTips() {
const { pushList } = this.state;
this.setValue({ revisionTips: pushList.map(push => ({
revision: push.revision,
author: push.author,
title: push.revisions[0].comments.split('\n')[0],
})) });
this.setValue({
revisionTips: pushList.map(push => ({
revision: push.revision,
author: push.author,
title: push.revisions[0].comments.split('\n')[0],
})),
});
}
getPush(pushId) {
@ -180,25 +190,26 @@ export class PushesClass extends React.Component {
getGeckoDecisionJob(pushId) {
const { jobMap } = this.state;
return Object.values(jobMap).find(job => (
job.push_id === pushId &&
job.platform === 'gecko-decision' &&
job.state === 'completed' &&
job.job_type_symbol === 'D'));
return Object.values(jobMap).find(
job =>
job.push_id === pushId &&
job.platform === 'gecko-decision' &&
job.state === 'completed' &&
job.job_type_symbol === 'D',
);
}
getGeckoDecisionTaskId(pushId, repoName) {
const decisionTask = this.getGeckoDecisionJob(pushId);
if (decisionTask) {
return JobModel.get(repoName, decisionTask.id).then(
(job) => {
// this failure case is unlikely, but I guess you
// never know
if (!job.taskcluster_metadata) {
return Promise.reject('Decision task missing taskcluster metadata');
}
return job.taskcluster_metadata.task_id;
});
return JobModel.get(repoName, decisionTask.id).then(job => {
// this failure case is unlikely, but I guess you
// never know
if (!job.taskcluster_metadata) {
return Promise.reject('Decision task missing taskcluster metadata');
}
return job.taskcluster_metadata.task_id;
});
}
// no decision task, we fail
@ -207,7 +218,10 @@ export class PushesClass extends React.Component {
getLastModifiedJobTime() {
const { jobMap } = this.state;
const latest = max(Object.values(jobMap).map(job => new Date(`${job.last_modified}Z`))) || new Date();
const latest =
max(
Object.values(jobMap).map(job => new Date(`${job.last_modified}Z`)),
) || new Date();
latest.setSeconds(latest.getSeconds() - 3);
return latest;
@ -221,7 +235,10 @@ export class PushesClass extends React.Component {
// within the constraints of the URL params
const locationSearch = parseQueryParams(getQueryString());
const pushPollingParams = pushPollingKeys.reduce(
(acc, prop) => (locationSearch[prop] ? { ...acc, [prop]: locationSearch[prop] } : acc), {});
(acc, prop) =>
locationSearch[prop] ? { ...acc, [prop]: locationSearch[prop] } : acc,
{},
);
if (pushList.length === 1 && locationSearch.revision) {
// If we are on a single revision, no need to poll for more pushes, but
@ -235,16 +252,15 @@ export class PushesClass extends React.Component {
}
// We will either have a ``revision`` param, but no push for it yet,
// or a ``fromchange`` param because we have at least 1 push already.
PushModel.getList(pushPollingParams)
.then(async (resp) => {
if (resp.ok) {
const data = await resp.json();
this.addPushes(data);
this.fetchNewJobs();
} else {
notify('Error fetching new push data', 'danger', { sticky: true });
}
});
PushModel.getList(pushPollingParams).then(async resp => {
if (resp.ok) {
const data = await resp.json();
this.addPushes(data);
this.fetchNewJobs();
} else {
notify('Error fetching new push data', 'danger', { sticky: true });
}
});
}
}, pushPollInterval);
}
@ -262,12 +278,16 @@ export class PushesClass extends React.Component {
const newReloadTriggerParams = this.getNewReloadTriggerParams();
// if we are just setting the repo to the default because none was
// set initially, then don't reload the page.
const defaulting = newReloadTriggerParams.repo === thDefaultRepo &&
const defaulting =
newReloadTriggerParams.repo === thDefaultRepo &&
!cachedReloadTriggerParams.repo;
if (!defaulting && cachedReloadTriggerParams &&
if (
!defaulting &&
cachedReloadTriggerParams &&
!isEqual(newReloadTriggerParams, cachedReloadTriggerParams) &&
!this.skipNextPageReload) {
!this.skipNextPageReload
) {
window.location.reload();
} else {
this.setState({ cachedReloadTriggerParams: newReloadTriggerParams });
@ -301,15 +321,17 @@ export class PushesClass extends React.Component {
delete options.tochange;
options.push_timestamp__lte = oldestPushTimestamp;
}
return PushModel.getList(options).then(async (resp) => {
if (resp.ok) {
const data = await resp.json();
return PushModel.getList(options)
.then(async resp => {
if (resp.ok) {
const data = await resp.json();
this.addPushes(data.results.length ? data : { results: [] });
} else {
notify('Error retrieving push data!', 'danger', { sticky: true });
}
}).then(() => this.setValue({ loadingPushes: false }));
this.addPushes(data.results.length ? data : { results: [] });
} else {
notify('Error retrieving push data!', 'danger', { sticky: true });
}
})
.then(() => this.setValue({ loadingPushes: false }));
}
addPushes(data) {
@ -317,13 +339,16 @@ export class PushesClass extends React.Component {
if (data.results.length > 0) {
const pushIds = pushList.map(push => push.id);
const newPushList = [...pushList, ...data.results.filter(push => !pushIds.includes(push.id))];
const newPushList = [
...pushList,
...data.results.filter(push => !pushIds.includes(push.id)),
];
newPushList.sort((a, b) => b.push_timestamp - a.push_timestamp);
const oldestPushTimestamp = newPushList[newPushList.length - 1].push_timestamp;
const oldestPushTimestamp =
newPushList[newPushList.length - 1].push_timestamp;
this.recalculateUnclassifiedCounts();
this.setValue(
{ pushList: newPushList, oldestPushTimestamp },
() => this.setRevisionTips(),
this.setValue({ pushList: newPushList, oldestPushTimestamp }, () =>
this.setRevisionTips(),
);
}
}
@ -345,13 +370,15 @@ export class PushesClass extends React.Component {
// If a job is selected, and one of the jobs we just fetched is the
// updated version of that selected job, then send that with the event.
const selectedJobId = getUrlParam('selectedJob');
const updatedSelectedJob = selectedJobId ?
jobList.find(job => job.id === parseInt(selectedJobId, 10)) : null;
const updatedSelectedJob = selectedJobId
? jobList.find(job => job.id === parseInt(selectedJobId, 10))
: null;
window.dispatchEvent(new CustomEvent(
thEvents.applyNewJobs,
{ detail: { jobs, updatedSelectedJob } },
));
window.dispatchEvent(
new CustomEvent(thEvents.applyNewJobs, {
detail: { jobs, updatedSelectedJob },
}),
);
}
updateUrlFromchange() {
@ -378,7 +405,7 @@ export class PushesClass extends React.Component {
let allUnclassifiedFailureCount = 0;
let filteredUnclassifiedFailureCount = 0;
Object.values(jobMap).forEach((job) => {
Object.values(jobMap).forEach(job => {
if (isUnclassifiedFailure(job)) {
if (tiers.includes(String(job.tier))) {
if (filterModel.showJob(job)) {
@ -388,7 +415,10 @@ export class PushesClass extends React.Component {
}
}
});
this.setValue({ allUnclassifiedFailureCount, filteredUnclassifiedFailureCount });
this.setValue({
allUnclassifiedFailureCount,
filteredUnclassifiedFailureCount,
});
}
updateJobMap(jobList) {
@ -411,7 +441,6 @@ export class PushesClass extends React.Component {
</PushesContext.Provider>
);
}
}
PushesClass.propTypes = {
@ -435,12 +464,16 @@ export function withPushes(Component) {
jobsLoaded={context.jobsLoaded}
loadingPushes={context.loadingPushes}
allUnclassifiedFailureCount={context.allUnclassifiedFailureCount}
filteredUnclassifiedFailureCount={context.filteredUnclassifiedFailureCount}
filteredUnclassifiedFailureCount={
context.filteredUnclassifiedFailureCount
}
updateJobMap={context.updateJobMap}
getAllShownJobs={context.getAllShownJobs}
fetchPushes={context.fetchPushes}
getPush={context.getPush}
recalculateUnclassifiedCounts={context.recalculateUnclassifiedCounts}
recalculateUnclassifiedCounts={
context.recalculateUnclassifiedCounts
}
getNextPushes={context.getNextPushes}
getGeckoDecisionJob={context.getGeckoDecisionJob}
getGeckoDecisionTaskId={context.getGeckoDecisionTaskId}

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

@ -42,7 +42,9 @@ class SelectedJobClass extends React.Component {
this.setSelectedJob = this.setSelectedJob.bind(this);
this.clearSelectedJob = this.clearSelectedJob.bind(this);
this.changeSelectedJob = this.changeSelectedJob.bind(this);
this.noMoreUnclassifiedFailures = this.noMoreUnclassifiedFailures.bind(this);
this.noMoreUnclassifiedFailures = this.noMoreUnclassifiedFailures.bind(
this,
);
this.handleApplyNewJobs = this.handleApplyNewJobs.bind(this);
// TODO: this.value needs to now get the bound versions of the functions.
@ -88,7 +90,10 @@ class SelectedJobClass extends React.Component {
const selectedJobIdStr = getUrlParam('selectedJob');
const selectedJobId = parseInt(selectedJobIdStr, 10);
if (selectedJobIdStr && (!selectedJob || selectedJob.id !== selectedJobId)) {
if (
selectedJobIdStr &&
(!selectedJob || selectedJob.id !== selectedJobId)
) {
const selectedJob = jobMap[selectedJobIdStr];
// select the job in question
@ -98,30 +103,37 @@ class SelectedJobClass extends React.Component {
setUrlParam('selectedJob');
// If the ``selectedJob`` was not mapped, then we need to notify
// the user it's not in the range of the current result set list.
JobModel.get(repoName, selectedJobId).then((job) => {
PushModel.get(job.push_id).then(async (resp) => {
if (resp.ok) {
const push = await resp.json();
const newPushUrl = getJobsUrl({ repo: repoName, revision: push.revision, selectedJob: selectedJobId });
JobModel.get(repoName, selectedJobId)
.then(job => {
PushModel.get(job.push_id).then(async resp => {
if (resp.ok) {
const push = await resp.json();
const newPushUrl = getJobsUrl({
repo: repoName,
revision: push.revision,
selectedJob: selectedJobId,
});
// the job exists, but isn't in any loaded push.
// provide a message and link to load the right push
notify(
`Selected job id: ${selectedJobId} not within current push range.`,
'danger',
{ sticky: true, linkText: 'Load push', url: newPushUrl });
} else {
throw Error(`Unable to find push with id ${job.push_id} for selected job`);
}
// the job exists, but isn't in any loaded push.
// provide a message and link to load the right push
notify(
`Selected job id: ${selectedJobId} not within current push range.`,
'danger',
{ sticky: true, linkText: 'Load push', url: newPushUrl },
);
} else {
throw Error(
`Unable to find push with id ${job.push_id} for selected job`,
);
}
});
})
.catch(error => {
// the job wasn't found in the db. Either never existed,
// or was expired and deleted.
this.clearSelectedJob();
notify(`Selected Job - ${error}`, 'danger', { sticky: true });
});
}).catch((error) => {
// the job wasn't found in the db. Either never existed,
// or was expired and deleted.
this.clearSelectedJob();
notify(`Selected Job - ${error}`,
'danger',
{ sticky: true });
});
}
} else if (!selectedJobIdStr && selectedJob) {
this.setValue({ selectedJob: null });
@ -193,13 +205,15 @@ class SelectedJobClass extends React.Component {
changeSelectedJob(direction, unclassifiedOnly) {
const { pinnedJobs } = this.props;
const jobNavSelector = unclassifiedOnly ?
thJobNavSelectors.UNCLASSIFIED_FAILURES : thJobNavSelectors.ALL_JOBS;
const jobNavSelector = unclassifiedOnly
? thJobNavSelectors.UNCLASSIFIED_FAILURES
: thJobNavSelectors.ALL_JOBS;
// Get the appropriate next index based on the direction and current job
// selection (if any). Must wrap end to end.
const getIndex = direction === 'next' ?
(idx, jobs) => (idx + 1 > jobs.length - 1 ? 0 : idx + 1) :
(idx, jobs) => (idx - 1 < 0 ? jobs.length - 1 : idx - 1);
const getIndex =
direction === 'next'
? (idx, jobs) => (idx + 1 > jobs.length - 1 ? 0 : idx + 1)
: (idx, jobs) => (idx - 1 < 0 ? jobs.length - 1 : idx - 1);
// TODO: (bug 1434679) Move from using jquery here to find the next/prev
// component. This could perhaps be done either with:
@ -257,7 +271,8 @@ class SelectedJobClass extends React.Component {
// a btn.
// This will exclude the JobDetails and navbars.
const globalContent = document.getElementById('th-global-content');
const isEligible = globalContent.contains(target) &&
const isEligible =
globalContent.contains(target) &&
target.tagName !== 'A' &&
!intersection(target.classList, ['btn', 'dropdown-item']).length;
@ -278,7 +293,6 @@ class SelectedJobClass extends React.Component {
</SelectedJobContext.Provider>
);
}
}
SelectedJobClass.propTypes = {
@ -289,7 +303,9 @@ SelectedJobClass.propTypes = {
children: PropTypes.object.isRequired,
};
export const SelectedJob = withNotifications(withPushes(withPinnedJobs(SelectedJobClass)));
export const SelectedJob = withNotifications(
withPushes(withPinnedJobs(SelectedJobClass)),
);
export function withSelectedJob(Component) {
return function SelectedJobComponent(props) {

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

@ -1,7 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Button, Modal, ModalHeader, ModalBody, ModalFooter, Tooltip, FormGroup, Input,
Button,
Modal,
ModalHeader,
ModalBody,
ModalFooter,
Tooltip,
FormGroup,
Input,
Label,
} from 'reactstrap';
@ -16,11 +23,16 @@ import { create } from '../../helpers/http';
import { withNotifications } from '../../shared/context/Notifications';
const crashRegex = /application crashed \[@ (.+)\]$/g;
const omittedLeads = ['TEST-UNEXPECTED-FAIL', 'PROCESS-CRASH', 'TEST-UNEXPECTED-ERROR', 'REFTEST ERROR'];
const omittedLeads = [
'TEST-UNEXPECTED-FAIL',
'PROCESS-CRASH',
'TEST-UNEXPECTED-ERROR',
'REFTEST ERROR',
];
/*
* Find the first thing in the summary line that looks like a filename.
*/
const findFilename = (summary) => {
const findFilename = summary => {
// Take left side of any reftest comparisons, as the right side is the reference file
// eslint-disable-next-line prefer-destructuring
summary = summary.split('==')[0];
@ -39,7 +51,7 @@ const findFilename = (summary) => {
* Remove extraneous junk from the start of the summary line
* and try to find the failing test name from what's left
*/
const parseSummary = (suggestion) => {
const parseSummary = suggestion => {
let summary = suggestion.search;
const searchTerms = suggestion.search_terms;
// Strip out some extra stuff at the start of some failure paths
@ -59,7 +71,10 @@ const parseSummary = (suggestion) => {
summary = summary.replace(re, '');
summary = summary.replace('/_mozilla/', 'mozilla/tests/');
// We don't want to include "REFTEST" when it's an unexpected pass
summary = summary.replace('REFTEST TEST-UNEXPECTED-PASS', 'TEST-UNEXPECTED-PASS');
summary = summary.replace(
'REFTEST TEST-UNEXPECTED-PASS',
'TEST-UNEXPECTED-PASS',
);
const summaryParts = summary.split(' | ');
// If the search_terms used for finding bug suggestions
@ -67,7 +82,7 @@ const parseSummary = (suggestion) => {
// for the full string match, so don't omit it in this case.
// If it's not needed, go ahead and omit it.
if (searchTerms.length && summaryParts.length > 1) {
omittedLeads.forEach((lead) => {
omittedLeads.forEach(lead => {
if (!searchTerms[0].includes(lead) && summaryParts[0].includes(lead)) {
summaryParts.shift();
}
@ -76,7 +91,10 @@ const parseSummary = (suggestion) => {
// Some of the TEST-FOO bits aren't removed from the summary,
// so we sometimes end up with them instead of the test path here.
const summaryName = summaryParts[0].startsWith('TEST-') && summaryParts.length > 1 ? summaryParts[1] : summaryParts[0];
const summaryName =
summaryParts[0].startsWith('TEST-') && summaryParts.length > 1
? summaryParts[1]
: summaryParts[0];
const possibleFilename = findFilename(summaryName);
return [summaryParts, possibleFilename];
@ -86,16 +104,30 @@ export class BugFilerClass extends React.Component {
constructor(props) {
super(props);
const { suggestions, suggestion, fullLog, parsedLog, reftestUrl, jobGroupName } = props;
const {
suggestions,
suggestion,
fullLog,
parsedLog,
reftestUrl,
jobGroupName,
} = props;
const allFailures = suggestions.map(sugg => sugg.search
.split(' | ')
.filter(part => !omittedLeads.includes(part))
.map(item => (item === 'REFTEST TEST-UNEXPECTED-PASS' ? 'TEST-UNEXPECTED-PASS' : item)),
const allFailures = suggestions.map(sugg =>
sugg.search
.split(' | ')
.filter(part => !omittedLeads.includes(part))
.map(item =>
item === 'REFTEST TEST-UNEXPECTED-PASS'
? 'TEST-UNEXPECTED-PASS'
: item,
),
);
const thisFailure = allFailures.map(f => f.join(' | ')).join('\n');
const crash = suggestion.search.match(crashRegex);
const crashSignatures = crash ? [crash[0].split('application crashed ')[1]] : [];
const crashSignatures = crash
? [crash[0].split('application crashed ')[1]]
: [];
const parsedSummary = parseSummary(suggestion);
let summaryString = parsedSummary[0].join(' | ');
@ -140,7 +172,7 @@ export class BugFilerClass extends React.Component {
return 'Selected failure does not contain any searchable terms.';
}
if (searchTerms.every(term => !summary.includes(term))) {
return 'Summary does not include the full text of any of the selected failure\'s search terms:';
return "Summary does not include the full text of any of the selected failure's search terms:";
}
return '';
}
@ -157,7 +189,10 @@ export class BugFilerClass extends React.Component {
if (jg.includes('talos')) {
newProducts.push('Testing :: Talos');
}
if (jg.includes('mochitest') && (fp.includes('webextensions/') || fp.includes('components/extensions'))) {
if (
jg.includes('mochitest') &&
(fp.includes('webextensions/') || fp.includes('components/extensions'))
) {
newProducts.push('WebExtensions :: General');
}
if (jg.includes('mochitest') && fp.includes('webrtc/')) {
@ -193,12 +228,20 @@ export class BugFilerClass extends React.Component {
this.setState({ searching: true });
if (productSearch) {
const resp = await fetch(`${bzBaseUrl}rest/prod_comp_search/${productSearch}?limit=5`);
const resp = await fetch(
`${bzBaseUrl}rest/prod_comp_search/${productSearch}?limit=5`,
);
const data = await resp.json();
const products = data.products.filter(item => !!item.product && !!item.component);
suggestedProductsSet = new Set([...suggestedProductsSet, ...products.map(prod => (
prod.product + (prod.component ? ` :: ${prod.component}` : '')
))]);
const products = data.products.filter(
item => !!item.product && !!item.component,
);
suggestedProductsSet = new Set([
...suggestedProductsSet,
...products.map(
prod =>
prod.product + (prod.component ? ` :: ${prod.component}` : ''),
),
]);
} else {
let failurePath = parsedSummary[0][0];
@ -219,17 +262,22 @@ export class BugFilerClass extends React.Component {
failurePath = `dom/media/test/external/external_media_tests/${failurePath}`;
}
if (lowerJobGroupName.includes('web platform')) {
failurePath = failurePath.startsWith('mozilla/tests') ?
`testing/web-platform/${failurePath}` :
`testing/web-platform/tests/${failurePath}`;
failurePath = failurePath.startsWith('mozilla/tests')
? `testing/web-platform/${failurePath}`
: `testing/web-platform/tests/${failurePath}`;
}
// Search mercurial's moz.build metadata to find products/components
fetch(`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=${failurePath}`)
.then(resp => resp.json().then((firstRequest) => {
if (firstRequest.data.aggregate && firstRequest.data.aggregate.recommended_bug_component) {
const suggested = firstRequest.data.aggregate.recommended_bug_component;
fetch(
`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=${failurePath}`,
).then(resp =>
resp.json().then(firstRequest => {
if (
firstRequest.data.aggregate &&
firstRequest.data.aggregate.recommended_bug_component
) {
const suggested =
firstRequest.data.aggregate.recommended_bug_component;
suggestedProductsSet.add(`${suggested[0]} :: ${suggested[1]}`);
}
@ -237,34 +285,53 @@ export class BugFilerClass extends React.Component {
if (suggestedProductsSet.size === 0 && possibleFilename.length > 4) {
const dxrlink = `${dxrBaseUrl}mozilla-central/search?q=file:${possibleFilename}&redirect=false&limit=5`;
// Bug 1358328 - We need to override headers here until DXR returns JSON with the default Accept header
fetch(dxrlink, { headers: { Accept: 'application/json' } })
.then((secondRequest) => {
fetch(dxrlink, { headers: { Accept: 'application/json' } }).then(
secondRequest => {
const { results } = secondRequest.data;
let resultsCount = results.length;
// If the search returns too many results, this probably isn't a good search term, so bail
if (resultsCount === 0) {
suggestedProductsSet = new Set([...suggestedProductsSet, this.getSpecialProducts(failurePath)]);
suggestedProductsSet = new Set([
...suggestedProductsSet,
this.getSpecialProducts(failurePath),
]);
}
results.forEach((result) => {
fetch(`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=${result.path}`)
.then((thirdRequest) => {
if (thirdRequest.data.aggregate && thirdRequest.data.aggregate.recommended_bug_component) {
const suggested = thirdRequest.data.aggregate.recommended_bug_component;
suggestedProductsSet.add(`${suggested[0]} :: ${suggested[1]}`);
}
// Only get rid of the throbber when all of these searches have completed
resultsCount -= 1;
if (resultsCount === 0) {
suggestedProductsSet = new Set([...suggestedProductsSet, this.getSpecialProducts(failurePath)]);
}
});
results.forEach(result => {
fetch(
`${hgBaseUrl}mozilla-central/json-mozbuildinfo?p=${
result.path
}`,
).then(thirdRequest => {
if (
thirdRequest.data.aggregate &&
thirdRequest.data.aggregate.recommended_bug_component
) {
const suggested =
thirdRequest.data.aggregate.recommended_bug_component;
suggestedProductsSet.add(
`${suggested[0]} :: ${suggested[1]}`,
);
}
// Only get rid of the throbber when all of these searches have completed
resultsCount -= 1;
if (resultsCount === 0) {
suggestedProductsSet = new Set([
...suggestedProductsSet,
this.getSpecialProducts(failurePath),
]);
}
});
});
});
},
);
} else {
suggestedProductsSet = new Set([...suggestedProductsSet, this.getSpecialProducts(failurePath)]);
suggestedProductsSet = new Set([
...suggestedProductsSet,
this.getSpecialProducts(failurePath),
]);
}
}));
}),
);
}
const newSuggestedProducts = [...suggestedProductsSet];
@ -277,9 +344,9 @@ export class BugFilerClass extends React.Component {
toggleCheckedLogLink(link) {
const { checkedLogLinks } = this.state;
const newCheckedLogLinks = checkedLogLinks.includes(link) ?
checkedLogLinks.filter(item => item !== link) :
[...checkedLogLinks, link];
const newCheckedLogLinks = checkedLogLinks.includes(link)
? checkedLogLinks.filter(item => item !== link)
: [...checkedLogLinks, link];
this.setState({ checkedLogLinks: newCheckedLogLinks });
}
@ -289,19 +356,32 @@ export class BugFilerClass extends React.Component {
*/
async submitFiler() {
const {
summary, selectedProduct, comment, isIntermittent, checkedLogLinks,
blocks, dependsOn, seeAlso, crashSignatures,
summary,
selectedProduct,
comment,
isIntermittent,
checkedLogLinks,
blocks,
dependsOn,
seeAlso,
crashSignatures,
} = this.state;
const { toggle, successCallback, notify } = this.props;
const [product, component] = selectedProduct.split(' :: ');
if (!selectedProduct) {
notify('Please select (or search and select) a product/component pair to continue', 'danger');
notify(
'Please select (or search and select) a product/component pair to continue',
'danger',
);
return;
}
if (summary.length > 255) {
notify('Please ensure the summary is no more than 255 characters', 'danger');
notify(
'Please ensure the summary is no more than 255 characters',
'danger',
);
return;
}
@ -320,12 +400,16 @@ export class BugFilerClass extends React.Component {
// submit the new bug. Only request the versions because some products
// take quite a long time to fetch the full object
try {
const productResp = await fetch(bugzillaBugsApi(`product/${product}`, { include_fields: 'versions' }));
const productResp = await fetch(
bugzillaBugsApi(`product/${product}`, { include_fields: 'versions' }),
);
const productData = await productResp.json();
if (productResp.ok) {
const productObject = productData.products[0];
// Find the newest version for the product that is_active
const version = productObject.versions.filter(prodVer => prodVer.is_active).slice(-1)[0];
const version = productObject.versions
.filter(prodVer => prodVer.is_active)
.slice(-1)[0];
const payload = {
product,
component,
@ -342,17 +426,30 @@ export class BugFilerClass extends React.Component {
comment_tags: 'treeherder',
};
const bugResp = await create(getApiUrl('/bugzilla/create_bug/'), payload);
const bugResp = await create(
getApiUrl('/bugzilla/create_bug/'),
payload,
);
// const bugResp = await create('http://httpstat.us/404', payload);
const data = await bugResp.json();
if (bugResp.ok) {
successCallback(data);
toggle();
} else {
this.submitFailure('Treeherder Bug Filer API', bugResp.status, bugResp.statusText, data);
this.submitFailure(
'Treeherder Bug Filer API',
bugResp.status,
bugResp.statusText,
data,
);
}
} else {
this.submitFailure('Bugzilla', productResp.status, productResp.statusText, productData);
this.submitFailure(
'Bugzilla',
productResp.status,
productResp.statusText,
productData,
);
}
} catch (e) {
notify(`Error filing bug: ${e.toString()}`, 'danger', { sticky: true });
@ -367,28 +464,45 @@ export class BugFilerClass extends React.Component {
failureString += `\n\n${data.failure}`;
}
if (status === 403) {
failureString += '\n\nAuthentication failed. Has your Treeherder session expired?';
failureString +=
'\n\nAuthentication failed. Has your Treeherder session expired?';
}
notify(failureString, 'danger', { sticky: true });
}
toggleTooltip(key) {
const { tooltipOpen } = this.state;
this.setState({ tooltipOpen: { ...tooltipOpen, [key]: !tooltipOpen[key] } });
this.setState({
tooltipOpen: { ...tooltipOpen, [key]: !tooltipOpen[key] },
});
}
render() {
const {
isOpen, toggle, suggestion, parsedLog, fullLog, reftestUrl,
isOpen,
toggle,
suggestion,
parsedLog,
fullLog,
reftestUrl,
} = this.props;
const {
productSearch, suggestedProducts, thisFailure, isFilerSummaryVisible,
isIntermittent, summary, searching, checkedLogLinks, tooltipOpen,
productSearch,
suggestedProducts,
thisFailure,
isFilerSummaryVisible,
isIntermittent,
summary,
searching,
checkedLogLinks,
tooltipOpen,
selectedProduct,
} = this.state;
const searchTerms = suggestion.search_terms;
const crash = summary.match(crashRegex);
const crashSignatures = crash ? [crash[0].split('application crashed ')[1]] : [];
const crashSignatures = crash
? [crash[0].split('application crashed ')[1]]
: [];
const unhelpfulSummaryReason = this.getUnhelpfulSummaryReason(summary);
return (
@ -402,7 +516,9 @@ export class BugFilerClass extends React.Component {
name="modalProductFinderSearch"
id="modalProductFinderSearch"
onKeyDown={this.productSearchEnter}
onChange={evt => this.setState({ productSearch: evt.target.value })}
onChange={evt =>
this.setState({ productSearch: evt.target.value })
}
type="text"
placeholder="Firefox"
className="flex-fill flex-grow-1"
@ -411,29 +527,42 @@ export class BugFilerClass extends React.Component {
target="modalProductFinderSearch"
isOpen={tooltipOpen.modalProductFinderSearch}
toggle={() => this.toggleTooltip('modalProductFinderSearch')}
>Manually search for a product</Tooltip>
>
Manually search for a product
</Tooltip>
<Button
color="secondary"
className="ml-1 btn-sm"
type="button"
onClick={this.findProduct}
>Find Product</Button>
>
Find Product
</Button>
</div>
<div>
{!!productSearch && searching && <div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />Searching {productSearch}
</div>}
{!!productSearch && searching && (
<div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
Searching {productSearch}
</div>
)}
<FormGroup tag="fieldset" className="mt-1">
{suggestedProducts.map(product => (
<div className="ml-4" key={`modalProductSuggestion${product}`}>
<div
className="ml-4"
key={`modalProductSuggestion${product}`}
>
<Label check>
<Input
type="radio"
value={product}
checked={product === selectedProduct}
onChange={evt => this.setState({ selectedProduct: evt.target.value })}
onChange={evt =>
this.setState({ selectedProduct: evt.target.value })
}
name="productGroup"
/>{product}
/>
{product}
</Label>
</div>
))}
@ -441,20 +570,31 @@ export class BugFilerClass extends React.Component {
</div>
<label>Summary:</label>
<div className="d-flex">
{!!unhelpfulSummaryReason && <div>
<div className="text-danger">
<span
className="fa fa-warning"
id="unhelpful-summary-reason"
/>Warning: {unhelpfulSummaryReason}
<Tooltip
target="unhelpful-summary-reason"
isOpen={tooltipOpen.unhelpfulSummaryReason}
toggle={() => this.toggleTooltip('unhelpfulSummaryReason')}
>This can cause poor bug suggestions to be generated</Tooltip>
{!!unhelpfulSummaryReason && (
<div>
<div className="text-danger">
<span
className="fa fa-warning"
id="unhelpful-summary-reason"
/>
Warning: {unhelpfulSummaryReason}
<Tooltip
target="unhelpful-summary-reason"
isOpen={tooltipOpen.unhelpfulSummaryReason}
toggle={() =>
this.toggleTooltip('unhelpfulSummaryReason')
}
>
This can cause poor bug suggestions to be generated
</Tooltip>
</div>
{searchTerms.map(term => (
<div className="text-monospace pl-3" key={term}>
{term}
</div>
))}
</div>
{searchTerms.map(term => <div className="text-monospace pl-3" key={term}>{term}</div>)}
</div>}
)}
<Input
id="summary"
className="flex-grow-1"
@ -469,27 +609,45 @@ export class BugFilerClass extends React.Component {
isOpen={tooltipOpen.toggleFailureLines}
toggle={() => this.toggleTooltip('toggleFailureLines')}
>
{isFilerSummaryVisible ? 'Hide all failure lines for this job' : 'Show all failure lines for this job'}
{isFilerSummaryVisible
? 'Hide all failure lines for this job'
: 'Show all failure lines for this job'}
</Tooltip>
<i
onClick={() => this.setState({ isFilerSummaryVisible: !isFilerSummaryVisible })}
className={`fa fa-lg pointable align-bottom pt-2 ml-1 ${isFilerSummaryVisible ? 'fa-chevron-circle-up' : 'fa-chevron-circle-down'}`}
onClick={() =>
this.setState({
isFilerSummaryVisible: !isFilerSummaryVisible,
})
}
className={`fa fa-lg pointable align-bottom pt-2 ml-1 ${
isFilerSummaryVisible
? 'fa-chevron-circle-up'
: 'fa-chevron-circle-down'
}`}
id="toggle-failure-lines"
/>
<span
id="summaryLength"
className={`ml-1 font-weight-bold lg ${summary.length > 255 ? 'text-danger' : 'text-success'}`}
>{summary.length}</span>
className={`ml-1 font-weight-bold lg ${
summary.length > 255 ? 'text-danger' : 'text-success'
}`}
>
{summary.length}
</span>
</div>
{isFilerSummaryVisible && <span>
<Input
className="w-100"
type="textarea"
value={thisFailure}
readOnly
onChange={evt => this.setState({ thisFailure: evt.target.value })}
/>
</span>}
{isFilerSummaryVisible && (
<span>
<Input
className="w-100"
type="textarea"
value={thisFailure}
readOnly
onChange={evt =>
this.setState({ thisFailure: evt.target.value })
}
/>
</span>
)}
<div className="ml-5 mt-2">
<div>
<label>
@ -498,7 +656,13 @@ export class BugFilerClass extends React.Component {
checked={checkedLogLinks.includes(parsedLog)}
onChange={() => this.toggleCheckedLogLink(parsedLog)}
/>
<a target="_blank" rel="noopener noreferrer" href={parsedLog}>Include Parsed Log Link</a>
<a
target="_blank"
rel="noopener noreferrer"
href={parsedLog}
>
Include Parsed Log Link
</a>
</label>
</div>
<div>
@ -508,17 +672,29 @@ export class BugFilerClass extends React.Component {
checked={checkedLogLinks.includes(fullLog)}
onChange={() => this.toggleCheckedLogLink(fullLog)}
/>
<a target="_blank" rel="noopener noreferrer" href={fullLog}>Include Full Log Link</a>
<a target="_blank" rel="noopener noreferrer" href={fullLog}>
Include Full Log Link
</a>
</label>
</div>
{!!reftestUrl && <div><label>
<Input
type="checkbox"
checked={checkedLogLinks.includes(reftestUrl)}
onChange={() => this.toggleCheckedLogLink(reftestUrl)}
/>
<a target="_blank" rel="noopener noreferrer" href={reftestUrl}>Include Reftest Viewer Link</a>
</label></div>}
{!!reftestUrl && (
<div>
<label>
<Input
type="checkbox"
checked={checkedLogLinks.includes(reftestUrl)}
onChange={() => this.toggleCheckedLogLink(reftestUrl)}
/>
<a
target="_blank"
rel="noopener noreferrer"
href={reftestUrl}
>
Include Reftest Viewer Link
</a>
</label>
</div>
)}
</div>
<div className="d-flex flex-column">
<label>Comment:</label>
@ -533,17 +709,22 @@ export class BugFilerClass extends React.Component {
<div className="mt-2">
<label>
<Input
onChange={() => this.setState({ isIntermittent: !isIntermittent })}
onChange={() =>
this.setState({ isIntermittent: !isIntermittent })
}
type="checkbox"
checked={isIntermittent}
/>This is an intermittent failure
/>
This is an intermittent failure
</label>
</div>
<div className="d-inline-flex ml-2">
<Input
id="blocksInput"
type="text"
onChange={evt => this.setState({ blocks: evt.target.value })}
onChange={evt =>
this.setState({ blocks: evt.target.value })
}
placeholder="Blocks"
/>
<Tooltip
@ -551,12 +732,16 @@ export class BugFilerClass extends React.Component {
placement="bottom"
isOpen={tooltipOpen.blocksInput}
toggle={() => this.toggleTooltip('blocksInput')}
>Comma-separated list of bugs</Tooltip>
>
Comma-separated list of bugs
</Tooltip>
<Input
id="dependsOn"
type="text"
className="ml-1"
onChange={evt => this.setState({ dependsOn: evt.target.value })}
onChange={evt =>
this.setState({ dependsOn: evt.target.value })
}
placeholder="Depends on"
/>
<Tooltip
@ -564,12 +749,16 @@ export class BugFilerClass extends React.Component {
placement="bottom"
isOpen={tooltipOpen.dependsOn}
toggle={() => this.toggleTooltip('dependsOn')}
>Comma-separated list of bugs</Tooltip>
>
Comma-separated list of bugs
</Tooltip>
<Input
id="seeAlso"
className="ml-1"
type="text"
onChange={evt => this.setState({ seeAlso: evt.target.value })}
onChange={evt =>
this.setState({ seeAlso: evt.target.value })
}
placeholder="See also"
/>
<Tooltip
@ -577,25 +766,34 @@ export class BugFilerClass extends React.Component {
placement="bottom"
isOpen={tooltipOpen.seeAlso}
toggle={() => this.toggleTooltip('seeAlso')}
>Comma-separated list of bugs</Tooltip>
>
Comma-separated list of bugs
</Tooltip>
</div>
</div>
{!!crashSignatures.length && <div>
<label>Signature:</label>
<Input
type="textarea"
onChange={evt => this.setState({ crashSignatures: evt.target.value })}
maxLength="2048"
readOnly
value={crashSignatures.join('\n')}
/>
</div>}
{!!crashSignatures.length && (
<div>
<label>Signature:</label>
<Input
type="textarea"
onChange={evt =>
this.setState({ crashSignatures: evt.target.value })
}
maxLength="2048"
readOnly
value={crashSignatures.join('\n')}
/>
</div>
)}
</form>
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={this.submitFiler}>Submit Bug</Button>{' '}
<Button color="secondary" onClick={toggle}>Cancel</Button>
<Button color="secondary" onClick={this.submitFiler}>
Submit Bug
</Button>{' '}
<Button color="secondary" onClick={toggle}>
Cancel
</Button>
</ModalFooter>
</Modal>
</div>

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

@ -50,19 +50,28 @@ class DetailsPanel extends React.Component {
componentDidMount() {
this.updateClassifications = this.updateClassifications.bind(this);
window.addEventListener(thEvents.classificationChanged, this.updateClassifications);
window.addEventListener(
thEvents.classificationChanged,
this.updateClassifications,
);
}
componentDidUpdate(prevProps) {
const { selectedJob } = this.props;
if (selectedJob && (!prevProps.selectedJob || prevProps.selectedJob !== selectedJob)) {
if (
selectedJob &&
(!prevProps.selectedJob || prevProps.selectedJob !== selectedJob)
) {
this.selectJob();
}
}
componentWillUnmount() {
window.removeEventListener(thEvents.classificationChanged, this.updateClassifications);
window.removeEventListener(
thEvents.classificationChanged,
this.updateClassifications,
);
}
togglePinBoardVisibility() {
@ -72,55 +81,57 @@ class DetailsPanel extends React.Component {
}
loadBugSuggestions() {
const { repoName, selectedJob } = this.props;
const { repoName, selectedJob } = this.props;
if (!selectedJob) {
return;
}
BugSuggestionsModel.get(selectedJob.id).then((suggestions) => {
suggestions.forEach((suggestion) => {
suggestion.bugs.too_many_open_recent = (
suggestion.bugs.open_recent.length > thBugSuggestionLimit
);
suggestion.bugs.too_many_all_others = (
suggestion.bugs.all_others.length > thBugSuggestionLimit
);
suggestion.valid_open_recent = (
suggestion.bugs.open_recent.length > 0 &&
!suggestion.bugs.too_many_open_recent
);
suggestion.valid_all_others = (
suggestion.bugs.all_others.length > 0 &&
!suggestion.bugs.too_many_all_others &&
// If we have too many open_recent bugs, we're unlikely to have
// relevant all_others bugs, so don't show them either.
!suggestion.bugs.too_many_open_recent
);
});
// if we have no bug suggestions, populate with the raw errors from
// the log (we can do this asynchronously, it should normally be
// fast)
if (!suggestions.length) {
TextLogStepModel.get(selectedJob.id).then((textLogSteps) => {
const errors = textLogSteps
.filter(step => step.result !== 'success')
.map(step => ({
name: step.name,
result: step.result,
logViewerUrl: getLogViewerUrl(selectedJob.id, repoName, step.finished_line_number),
}));
this.setState({ errors });
});
}
this.setState({ bugSuggestionsLoading: false, suggestions });
if (!selectedJob) {
return;
}
BugSuggestionsModel.get(selectedJob.id).then(suggestions => {
suggestions.forEach(suggestion => {
suggestion.bugs.too_many_open_recent =
suggestion.bugs.open_recent.length > thBugSuggestionLimit;
suggestion.bugs.too_many_all_others =
suggestion.bugs.all_others.length > thBugSuggestionLimit;
suggestion.valid_open_recent =
suggestion.bugs.open_recent.length > 0 &&
!suggestion.bugs.too_many_open_recent;
suggestion.valid_all_others =
suggestion.bugs.all_others.length > 0 &&
!suggestion.bugs.too_many_all_others &&
// If we have too many open_recent bugs, we're unlikely to have
// relevant all_others bugs, so don't show them either.
!suggestion.bugs.too_many_open_recent;
});
// if we have no bug suggestions, populate with the raw errors from
// the log (we can do this asynchronously, it should normally be
// fast)
if (!suggestions.length) {
TextLogStepModel.get(selectedJob.id).then(textLogSteps => {
const errors = textLogSteps
.filter(step => step.result !== 'success')
.map(step => ({
name: step.name,
result: step.result,
logViewerUrl: getLogViewerUrl(
selectedJob.id,
repoName,
step.finished_line_number,
),
}));
this.setState({ errors });
});
}
this.setState({ bugSuggestionsLoading: false, suggestions });
});
}
async updateClassifications() {
const { selectedJob } = this.props;
const classifications = await JobClassificationModel.getList({ job_id: selectedJob.id });
const classifications = await JobClassificationModel.getList({
job_id: selectedJob.id,
});
const bugs = await BugJobMapModel.getList({ job_id: selectedJob.id });
this.setState({ classifications, bugs });
@ -129,115 +140,193 @@ class DetailsPanel extends React.Component {
selectJob() {
const { repoName, selectedJob, getPush } = this.props;
this.setState({ jobDetails: [], suggestions: [], jobDetailLoading: true }, () => {
if (this.selectJobController !== null) {
// Cancel the in-progress fetch requests.
this.selectJobController.abort();
}
this.selectJobController = new AbortController();
let jobDetails = [];
const jobPromise = 'logs' in selectedJob ? Promise.resolve(selectedJob) : JobModel.get(
repoName, selectedJob.id,
this.selectJobController.signal);
const jobDetailPromise = JobDetailModel.getJobDetails(
{ job_guid: selectedJob.job_guid },
this.selectJobController.signal);
const jobLogUrlPromise = JobLogUrlModel.getList(
{ job_id: selectedJob.id },
this.selectJobController.signal);
const phSeriesPromise = PerfSeriesModel.getSeriesData(
repoName, { job_id: selectedJob.id });
Promise.all([
jobPromise,
jobDetailPromise,
jobLogUrlPromise,
phSeriesPromise,
]).then(async ([jobResult, jobDetailResult, jobLogUrlResult, phSeriesResult]) => {
// This version of the job has more information than what we get in the main job list. This
// is what we'll pass to the rest of the details panel. It has extra fields like
// taskcluster_metadata.
Object.assign(selectedJob, jobResult);
const jobRevision = getPush(selectedJob.push_id).revision;
jobDetails = jobDetailResult;
// incorporate the buildername into the job details if this is a buildbot job
// (i.e. it has a buildbot request id)
const buildbotRequestIdDetail = jobDetails.find(detail => detail.title === 'buildbot_request_id');
if (buildbotRequestIdDetail) {
jobDetails = [...jobDetails, { title: 'Buildername', value: selectedJob.ref_data_name }];
this.setState(
{ jobDetails: [], suggestions: [], jobDetailLoading: true },
() => {
if (this.selectJobController !== null) {
// Cancel the in-progress fetch requests.
this.selectJobController.abort();
}
// the third result comes from the jobLogUrl promise
// exclude the json log URLs
const jobLogUrls = jobLogUrlResult.filter(log => !log.name.endsWith('_json'));
this.selectJobController = new AbortController();
let logParseStatus = 'unavailable';
// Provide a parse status as a scope variable for logviewer shortcut
if (jobLogUrls.length && jobLogUrls[0].parse_status) {
logParseStatus = jobLogUrls[0].parse_status;
}
let jobDetails = [];
const jobPromise =
'logs' in selectedJob
? Promise.resolve(selectedJob)
: JobModel.get(
repoName,
selectedJob.id,
this.selectJobController.signal,
);
const logViewerUrl = getLogViewerUrl(selectedJob.id, repoName);
const logViewerFullUrl = `${window.location.origin}/${logViewerUrl}`;
const reftestUrl = jobLogUrls.length ? getReftestUrl(jobLogUrls[0].url) : '';
const performanceData = Object.values(phSeriesResult).reduce((a, b) => [...a, ...b], []);
let perfJobDetail = [];
const jobDetailPromise = JobDetailModel.getJobDetails(
{ job_guid: selectedJob.job_guid },
this.selectJobController.signal,
);
if (performanceData.length) {
const signatureIds = [...new Set(performanceData.map(perf => perf.signature_id))];
const seriesListList = await Promise.all(chunk(signatureIds, 20).map(
signatureIdChunk => PerfSeriesModel.getSeriesList(repoName, { id: signatureIdChunk }),
));
const seriesList = seriesListList.reduce((a, b) => [...a, ...b], []);
const jobLogUrlPromise = JobLogUrlModel.getList(
{ job_id: selectedJob.id },
this.selectJobController.signal,
);
perfJobDetail = performanceData.map(d => ({
series: seriesList.find(s => d.signature_id === s.id),
...d,
})).filter(d => !d.series.parentSignature).map(d => ({
url: `/perf.html#/graphs?series=${[repoName, d.signature_id, 1, d.series.frameworkId]}&selected=${[repoName, d.signature_id, selectedJob.push_id, d.id]}`,
value: d.value,
title: d.series.name,
}));
}
this.setState({
jobLogUrls,
jobDetails,
logParseStatus,
logViewerUrl,
logViewerFullUrl,
reftestUrl,
perfJobDetail,
jobRevision,
}, async () => {
await this.updateClassifications();
await this.loadBugSuggestions();
this.setState({ jobDetailLoading: false });
const phSeriesPromise = PerfSeriesModel.getSeriesData(repoName, {
job_id: selectedJob.id,
});
}).finally(() => {
this.selectJobController = null;
});
});
Promise.all([
jobPromise,
jobDetailPromise,
jobLogUrlPromise,
phSeriesPromise,
])
.then(
async ([
jobResult,
jobDetailResult,
jobLogUrlResult,
phSeriesResult,
]) => {
// This version of the job has more information than what we get in the main job list. This
// is what we'll pass to the rest of the details panel. It has extra fields like
// taskcluster_metadata.
Object.assign(selectedJob, jobResult);
const jobRevision = getPush(selectedJob.push_id).revision;
jobDetails = jobDetailResult;
// incorporate the buildername into the job details if this is a buildbot job
// (i.e. it has a buildbot request id)
const buildbotRequestIdDetail = jobDetails.find(
detail => detail.title === 'buildbot_request_id',
);
if (buildbotRequestIdDetail) {
jobDetails = [
...jobDetails,
{ title: 'Buildername', value: selectedJob.ref_data_name },
];
}
// the third result comes from the jobLogUrl promise
// exclude the json log URLs
const jobLogUrls = jobLogUrlResult.filter(
log => !log.name.endsWith('_json'),
);
let logParseStatus = 'unavailable';
// Provide a parse status as a scope variable for logviewer shortcut
if (jobLogUrls.length && jobLogUrls[0].parse_status) {
logParseStatus = jobLogUrls[0].parse_status;
}
const logViewerUrl = getLogViewerUrl(selectedJob.id, repoName);
const logViewerFullUrl = `${
window.location.origin
}/${logViewerUrl}`;
const reftestUrl = jobLogUrls.length
? getReftestUrl(jobLogUrls[0].url)
: '';
const performanceData = Object.values(phSeriesResult).reduce(
(a, b) => [...a, ...b],
[],
);
let perfJobDetail = [];
if (performanceData.length) {
const signatureIds = [
...new Set(performanceData.map(perf => perf.signature_id)),
];
const seriesListList = await Promise.all(
chunk(signatureIds, 20).map(signatureIdChunk =>
PerfSeriesModel.getSeriesList(repoName, {
id: signatureIdChunk,
}),
),
);
const seriesList = seriesListList.reduce(
(a, b) => [...a, ...b],
[],
);
perfJobDetail = performanceData
.map(d => ({
series: seriesList.find(s => d.signature_id === s.id),
...d,
}))
.filter(d => !d.series.parentSignature)
.map(d => ({
url: `/perf.html#/graphs?series=${[
repoName,
d.signature_id,
1,
d.series.frameworkId,
]}&selected=${[
repoName,
d.signature_id,
selectedJob.push_id,
d.id,
]}`,
value: d.value,
title: d.series.name,
}));
}
this.setState(
{
jobLogUrls,
jobDetails,
logParseStatus,
logViewerUrl,
logViewerFullUrl,
reftestUrl,
perfJobDetail,
jobRevision,
},
async () => {
await this.updateClassifications();
await this.loadBugSuggestions();
this.setState({ jobDetailLoading: false });
},
);
},
)
.finally(() => {
this.selectJobController = null;
});
},
);
}
render() {
const {
repoName, user, currentRepo, resizedHeight, classificationMap,
classificationTypes, isPinBoardVisible, selectedJob,
repoName,
user,
currentRepo,
resizedHeight,
classificationMap,
classificationTypes,
isPinBoardVisible,
selectedJob,
} = this.props;
const {
jobDetails, jobRevision, jobLogUrls, jobDetailLoading,
perfJobDetail, suggestions, errors, bugSuggestionsLoading, logParseStatus,
classifications, logViewerUrl, logViewerFullUrl, bugs, reftestUrl,
jobDetails,
jobRevision,
jobLogUrls,
jobDetailLoading,
perfJobDetail,
suggestions,
errors,
bugSuggestionsLoading,
logParseStatus,
classifications,
logViewerUrl,
logViewerFullUrl,
bugs,
reftestUrl,
} = this.state;
const detailsPanelHeight = isPinBoardVisible ? resizedHeight - pinboardHeight : resizedHeight;
const detailsPanelHeight = isPinBoardVisible
? resizedHeight - pinboardHeight
: resizedHeight;
return (
<div
@ -251,41 +340,47 @@ class DetailsPanel extends React.Component {
isLoggedIn={user.isLoggedIn || false}
classificationTypes={classificationTypes}
/>
{!!selectedJob && <div id="details-panel-content">
<SummaryPanel
repoName={repoName}
currentRepo={currentRepo}
classificationMap={classificationMap}
jobLogUrls={jobLogUrls}
logParseStatus={logParseStatus}
jobDetailLoading={jobDetailLoading}
latestClassification={classifications.length ? classifications[0] : null}
logViewerUrl={logViewerUrl}
logViewerFullUrl={logViewerFullUrl}
bugs={bugs}
user={user}
/>
<span className="job-tabs-divider" />
<TabsPanel
jobDetails={jobDetails}
perfJobDetail={perfJobDetail}
repoName={repoName}
jobRevision={jobRevision}
suggestions={suggestions}
errors={errors}
bugSuggestionsLoading={bugSuggestionsLoading}
logParseStatus={logParseStatus}
classifications={classifications}
classificationMap={classificationMap}
jobLogUrls={jobLogUrls}
bugs={bugs}
togglePinBoardVisibility={() => this.togglePinBoardVisibility()}
logViewerFullUrl={logViewerFullUrl}
reftestUrl={reftestUrl}
user={user}
/>
</div>}
<div id="clipboard-container"><textarea id="clipboard" /></div>
{!!selectedJob && (
<div id="details-panel-content">
<SummaryPanel
repoName={repoName}
currentRepo={currentRepo}
classificationMap={classificationMap}
jobLogUrls={jobLogUrls}
logParseStatus={logParseStatus}
jobDetailLoading={jobDetailLoading}
latestClassification={
classifications.length ? classifications[0] : null
}
logViewerUrl={logViewerUrl}
logViewerFullUrl={logViewerFullUrl}
bugs={bugs}
user={user}
/>
<span className="job-tabs-divider" />
<TabsPanel
jobDetails={jobDetails}
perfJobDetail={perfJobDetail}
repoName={repoName}
jobRevision={jobRevision}
suggestions={suggestions}
errors={errors}
bugSuggestionsLoading={bugSuggestionsLoading}
logParseStatus={logParseStatus}
classifications={classifications}
classificationMap={classificationMap}
jobLogUrls={jobLogUrls}
bugs={bugs}
togglePinBoardVisibility={() => this.togglePinBoardVisibility()}
logViewerFullUrl={logViewerFullUrl}
reftestUrl={reftestUrl}
user={user}
/>
</div>
)}
<div id="clipboard-container">
<textarea id="clipboard" />
</div>
</div>
);
}

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

@ -30,7 +30,9 @@ class PinBoard extends React.Component {
componentDidMount() {
this.bugNumberKeyPress = this.bugNumberKeyPress.bind(this);
this.save = this.save.bind(this);
this.handleRelatedBugDocumentClick = this.handleRelatedBugDocumentClick.bind(this);
this.handleRelatedBugDocumentClick = this.handleRelatedBugDocumentClick.bind(
this,
);
this.handleRelatedBugEscape = this.handleRelatedBugEscape.bind(this);
this.unPinAll = this.unPinAll.bind(this);
this.retriggerAllPinnedJobs = this.retriggerAllPinnedJobs.bind(this);
@ -62,7 +64,12 @@ class PinBoard extends React.Component {
}
save() {
const { isLoggedIn, pinnedJobs, recalculateUnclassifiedCounts, notify } = this.props;
const {
isLoggedIn,
pinnedJobs,
recalculateUnclassifiedCounts,
notify,
} = this.props;
let errorFree = true;
if (this.state.enteringBugNumber) {
@ -107,7 +114,10 @@ class PinBoard extends React.Component {
createNewClassification() {
const { email } = this.props;
const { failureClassificationId, failureClassificationComment } = this.state;
const {
failureClassificationId,
failureClassificationComment,
} = this.state;
return new JobClassificationModel({
text: failureClassificationComment,
@ -127,10 +137,18 @@ class PinBoard extends React.Component {
recalculateUnclassifiedCounts();
classification.job_id = job.id;
return classification.create().then(() => {
notify(`Classification saved for ${job.platform} ${job.job_type_name}`, 'success');
}).catch((response) => {
const message = `Error saving classification for ${job.platform} ${job.job_type_name}`;
return classification
.create()
.then(() => {
notify(
`Classification saved for ${job.platform} ${job.job_type_name}`,
'success',
);
})
.catch(response => {
const message = `Error saving classification for ${job.platform} ${
job.job_type_name
}`;
notify(formatModelError(response, message), 'danger');
});
}
@ -139,21 +157,27 @@ class PinBoard extends React.Component {
saveBugs(job) {
const { pinnedJobBugs, notify } = this.props;
Object.values(pinnedJobBugs).forEach((bug) => {
Object.values(pinnedJobBugs).forEach(bug => {
const bjm = new BugJobMapModel({
bug_id: bug.id,
job_id: job.id,
type: 'annotation',
});
bjm.create()
bjm
.create()
.then(() => {
notify(`Bug association saved for ${job.platform} ${job.job_type_name}`, 'success');
notify(
`Bug association saved for ${job.platform} ${job.job_type_name}`,
'success',
);
})
.catch((response) => {
const message = `Error saving bug association for ${job.platform} ${job.job_type_name}`;
.catch(response => {
const message = `Error saving bug association for ${job.platform} ${
job.job_type_name
}`;
notify(formatModelError(response, message), 'danger');
});
});
});
}
@ -182,7 +206,8 @@ class PinBoard extends React.Component {
canCancelAllPinnedJobs() {
const cancellableJobs = Object.values(this.props.pinnedJobs).filter(
job => (job.state === 'pending' || job.state === 'running'));
job => job.state === 'pending' || job.state === 'running',
);
return this.props.isLoggedIn && cancellableJobs.length > 0;
}
@ -190,7 +215,9 @@ class PinBoard extends React.Component {
cancelAllPinnedJobs() {
const { getGeckoDecisionTaskId, notify, repoName } = this.props;
if (window.confirm('This will cancel all the selected jobs. Are you sure?')) {
if (
window.confirm('This will cancel all the selected jobs. Are you sure?')
) {
const jobIds = Object.keys(this.props.pinnedJobs);
JobModel.cancel(jobIds, repoName, getGeckoDecisionTaskId, notify);
@ -200,24 +227,37 @@ class PinBoard extends React.Component {
canSaveClassifications() {
const { pinnedJobBugs, isLoggedIn, currentRepo } = this.props;
const { failureClassificationId, failureClassificationComment } = this.state;
const {
failureClassificationId,
failureClassificationComment,
} = this.state;
return this.hasPinnedJobs() && isLoggedIn &&
return (
this.hasPinnedJobs() &&
isLoggedIn &&
(!!Object.keys(pinnedJobBugs).length ||
(failureClassificationId !== 4 && failureClassificationId !== 2) ||
currentRepo.is_try_repo ||
currentRepo.repository_group.name === 'project repositories' ||
(failureClassificationId === 4 && failureClassificationComment.length > 0) ||
(failureClassificationId === 2 && failureClassificationComment.length > 7));
(failureClassificationId === 4 &&
failureClassificationComment.length > 0) ||
(failureClassificationId === 2 &&
failureClassificationComment.length > 7))
);
}
// Facilitates Clear all if no jobs pinned to reset pinBoard UI
pinboardIsDirty() {
const { failureClassificationId, failureClassificationComment } = this.state;
const {
failureClassificationId,
failureClassificationComment,
} = this.state;
return failureClassificationComment !== '' ||
return (
failureClassificationComment !== '' ||
!!Object.keys(this.props.pinnedJobBugs).length ||
failureClassificationId !== 4;
failureClassificationId !== 4
);
}
// Dynamic btn/anchor title for classification save
@ -274,23 +314,32 @@ class PinBoard extends React.Component {
}
toggleEnterBugNumber(tf) {
this.setState({
enteringBugNumber: tf,
}, () => {
if (tf) {
document.getElementById('related-bug-input').focus();
// Bind escape to canceling the bug entry.
document.addEventListener('keydown', this.handleRelatedBugEscape);
// Install a click handler on the document so that clicking
// outside of the input field will close it. A blur handler
// can't be used because it would have timing issues with the
// click handler on the + icon.
document.addEventListener('click', this.handleRelatedBugDocumentClick);
} else {
document.removeEventListener('keydown', this.handleRelatedBugEscape);
document.removeEventListener('click', this.handleRelatedBugDocumentClick);
}
});
this.setState(
{
enteringBugNumber: tf,
},
() => {
if (tf) {
document.getElementById('related-bug-input').focus();
// Bind escape to canceling the bug entry.
document.addEventListener('keydown', this.handleRelatedBugEscape);
// Install a click handler on the document so that clicking
// outside of the input field will close it. A blur handler
// can't be used because it would have timing issues with the
// click handler on the + icon.
document.addEventListener(
'click',
this.handleRelatedBugDocumentClick,
);
} else {
document.removeEventListener('keydown', this.handleRelatedBugEscape);
document.removeEventListener(
'click',
this.handleRelatedBugDocumentClick,
);
}
},
);
}
isNumber(text) {
@ -335,39 +384,60 @@ class PinBoard extends React.Component {
render() {
const {
selectedJob, revisionTips, isLoggedIn, isPinBoardVisible, classificationTypes,
pinnedJobs, pinnedJobBugs, removeBug, unPinJob, setSelectedJob,
selectedJob,
revisionTips,
isLoggedIn,
isPinBoardVisible,
classificationTypes,
pinnedJobs,
pinnedJobBugs,
removeBug,
unPinJob,
setSelectedJob,
} = this.props;
const {
failureClassificationId, failureClassificationComment,
enteringBugNumber, newBugNumber,
failureClassificationId,
failureClassificationComment,
enteringBugNumber,
newBugNumber,
} = this.state;
const selectedJobId = selectedJob ? selectedJob.id : null;
return (
<div
id="pinboard-panel"
className={isPinBoardVisible ? '' : 'hidden'}
>
<div id="pinboard-panel" className={isPinBoardVisible ? '' : 'hidden'}>
<div id="pinboard-contents">
<div id="pinned-job-list">
<div className="content">
{!this.hasPinnedJobs() && <span
className="pinboard-preload-txt"
>press spacebar to pin a selected job</span>}
{!this.hasPinnedJobs() && (
<span className="pinboard-preload-txt">
press spacebar to pin a selected job
</span>
)}
{Object.values(pinnedJobs).map(job => (
<span className="btn-group" key={job.id}>
<span
className={`btn pinned-job ${getJobBtnClass(job)} ${selectedJobId === job.id ? 'btn-lg selected-job' : 'btn-xs'}`}
className={`btn pinned-job ${getJobBtnClass(job)} ${
selectedJobId === job.id
? 'btn-lg selected-job'
: 'btn-xs'
}`}
title={getHoverText(job)}
onClick={() => setSelectedJob(job)}
data-job-id={job.job_id}
>{job.job_type_symbol}</span>
>
{job.job_type_symbol}
</span>
<span
className={`btn btn-ltgray pinned-job-close-btn ${selectedJobId === job.id ? 'btn-lg selected-job' : 'btn-xs'}`}
className={`btn btn-ltgray pinned-job-close-btn ${
selectedJobId === job.id
? 'btn-lg selected-job'
: 'btn-xs'
}`}
onClick={() => unPinJob(job)}
title="un-pin this job"
><i className="fa fa-times" /></span>
>
<i className="fa fa-times" />
</span>
</span>
))}
</div>
@ -381,45 +451,59 @@ class PinBoard extends React.Component {
onClick={() => this.toggleEnterBugNumber(!enteringBugNumber)}
className="pointable"
title="Add a related bug"
><i className="fa fa-plus-square add-related-bugs-icon" /></span>
{!this.hasPinnedJobBugs() && <span
className="pinboard-preload-txt pinboard-related-bug-preload-txt"
onClick={() => {
this.toggleEnterBugNumber(!enteringBugNumber);
}}
>click to add a related bug</span>}
{enteringBugNumber && <span
className="add-related-bugs-form"
>
<Input
id="related-bug-input"
data-bug-input
type="text"
pattern="[0-9]*"
className="add-related-bugs-input"
placeholder="enter bug number"
invalid={!this.isNumber(newBugNumber)}
onKeyPress={this.bugNumberKeyPress}
onChange={ev => this.setState({ newBugNumber: ev.target.value })}
/>
<FormFeedback>Please enter only numbers</FormFeedback>
</span>}
{Object.values(pinnedJobBugs).map(bug => (<span key={bug.id}>
<span className="btn-group pinboard-related-bugs-btn">
<a
className="btn btn-xs related-bugs-link"
title={bug.summary}
href={getBugUrl(bug.id)}
target="_blank"
rel="noopener noreferrer"
><em>{bug.id}</em></a>
<span
className="btn btn-ltgray btn-xs pinned-job-close-btn"
onClick={() => removeBug(bug.id)}
title="remove this bug"
><i className="fa fa-times" /></span>
<i className="fa fa-plus-square add-related-bugs-icon" />
</span>
{!this.hasPinnedJobBugs() && (
<span
className="pinboard-preload-txt pinboard-related-bug-preload-txt"
onClick={() => {
this.toggleEnterBugNumber(!enteringBugNumber);
}}
>
click to add a related bug
</span>
</span>))}
)}
{enteringBugNumber && (
<span className="add-related-bugs-form">
<Input
id="related-bug-input"
data-bug-input
type="text"
pattern="[0-9]*"
className="add-related-bugs-input"
placeholder="enter bug number"
invalid={!this.isNumber(newBugNumber)}
onKeyPress={this.bugNumberKeyPress}
onChange={ev =>
this.setState({ newBugNumber: ev.target.value })
}
/>
<FormFeedback>Please enter only numbers</FormFeedback>
</span>
)}
{Object.values(pinnedJobBugs).map(bug => (
<span key={bug.id}>
<span className="btn-group pinboard-related-bugs-btn">
<a
className="btn btn-xs related-bugs-link"
title={bug.summary}
href={getBugUrl(bug.id)}
target="_blank"
rel="noopener noreferrer"
>
<em>{bug.id}</em>
</a>
<span
className="btn btn-ltgray btn-xs pinned-job-close-btn"
onClick={() => removeBug(bug.id)}
title="remove this bug"
>
<i className="fa fa-times" />
</span>
</span>
</span>
))}
</div>
</div>
@ -437,7 +521,9 @@ class PinBoard extends React.Component {
onChange={evt => this.setClassificationId(evt)}
>
{classificationTypes.map(opt => (
<option value={opt.id} key={opt.id}>{opt.name}</option>
<option value={opt.id} key={opt.id}>
{opt.name}
</option>
))}
</Input>
</FormGroup>
@ -452,26 +538,32 @@ class PinBoard extends React.Component {
placeholder="click to add comment"
value={failureClassificationComment}
/>
{failureClassificationId === 2 && <div>
<FormGroup>
<Input
id="pinboard-revision-select"
className="classification-select"
type="select"
defaultValue={0}
onChange={evt => this.setClassificationText(evt)}
>
<option value="0" disabled>Choose a recent
commit
</option>
{revisionTips.slice(0, 20).map(tip => (<option
title={tip.title}
value={tip.revision}
key={tip.revision}
>{tip.revision.slice(0, 12)} {tip.author}</option>))}
</Input>
</FormGroup>
</div>}
{failureClassificationId === 2 && (
<div>
<FormGroup>
<Input
id="pinboard-revision-select"
className="classification-select"
type="select"
defaultValue={0}
onChange={evt => this.setClassificationText(evt)}
>
<option value="0" disabled>
Choose a recent commit
</option>
{revisionTips.slice(0, 20).map(tip => (
<option
title={tip.title}
value={tip.revision}
key={tip.revision}
>
{tip.revision.slice(0, 12)} {tip.author}
</option>
))}
</Input>
</FormGroup>
</div>
)}
</div>
</div>
</div>
@ -484,14 +576,27 @@ class PinBoard extends React.Component {
>
<div className="btn-group save-btn-group dropdown">
<button
className={`btn btn-light-bordered btn-xs save-btn ${!isLoggedIn || !this.canSaveClassifications() ? 'disabled' : ''}`}
className={`btn btn-light-bordered btn-xs save-btn ${
!isLoggedIn || !this.canSaveClassifications()
? 'disabled'
: ''
}`}
title={this.saveUITitle('classification')}
onClick={this.save}
>save
>
save
</button>
<button
className={`btn btn-light-bordered btn-xs dropdown-toggle save-btn-dropdown ${!this.hasPinnedJobs() && !this.pinboardIsDirty() ? 'disabled' : ''}`}
title={!this.hasPinnedJobs() && !this.pinboardIsDirty() ? 'No pinned jobs' : 'Additional pinboard functions'}
className={`btn btn-light-bordered btn-xs dropdown-toggle save-btn-dropdown ${
!this.hasPinnedJobs() && !this.pinboardIsDirty()
? 'disabled'
: ''
}`}
title={
!this.hasPinnedJobs() && !this.pinboardIsDirty()
? 'No pinned jobs'
: 'Additional pinboard functions'
}
type="button"
data-toggle="dropdown"
>
@ -500,23 +605,36 @@ class PinBoard extends React.Component {
<ul className="dropdown-menu save-btn-dropdown-menu">
<li
className={!isLoggedIn ? 'disabled' : ''}
title={!isLoggedIn ? 'Not logged in' : 'Repeat the pinned jobs'}
title={
!isLoggedIn ? 'Not logged in' : 'Repeat the pinned jobs'
}
>
<a
className="dropdown-item"
onClick={() => !isLoggedIn || this.retriggerAllPinnedJobs()}
>Retrigger all</a></li>
>
Retrigger all
</a>
</li>
<li
className={this.canCancelAllPinnedJobs() ? '' : 'disabled'}
title={this.cancelAllPinnedJobsTitle()}
>
<a
className="dropdown-item"
onClick={() => this.canCancelAllPinnedJobs() && this.cancelAllPinnedJobs()}
>Cancel all</a>
onClick={() =>
this.canCancelAllPinnedJobs() &&
this.cancelAllPinnedJobs()
}
>
Cancel all
</a>
</li>
<li>
<a className="dropdown-item" onClick={() => this.unPinAll()}>
Clear all
</a>
</li>
<li><a className="dropdown-item" onClick={() => this.unPinAll()}>Clear
all</a></li>
</ul>
</div>
</div>
@ -553,4 +671,6 @@ PinBoard.defaultProps = {
revisionTips: [],
};
export default withNotifications(withPushes(withSelectedJob(withPinnedJobs(PinBoard))));
export default withNotifications(
withPushes(withSelectedJob(withPinnedJobs(PinBoard))),
);

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

@ -52,11 +52,14 @@ class ActionBar extends React.PureComponent {
switch (logParseStatus) {
case 'pending':
notify('Log parsing in progress, log viewer not yet available', 'info'); break;
notify('Log parsing in progress, log viewer not yet available', 'info');
break;
case 'failed':
notify('Log parsing has failed, log viewer is unavailable', 'warning'); break;
notify('Log parsing has failed, log viewer is unavailable', 'warning');
break;
case 'unavailable':
notify('No logs available for this job', 'info'); break;
notify('No logs available for this job', 'info');
break;
case 'parsed':
$('.logviewer-btn')[0].click();
}
@ -73,13 +76,19 @@ class ActionBar extends React.PureComponent {
return notify('Must be logged in to create a gecko profile', 'danger');
}
getGeckoDecisionTaskId(
selectedJob.push_id).then(decisionTaskId => (
TaskclusterModel.load(decisionTaskId, selectedJob).then((results) => {
const geckoprofile = results.actions.find(result => result.name === 'geckoprofile');
getGeckoDecisionTaskId(selectedJob.push_id).then(decisionTaskId =>
TaskclusterModel.load(decisionTaskId, selectedJob).then(results => {
const geckoprofile = results.actions.find(
result => result.name === 'geckoprofile',
);
if (geckoprofile === undefined || !Object.prototype.hasOwnProperty.call(geckoprofile, 'kind')) {
return notify('Job was scheduled without taskcluster support for GeckoProfiles');
if (
geckoprofile === undefined ||
!Object.prototype.hasOwnProperty.call(geckoprofile, 'kind')
) {
return notify(
'Job was scheduled without taskcluster support for GeckoProfiles',
);
}
TaskclusterModel.submit({
@ -89,20 +98,21 @@ class ActionBar extends React.PureComponent {
task: results.originalTask,
input: {},
staticActionVariables: results.staticActionVariables,
}).then(() => {
notify(
'Request sent to collect gecko profile job via actions.json',
'success');
}, (e) => {
// The full message is too large to fit in a Treeherder
// notification box.
notify(
formatTaskclusterError(e),
'danger',
{ sticky: true });
});
})
));
}).then(
() => {
notify(
'Request sent to collect gecko profile job via actions.json',
'success',
);
},
e => {
// The full message is too large to fit in a Treeherder
// notification box.
notify(formatTaskclusterError(e), 'danger', { sticky: true });
},
);
}),
);
}
retriggerJob(jobs) {
@ -143,11 +153,15 @@ class ActionBar extends React.PureComponent {
return;
}
if (selectedJob.build_system_type === 'taskcluster' || selectedJob.reason.startsWith('Created by BBB for task')) {
getGeckoDecisionTaskId(
selectedJob.push_id).then(decisionTaskId => (
TaskclusterModel.load(decisionTaskId, selectedJob).then((results) => {
const backfilltask = results.actions.find(result => result.name === 'backfill');
if (
selectedJob.build_system_type === 'taskcluster' ||
selectedJob.reason.startsWith('Created by BBB for task')
) {
getGeckoDecisionTaskId(selectedJob.push_id).then(decisionTaskId =>
TaskclusterModel.load(decisionTaskId, selectedJob).then(results => {
const backfilltask = results.actions.find(
result => result.name === 'backfill',
);
return TaskclusterModel.submit({
action: backfilltask,
@ -155,15 +169,21 @@ class ActionBar extends React.PureComponent {
taskId: results.originalTaskId,
input: {},
staticActionVariables: results.staticActionVariables,
}).then(() => {
notify('Request sent to backfill job via actions.json', 'success');
}, (e) => {
// The full message is too large to fit in a Treeherder
// notification box.
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
})
));
}).then(
() => {
notify(
'Request sent to backfill job via actions.json',
'success',
);
},
e => {
// The full message is too large to fit in a Treeherder
// notification box.
notify(formatTaskclusterError(e), 'danger', { sticky: true });
},
);
}),
);
} else {
notify('Unable to backfill this job type!', 'danger', { sticky: true });
}
@ -189,7 +209,8 @@ class ActionBar extends React.PureComponent {
}
if (title === '') {
title = 'Trigger jobs of ths type on prior pushes ' +
title =
'Trigger jobs of ths type on prior pushes ' +
'to fill in gaps where the job was not run';
} else {
// Cut off trailing '/ ' if one exists, capitalize first letter
@ -200,17 +221,28 @@ class ActionBar extends React.PureComponent {
}
async createInteractiveTask() {
const { user, selectedJob, repoName, getGeckoDecisionTaskId, notify } = this.props;
const {
user,
selectedJob,
repoName,
getGeckoDecisionTaskId,
notify,
} = this.props;
const jobId = selectedJob.id;
if (!user.isLoggedIn) {
return notify('Must be logged in to create an interactive task', 'danger');
return notify(
'Must be logged in to create an interactive task',
'danger',
);
}
const job = await JobModel.get(repoName, jobId);
const decisionTaskId = await getGeckoDecisionTaskId(job.push_id);
const results = await TaskclusterModel.load(decisionTaskId, job);
const interactiveTask = results.actions.find(result => result.name === 'create-interactive');
const interactiveTask = results.actions.find(
result => result.name === 'create-interactive',
);
try {
await TaskclusterModel.submit({
@ -226,7 +258,8 @@ class ActionBar extends React.PureComponent {
notify(
`Request sent to create an interactive job via actions.json.
You will soon receive an email containing a link to interact with the task.`,
'success');
'success',
);
} catch (e) {
// The full message is too large to fit in a Treeherder
// notification box.
@ -236,7 +269,9 @@ class ActionBar extends React.PureComponent {
cancelJobs(jobs) {
const { user, repoName, getGeckoDecisionTaskId, notify } = this.props;
const jobIds = jobs.filter(({ state }) => state === 'pending' || state === 'running').map(({ id }) => id);
const jobIds = jobs
.filter(({ state }) => state === 'pending' || state === 'running')
.map(({ id }) => id);
if (!user.isLoggedIn) {
return notify('Must be logged in to cancel a job', 'danger');
@ -256,14 +291,20 @@ class ActionBar extends React.PureComponent {
}
render() {
const { selectedJob, logViewerUrl, logViewerFullUrl, jobLogUrls, user, pinJob } = this.props;
const {
selectedJob,
logViewerUrl,
logViewerFullUrl,
jobLogUrls,
user,
pinJob,
} = this.props;
const { customJobActionsShowing } = this.state;
return (
<div id="job-details-actionbar">
<nav className="navbar navbar-dark details-panel-navbar">
<ul className="nav navbar-nav actionbar-nav">
<LogUrls
logUrls={jobLogUrls}
logViewerUrl={logViewerUrl}
@ -275,32 +316,53 @@ class ActionBar extends React.PureComponent {
title="Add this job to the pinboard"
className="btn icon-blue"
onClick={() => pinJob(selectedJob)}
><span className="fa fa-thumb-tack" /></span>
>
<span className="fa fa-thumb-tack" />
</span>
</li>
<li>
<span
id="retrigger-btn"
title={user.isLoggedIn ? 'Repeat the selected job' : 'Must be logged in to retrigger a job'}
title={
user.isLoggedIn
? 'Repeat the selected job'
: 'Must be logged in to retrigger a job'
}
className={`btn ${user.isLoggedIn ? 'icon-green' : 'disabled'}`}
disabled={!user.isLoggedIn}
onClick={() => this.retriggerJob([selectedJob])}
><span className="fa fa-repeat" /></span>
>
<span className="fa fa-repeat" />
</span>
</li>
{isReftest(selectedJob) && jobLogUrls.map(jobLogUrl => (<li key={`reftest-${jobLogUrl.id}`}>
<a
title="Launch the Reftest Analyser in a new window"
target="_blank"
rel="noopener noreferrer"
href={getReftestUrl(jobLogUrl.url)}
><span className="fa fa-bar-chart-o" /></a>
</li>))}
{this.canCancel() && <li>
<a
title={user.isLoggedIn ? 'Cancel this job' : 'Must be logged in to cancel a job'}
className={user.isLoggedIn ? 'hover-warning' : 'disabled'}
onClick={() => this.cancelJob()}
><span className="fa fa-times-circle cancel-job-icon" /></a>
</li>}
{isReftest(selectedJob) &&
jobLogUrls.map(jobLogUrl => (
<li key={`reftest-${jobLogUrl.id}`}>
<a
title="Launch the Reftest Analyser in a new window"
target="_blank"
rel="noopener noreferrer"
href={getReftestUrl(jobLogUrl.url)}
>
<span className="fa fa-bar-chart-o" />
</a>
</li>
))}
{this.canCancel() && (
<li>
<a
title={
user.isLoggedIn
? 'Cancel this job'
: 'Must be logged in to cancel a job'
}
className={user.isLoggedIn ? 'hover-warning' : 'disabled'}
onClick={() => this.cancelJob()}
>
<span className="fa fa-times-circle cancel-job-icon" />
</a>
</li>
)}
</ul>
<ul className="nav navbar-right">
<li className="dropdown">
@ -311,54 +373,76 @@ class ActionBar extends React.PureComponent {
aria-expanded="false"
className="dropdown-toggle"
data-toggle="dropdown"
><span className="fa fa-ellipsis-h" aria-hidden="true" /></span>
>
<span className="fa fa-ellipsis-h" aria-hidden="true" />
</span>
<ul className="dropdown-menu actionbar-menu" role="menu">
<li>
<span
id="backfill-btn"
className={`btn dropdown-item ${!user.isLoggedIn || !this.canBackfill() ? 'disabled' : ''}`}
className={`btn dropdown-item ${
!user.isLoggedIn || !this.canBackfill() ? 'disabled' : ''
}`}
title={this.backfillButtonTitle()}
onClick={() => !this.canBackfill() || this.backfillJob()}
>Backfill</span>
>
Backfill
</span>
</li>
{selectedJob.taskcluster_metadata && <React.Fragment>
<li>
<a
target="_blank"
rel="noopener noreferrer"
className="dropdown-item"
href={getInspectTaskUrl(selectedJob.taskcluster_metadata.task_id)}
>Inspect Task</a>
</li>
<li>
<a
className="dropdown-item"
onClick={this.createInteractiveTask}
>Create Interactive Task</a>
</li>
{isPerfTest(selectedJob) && <li>
<a
className="dropdown-item"
onClick={this.createGeckoProfile}
>Create Gecko Profile</a>
</li>}
<li>
<a
onClick={this.toggleCustomJobActions}
className="dropdown-item"
>Custom Action...</a>
</li>
</React.Fragment>}
{selectedJob.taskcluster_metadata && (
<React.Fragment>
<li>
<a
target="_blank"
rel="noopener noreferrer"
className="dropdown-item"
href={getInspectTaskUrl(
selectedJob.taskcluster_metadata.task_id,
)}
>
Inspect Task
</a>
</li>
<li>
<a
className="dropdown-item"
onClick={this.createInteractiveTask}
>
Create Interactive Task
</a>
</li>
{isPerfTest(selectedJob) && (
<li>
<a
className="dropdown-item"
onClick={this.createGeckoProfile}
>
Create Gecko Profile
</a>
</li>
)}
<li>
<a
onClick={this.toggleCustomJobActions}
className="dropdown-item"
>
Custom Action...
</a>
</li>
</React.Fragment>
)}
</ul>
</li>
</ul>
</nav>
{customJobActionsShowing && <CustomJobActions
job={selectedJob}
pushId={selectedJob.push_id}
isLoggedIn={user.isLoggedIn}
toggle={this.toggleCustomJobActions}
/>}
{customJobActionsShowing && (
<CustomJobActions
job={selectedJob}
pushId={selectedJob.push_id}
isLoggedIn={user.isLoggedIn}
toggle={this.toggleCustomJobActions}
/>
)}
</div>
);
}
@ -385,4 +469,6 @@ ActionBar.defaultProps = {
jobLogUrls: [],
};
export default withNotifications(withPushes(withSelectedJob(withPinnedJobs(ActionBar))));
export default withNotifications(
withPushes(withSelectedJob(withPinnedJobs(ActionBar))),
);

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

@ -6,12 +6,12 @@ import RevisionLinkify from '../../../shared/RevisionLinkify';
import { longDateFormat } from '../../../helpers/display';
export default function ClassificationsPanel(props) {
const {
classification, job, bugs, currentRepo, classificationMap,
} = props;
const { classification, job, bugs, currentRepo, classificationMap } = props;
const failureId = classification.failure_classification_id;
const iconClass = `${(failureId === 7 ? 'fa-star-o' : 'fa fa-star')} star-${job.result}`;
const iconClass = `${failureId === 7 ? 'fa-star-o' : 'fa fa-star'} star-${
job.result
}`;
const classificationName = classificationMap[failureId];
return (
@ -21,23 +21,33 @@ export default function ClassificationsPanel(props) {
<i className={`fa ${iconClass}`} />
<span className="ml-1">{classificationName.name}</span>
</span>
{!!bugs.length &&
{!!bugs.length && (
<a
target="_blank"
rel="noopener noreferrer"
href={getBugUrl(bugs[0].bug_id)}
title={`View bug ${bugs[0].bug_id}`}
><em> {bugs[0].bug_id}</em></a>}
>
<em> {bugs[0].bug_id}</em>
</a>
)}
</li>
{classification.text.length > 0 &&
<li><em><RevisionLinkify repo={currentRepo}>{classification.text}</RevisionLinkify></em></li>
}
{classification.text.length > 0 && (
<li>
<em>
<RevisionLinkify repo={currentRepo}>
{classification.text}
</RevisionLinkify>
</em>
</li>
)}
<li className="revision-comment">
{new Date(classification.created).toLocaleString('en-US', longDateFormat)}
</li>
<li className="revision-comment">
{classification.who}
{new Date(classification.created).toLocaleString(
'en-US',
longDateFormat,
)}
</li>
<li className="revision-comment">{classification.who}</li>
</React.Fragment>
);
}

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

@ -31,48 +31,60 @@ export default function LogUrls(props) {
return (
<React.Fragment>
{logUrls.map(jobLogUrl => (<li key={`logview-${jobLogUrl.id}`}>
<a
className="logviewer-btn"
{...getLogUrlProps(jobLogUrl, logViewerUrl, logViewerFullUrl)}
>
<img
alt="Logviewer"
src={logviewerIcon}
className="logviewer-icon"
/>
</a>
</li>))}
{logUrls.map(jobLogUrl => (
<li key={`logview-${jobLogUrl.id}`}>
<a
className="logviewer-btn"
{...getLogUrlProps(jobLogUrl, logViewerUrl, logViewerFullUrl)}
>
<img
alt="Logviewer"
src={logviewerIcon}
className="logviewer-icon"
/>
</a>
</li>
))}
<li>
{!logUrls.length && <a
className="logviewer-btn disabled"
title="No logs available for this job"
>
<img
alt="Logviewer"
src={logviewerIcon}
className="logviewer-icon"
/>
</a>}
{!logUrls.length && (
<a
className="logviewer-btn disabled"
title="No logs available for this job"
>
<img
alt="Logviewer"
src={logviewerIcon}
className="logviewer-icon"
/>
</a>
)}
</li>
{logUrls.map(jobLogUrl => (<li key={`raw-${jobLogUrl.id}`}>
<a
id="raw-log-btn"
className="raw-log-icon"
title="Open the raw log in a new window"
target="_blank"
rel="noopener noreferrer"
href={jobLogUrl.url}
copy-value={jobLogUrl.url}
><span className="fa fa-file-text-o" /></a>
</li>))}
{!logUrls.length && <li>
<a
className="disabled raw-log-icon"
title="No logs available for this job"
><span className="fa fa-file-text-o" /></a>
</li>}
{logUrls.map(jobLogUrl => (
<li key={`raw-${jobLogUrl.id}`}>
<a
id="raw-log-btn"
className="raw-log-icon"
title="Open the raw log in a new window"
target="_blank"
rel="noopener noreferrer"
href={jobLogUrl.url}
copy-value={jobLogUrl.url}
>
<span className="fa fa-file-text-o" />
</a>
</li>
))}
{!logUrls.length && (
<li>
<a
className="disabled raw-log-icon"
title="No logs available for this job"
>
<span className="fa fa-file-text-o" />
</a>
</li>
)}
</React.Fragment>
);
}

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

@ -9,10 +9,7 @@ function StatusPanel(props) {
const shadingClass = `result-status-shading-${getStatus(selectedJob)}`;
return (
<li
id="result-status-pane"
className={`small ${shadingClass}`}
>
<li id="result-status-pane" className={`small ${shadingClass}`}>
<div>
<label>Result:</label>
<span> {selectedJob.result}</span>

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

@ -11,15 +11,28 @@ import StatusPanel from './StatusPanel';
class SummaryPanel extends React.PureComponent {
render() {
const {
repoName, selectedJob, latestClassification, bugs, jobLogUrls,
jobDetailLoading, logViewerUrl, logViewerFullUrl,
logParseStatus, user, currentRepo, classificationMap,
repoName,
selectedJob,
latestClassification,
bugs,
jobLogUrls,
jobDetailLoading,
logViewerUrl,
logViewerFullUrl,
logParseStatus,
user,
currentRepo,
classificationMap,
} = this.props;
const logStatus = [{
title: 'Log parsing status',
value: !jobLogUrls.length ? 'No logs' : jobLogUrls.map(log => log.parse_status).join(', '),
}];
const logStatus = [
{
title: 'Log parsing status',
value: !jobLogUrls.length
? 'No logs'
: jobLogUrls.map(log => log.parse_status).join(', '),
},
];
return (
<div id="summary-panel">
@ -34,28 +47,26 @@ class SummaryPanel extends React.PureComponent {
/>
<div id="summary-panel-content">
<div>
{jobDetailLoading &&
{jobDetailLoading && (
<div className="overlay">
<div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
</div>
</div>
}
)}
<ul className="list-unstyled">
{latestClassification &&
{latestClassification && (
<ClassificationsPanel
job={selectedJob}
classification={latestClassification}
classificationMap={classificationMap}
bugs={bugs}
currentRepo={currentRepo}
/>}
/>
)}
<StatusPanel />
<JobInfo
job={selectedJob}
extraFields={logStatus}
/>
<JobInfo job={selectedJob} extraFields={logStatus} />
</ul>
</div>
</div>

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

@ -48,11 +48,9 @@ function RelatedBug(props) {
<ul className="annotations-bug-list">
{bugs.map(bug => (
<li key={bug.bug_id}>
<RelatedBugSaved
bug={bug}
deleteBug={() => deleteBug(bug)}
/>
</li>))}
<RelatedBugSaved bug={bug} deleteBug={() => deleteBug(bug)} />
</li>
))}
</ul>
</span>
);
@ -66,7 +64,9 @@ RelatedBug.propTypes = {
function TableRow(props) {
const { deleteClassification, classification, classificationMap } = props;
const { created, who, name, text } = classification;
const deleteEvent = () => { deleteClassification(classification); };
const deleteEvent = () => {
deleteClassification(classification);
};
const failureId = classification.failure_classification_id;
const iconClass = failureId === 7 ? 'fa-star-o' : 'fa fa-star';
const classificationName = classificationMap[failureId];
@ -104,9 +104,7 @@ TableRow.propTypes = {
};
function AnnotationsTable(props) {
const {
classifications, deleteClassification, classificationMap,
} = props;
const { classifications, deleteClassification, classificationMap } = props;
return (
<table className="table-super-condensed table-hover">
@ -125,8 +123,8 @@ function AnnotationsTable(props) {
classification={classification}
deleteClassification={deleteClassification}
classificationMap={classificationMap}
/>))
}
/>
))}
</tbody>
</table>
);
@ -148,11 +146,17 @@ class AnnotationsTab extends React.Component {
}
componentDidMount() {
window.addEventListener(thEvents.deleteClassification, this.onDeleteClassification);
window.addEventListener(
thEvents.deleteClassification,
this.onDeleteClassification,
);
}
componentWillUnmount() {
window.removeEventListener(thEvents.deleteClassification, this.onDeleteClassification);
window.removeEventListener(
thEvents.deleteClassification,
this.onDeleteClassification,
);
}
onDeleteClassification() {
@ -161,7 +165,9 @@ class AnnotationsTab extends React.Component {
if (classifications.length) {
this.deleteClassification(classifications[0]);
// Delete any number of bugs if they exist
bugs.forEach((bug) => { this.deleteBug(bug); });
bugs.forEach(bug => {
this.deleteBug(bug);
});
} else {
notify('No classification on this job to delete', 'warning');
}
@ -182,19 +188,27 @@ class AnnotationsTab extends React.Component {
},
() => {
notify('Classification deletion failed', 'danger', { sticky: true });
});
},
);
}
deleteBug(bug) {
const { notify } = this.props;
bug.destroy()
.then(() => {
notify(`Association to bug ${bug.bug_id} successfully deleted`, 'success');
bug.destroy().then(
() => {
notify(
`Association to bug ${bug.bug_id} successfully deleted`,
'success',
);
window.dispatchEvent(new CustomEvent(thEvents.classificationChanged));
}, () => {
notify(`Association to bug ${bug.bug_id} deletion failed`, 'danger', { sticky: true });
});
},
() => {
notify(`Association to bug ${bug.bug_id} deletion failed`, 'danger', {
sticky: true,
});
},
);
}
render() {
@ -204,23 +218,22 @@ class AnnotationsTab extends React.Component {
<div className="container-fluid">
<div className="row h-100">
<div className="col-sm-10 classifications-pane">
{classifications.length ?
{classifications.length ? (
<AnnotationsTable
classifications={classifications}
deleteClassification={this.deleteClassification}
classificationMap={classificationMap}
/> :
/>
) : (
<p>This job has not been classified</p>
}
)}
</div>
{!!classifications.length && !!bugs.length &&
<div className="col-sm-2 bug-list-pane">
<RelatedBug
bugs={bugs}
deleteBug={this.deleteBug}
/>
</div>}
{!!classifications.length && !!bugs.length && (
<div className="col-sm-2 bug-list-pane">
<RelatedBug bugs={bugs} deleteBug={this.deleteBug} />
</div>
)}
</div>
</div>
);

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

@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { getCompareChooserUrl } from '../../../helpers/url';
export default class PerformanceTab extends React.PureComponent {
render() {
const { repoName, revision, perfJobDetail } = this.props;
const sortedDetails = perfJobDetail ? perfJobDetail.slice() : [];
@ -13,26 +12,34 @@ export default class PerformanceTab extends React.PureComponent {
return (
<div className="performance-panel">
{!!sortedDetails.length && <ul>
<li>Perfherder:
{sortedDetails.map((detail, idx) => (
<ul
key={idx} // eslint-disable-line react/no-array-index-key
>
<li>{detail.title}:
<a href={detail.url}> {detail.value}</a>
</li>
</ul>
))}
</li>
</ul>}
{!!sortedDetails.length && (
<ul>
<li>
Perfherder:
{sortedDetails.map((detail, idx) => (
<ul
key={idx} // eslint-disable-line react/no-array-index-key
>
<li>
{detail.title}:<a href={detail.url}> {detail.value}</a>
</li>
</ul>
))}
</li>
</ul>
)}
<ul>
<li>
<a
href={getCompareChooserUrl({ newProject: repoName, newRevision: revision })}
href={getCompareChooserUrl({
newProject: repoName,
newRevision: revision,
})}
target="_blank"
rel="noopener noreferrer"
>Compare result against another revision</a>
>
Compare result against another revision
</a>
</li>
</ul>
</div>

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

@ -51,15 +51,18 @@ class SimilarJobsTab extends React.Component {
offset: (page - 1) * this.pageSize,
};
['filterBuildPlatformId', 'filterOptionCollectionHash']
.forEach((key) => {
if (this.state[key]) {
const field = this.filterMap[key];
options[field] = selectedJob[field];
}
['filterBuildPlatformId', 'filterOptionCollectionHash'].forEach(key => {
if (this.state[key]) {
const field = this.filterMap[key];
options[field] = selectedJob[field];
}
});
const newSimilarJobs = await JobModel.getSimilarJobs(repoName, selectedJob.id, options);
const newSimilarJobs = await JobModel.getSimilarJobs(
repoName,
selectedJob.id,
options,
);
if (newSimilarJobs.length > 0) {
this.setState({ hasNextPage: newSimilarJobs.length > this.pageSize });
@ -68,18 +71,28 @@ class SimilarJobsTab extends React.Component {
const pushIds = [...new Set(newSimilarJobs.map(job => job.push_id))];
// get pushes and revisions for the given ids
let pushList = { results: [] };
const resp = await PushModel.getList({ id__in: pushIds.join(','), count: thMaxPushFetchSize });
const resp = await PushModel.getList({
id__in: pushIds.join(','),
count: thMaxPushFetchSize,
});
if (resp.ok) {
pushList = await resp.json();
// decorate the list of jobs with their result sets
const pushes = pushList.results.reduce((acc, push) => (
{ ...acc, [push.id]: push }
), {});
newSimilarJobs.forEach((simJob) => {
const pushes = pushList.results.reduce(
(acc, push) => ({ ...acc, [push.id]: push }),
{},
);
newSimilarJobs.forEach(simJob => {
simJob.result_set = pushes[simJob.push_id];
simJob.revisionResultsetFilterUrl = getJobsUrl({ repo: repoName, revision: simJob.result_set.revisions[0].revision });
simJob.authorResultsetFilterUrl = getJobsUrl({ repo: repoName, author: simJob.result_set.author });
simJob.revisionResultsetFilterUrl = getJobsUrl({
repo: repoName,
revision: simJob.result_set.revisions[0].revision,
});
simJob.authorResultsetFilterUrl = getJobsUrl({
repo: repoName,
author: simJob.result_set.author,
});
});
this.setState({ similarJobs: [...similarJobs, ...newSimilarJobs] });
// on the first page show the first element info by default
@ -87,7 +100,11 @@ class SimilarJobsTab extends React.Component {
this.showJobInfo(newSimilarJobs[0]);
}
} else {
notify(`Error fetching similar jobs push data: ${resp.message}`, 'danger', { sticky: true });
notify(
`Error fetching similar jobs push data: ${resp.message}`,
'danger',
{ sticky: true },
);
}
}
this.setState({ isLoading: false });
@ -102,25 +119,30 @@ class SimilarJobsTab extends React.Component {
showJobInfo(job) {
const { repoName, classificationMap } = this.props;
JobModel.get(repoName, job.id)
.then((nextJob) => {
nextJob.result_status = getStatus(nextJob);
nextJob.duration = (nextJob.end_timestamp - nextJob.start_timestamp) / 60;
nextJob.failure_classification = classificationMap[
nextJob.failure_classification_id];
JobModel.get(repoName, job.id).then(nextJob => {
nextJob.result_status = getStatus(nextJob);
nextJob.duration = (nextJob.end_timestamp - nextJob.start_timestamp) / 60;
nextJob.failure_classification =
classificationMap[nextJob.failure_classification_id];
// retrieve the list of error lines
TextLogStepModel.get(nextJob.id).then((textLogSteps) => {
nextJob.error_lines = textLogSteps.reduce((acc, step) => (
[...acc, ...step.errors]), []);
this.setState({ selectedSimilarJob: nextJob });
});
// retrieve the list of error lines
TextLogStepModel.get(nextJob.id).then(textLogSteps => {
nextJob.error_lines = textLogSteps.reduce(
(acc, step) => [...acc, ...step.errors],
[],
);
this.setState({ selectedSimilarJob: nextJob });
});
});
}
toggleFilter(filterField) {
this.setState(
{ [filterField]: !this.state[filterField], similarJobs: [], isLoading: true },
{
[filterField]: !this.state[filterField],
similarJobs: [],
isLoading: true,
},
this.getSimilarJobs,
);
}
@ -135,7 +157,9 @@ class SimilarJobsTab extends React.Component {
isLoading,
} = this.state;
const button_class = job => getBtnClass(getStatus(job));
const selectedSimilarJobId = selectedSimilarJob ? selectedSimilarJob.id : null;
const selectedSimilarJobId = selectedSimilarJob
? selectedSimilarJob.id
: null;
return (
<div className="similar-jobs w-100">
@ -154,19 +178,25 @@ class SimilarJobsTab extends React.Component {
<tr
key={similarJob.id}
onClick={() => this.showJobInfo(similarJob)}
className={selectedSimilarJobId === similarJob.id ? 'table-active' : ''}
className={
selectedSimilarJobId === similarJob.id ? 'table-active' : ''
}
>
<td>
<button
className={`btn btn-similar-jobs btn-xs ${button_class(similarJob)}`}
>{similarJob.job_type_symbol}
{similarJob.failure_classification_id > 1 &&
<span>*</span>}
className={`btn btn-similar-jobs btn-xs ${button_class(
similarJob,
)}`}
>
{similarJob.job_type_symbol}
{similarJob.failure_classification_id > 1 && (
<span>*</span>
)}
</button>
</td>
<td
title={toDateStr(similarJob.result_set.push_timestamp)}
>{toShortDateStr(similarJob.result_set.push_timestamp)}</td>
<td title={toDateStr(similarJob.result_set.push_timestamp)}>
{toShortDateStr(similarJob.result_set.push_timestamp)}
</td>
<td>
<a href={similarJob.authorResultsetFilterUrl}>
{similarJob.result_set.author}
@ -177,14 +207,18 @@ class SimilarJobsTab extends React.Component {
{similarJob.result_set.revisions[0].revision}
</a>
</td>
</tr>))}
</tr>
))}
</tbody>
</table>
{hasNextPage &&
<button
className="btn btn-light-bordered btn-sm link-style"
onClick={this.showNext}
>Show previous jobs</button>}
{hasNextPage && (
<button
className="btn btn-light-bordered btn-sm link-style"
onClick={this.showNext}
>
Show previous jobs
</button>
)}
</div>
<div className="similar-job-detail-panel">
<form className="form form-inline">
@ -195,7 +229,6 @@ class SimilarJobsTab extends React.Component {
checked={filterBuildPlatformId}
/>
<small>Same platform</small>
</div>
<div className="checkbox">
<input
@ -204,70 +237,83 @@ class SimilarJobsTab extends React.Component {
checked={filterOptionCollectionHash}
/>
<small>Same options</small>
</div>
</form>
<div className="similar_job_detail">
{selectedSimilarJob && <table className="table table-super-condensed">
<tbody>
<tr>
<th>Result</th>
<td>{selectedSimilarJob.result_status}</td>
</tr>
<tr>
<th>Build</th>
<td>
{selectedSimilarJob.build_architecture} {selectedSimilarJob.build_platform} {selectedSimilarJob.build_os}
</td>
</tr>
<tr>
<th>Build option</th>
<td>
{selectedSimilarJob.platform_option}
</td>
</tr>
<tr>
<th>Job name</th>
<td>{selectedSimilarJob.job_type_name}</td>
</tr>
<tr>
<th>Started</th>
<td>{toDateStr(selectedSimilarJob.start_timestamp)}</td>
</tr>
<tr>
<th>Duration</th>
<td>
{selectedSimilarJob.duration >= 0 ? `${selectedSimilarJob.duration.toFixed(0)} minute(s)` : 'unknown'}
</td>
</tr>
<tr>
<th>Classification</th>
<td>
<label
className={`badge ${selectedSimilarJob.failure_classification.star}`}
>{selectedSimilarJob.failure_classification.name}</label>
</td>
</tr>
{!!selectedSimilarJob.error_lines && <tr>
<td colSpan={2}>
<ul className="list-unstyled error_list">
{selectedSimilarJob.error_lines.map(error => (<li key={error.id}>
<small title={error.line}>{error.line}</small>
</li>))}
</ul>
</td>
</tr>}
</tbody>
</table>}
{selectedSimilarJob && (
<table className="table table-super-condensed">
<tbody>
<tr>
<th>Result</th>
<td>{selectedSimilarJob.result_status}</td>
</tr>
<tr>
<th>Build</th>
<td>
{selectedSimilarJob.build_architecture}{' '}
{selectedSimilarJob.build_platform}{' '}
{selectedSimilarJob.build_os}
</td>
</tr>
<tr>
<th>Build option</th>
<td>{selectedSimilarJob.platform_option}</td>
</tr>
<tr>
<th>Job name</th>
<td>{selectedSimilarJob.job_type_name}</td>
</tr>
<tr>
<th>Started</th>
<td>{toDateStr(selectedSimilarJob.start_timestamp)}</td>
</tr>
<tr>
<th>Duration</th>
<td>
{selectedSimilarJob.duration >= 0
? `${selectedSimilarJob.duration.toFixed(0)} minute(s)`
: 'unknown'}
</td>
</tr>
<tr>
<th>Classification</th>
<td>
<label
className={`badge ${
selectedSimilarJob.failure_classification.star
}`}
>
{selectedSimilarJob.failure_classification.name}
</label>
</td>
</tr>
{!!selectedSimilarJob.error_lines && (
<tr>
<td colSpan={2}>
<ul className="list-unstyled error_list">
{selectedSimilarJob.error_lines.map(error => (
<li key={error.id}>
<small title={error.line}>{error.line}</small>
</li>
))}
</ul>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
{isLoading && <div className="overlay">
<div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
{isLoading && (
<div className="overlay">
<div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
</div>
</div>
</div>}
)}
</div>
);
);
}
}

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

@ -32,10 +32,14 @@ class TabsPanel extends React.Component {
// This fires every time the props change. But we only want to figure out the new default
// tab when we get a new job. However, the job could change, then later, the perf details fetch
// returns. So we need to check for a change in the size of the perfJobDetail too.
if (state.jobId !== selectedJob.id || state.perfJobDetailSize !== perfJobDetail.length) {
if (
state.jobId !== selectedJob.id ||
state.perfJobDetailSize !== perfJobDetail.length
) {
const tabIndex = TabsPanel.getDefaultTabIndex(
getStatus(selectedJob),
!!perfJobDetail.length, showAutoclassifyTab,
!!perfJobDetail.length,
showAutoclassifyTab,
);
return {
@ -62,28 +66,45 @@ class TabsPanel extends React.Component {
const { tabIndex, showAutoclassifyTab } = this.state;
const { perfJobDetail } = this.props;
const nextIndex = tabIndex + 1;
const tabCount = TabsPanel.getTabNames(!!perfJobDetail.length, showAutoclassifyTab).length;
const tabCount = TabsPanel.getTabNames(
!!perfJobDetail.length,
showAutoclassifyTab,
).length;
this.setState({ tabIndex: nextIndex < tabCount ? nextIndex : 0 });
}
static getDefaultTabIndex(status, showPerf, showAutoclassify) {
let idx = 0;
const tabNames = TabsPanel.getTabNames(showPerf, showAutoclassify);
const tabIndexes = tabNames.reduce((acc, name) => ({ ...acc, [name]: idx++ }), {});
const tabIndexes = tabNames.reduce(
(acc, name) => ({ ...acc, [name]: idx++ }),
{},
);
let tabIndex = showPerf ? tabIndexes.perf : tabIndexes.details;
if (['busted', 'testfailed', 'exception'].includes(status)) {
tabIndex = showAutoclassify ? tabIndexes.autoclassify : tabIndexes.failure;
tabIndex = showAutoclassify
? tabIndexes.autoclassify
: tabIndexes.failure;
}
return tabIndex;
}
static getTabNames(showPerf, showAutoclassify) {
return [
'details', 'failure', 'autoclassify', 'annotations', 'similar', 'perf',
].filter(name => (
!((name === 'autoclassify' && !showAutoclassify) || (name === 'perf' && !showPerf))
));
'details',
'failure',
'autoclassify',
'annotations',
'similar',
'perf',
].filter(
name =>
!(
(name === 'autoclassify' && !showAutoclassify) ||
(name === 'perf' && !showPerf)
),
);
}
setTabIndex(tabIndex) {
@ -92,10 +113,25 @@ class TabsPanel extends React.Component {
render() {
const {
jobDetails, jobLogUrls, logParseStatus, suggestions, errors, user, bugs,
bugSuggestionsLoading, perfJobDetail, repoName, jobRevision,
classifications, togglePinBoardVisibility, isPinBoardVisible, pinnedJobs,
classificationMap, logViewerFullUrl, reftestUrl, clearSelectedJob,
jobDetails,
jobLogUrls,
logParseStatus,
suggestions,
errors,
user,
bugs,
bugSuggestionsLoading,
perfJobDetail,
repoName,
jobRevision,
classifications,
togglePinBoardVisibility,
isPinBoardVisible,
pinnedJobs,
classificationMap,
logViewerFullUrl,
reftestUrl,
clearSelectedJob,
} = this.props;
const { showAutoclassifyTab, tabIndex } = this.state;
const countPinnedJobs = Object.keys(pinnedJobs).length;
@ -116,30 +152,50 @@ class TabsPanel extends React.Component {
<Tab>Similar Jobs</Tab>
{!!perfJobDetail.length && <Tab>Performance</Tab>}
</span>
<span id="tab-header-buttons" className="details-panel-controls pull-right">
<span
id="tab-header-buttons"
className="details-panel-controls pull-right"
>
<span
id="pinboard-btn"
className="btn pinboard-btn-text"
onClick={togglePinBoardVisibility}
title={isPinBoardVisible ? 'Close the pinboard' : 'Open the pinboard'}
>PinBoard
{!!countPinnedJobs && <div
id="pin-count-group"
title={`You have ${countPinnedJobs} job${countPinnedJobs > 1 ? 's' : ''} pinned`}
className={`${countPinnedJobs > 99 ? 'pin-count-group-3-digit' : ''}`}
>
title={
isPinBoardVisible ? 'Close the pinboard' : 'Open the pinboard'
}
>
PinBoard
{!!countPinnedJobs && (
<div
className={`pin-count-text ${countPinnedJobs > 99 ? 'pin-count-group-3-digit' : ''}`}
>{countPinnedJobs}</div>
</div>}
id="pin-count-group"
title={`You have ${countPinnedJobs} job${
countPinnedJobs > 1 ? 's' : ''
} pinned`}
className={`${
countPinnedJobs > 99 ? 'pin-count-group-3-digit' : ''
}`}
>
<div
className={`pin-count-text ${
countPinnedJobs > 99 ? 'pin-count-group-3-digit' : ''
}`}
>
{countPinnedJobs}
</div>
</div>
)}
<span
className={`fa ${isPinBoardVisible ? 'fa-angle-down' : 'fa-angle-up'}`}
className={`fa ${
isPinBoardVisible ? 'fa-angle-down' : 'fa-angle-up'
}`}
/>
</span>
<span
onClick={() => clearSelectedJob(countPinnedJobs)}
className="btn details-panel-close-btn"
><span className="fa fa-times" /></span>
>
<span className="fa fa-times" />
</span>
</span>
</TabList>
<TabPanel>
@ -156,15 +212,17 @@ class TabsPanel extends React.Component {
reftestUrl={reftestUrl}
/>
</TabPanel>
{showAutoclassifyTab && <TabPanel>
<AutoclassifyTab
hasLogs={!!jobLogUrls.length}
logsParsed={logParseStatus !== 'pending'}
logParseStatus={logParseStatus}
user={user}
repoName={repoName}
/>
</TabPanel>}
{showAutoclassifyTab && (
<TabPanel>
<AutoclassifyTab
hasLogs={!!jobLogUrls.length}
logsParsed={logParseStatus !== 'pending'}
logParseStatus={logParseStatus}
user={user}
repoName={repoName}
/>
</TabPanel>
)}
<TabPanel>
<AnnotationsTab
classificationMap={classificationMap}
@ -178,13 +236,15 @@ class TabsPanel extends React.Component {
classificationMap={classificationMap}
/>
</TabPanel>
{!!perfJobDetail.length && <TabPanel>
<PerformanceTab
repoName={repoName}
perfJobDetail={perfJobDetail}
revision={jobRevision}
/>
</TabPanel>}
{!!perfJobDetail.length && (
<TabPanel>
<PerformanceTab
repoName={repoName}
perfJobDetail={perfJobDetail}
revision={jobRevision}
/>
</TabPanel>
)}
</Tabs>
</div>
);

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

@ -68,10 +68,9 @@ class AutoclassifyTab extends React.Component {
*/
onSaveAll(pendingLines) {
const pending = pendingLines || Array.from(this.state.inputByLine.values());
this.save(pending)
.then(() => {
this.setState({ selectedLineIds: new Set() });
});
this.save(pending).then(() => {
this.setState({ selectedLineIds: new Set() });
});
}
/**
@ -121,21 +120,32 @@ class AutoclassifyTab extends React.Component {
getLoadStatusText() {
switch (this.state.loadStatus) {
case 'job_pending': return 'Job not complete, please wait';
case 'pending': return 'Logs not fully parsed, please wait';
case 'failed': return 'Log parsing failed';
case 'no_logs': return 'No errors logged';
case 'error': return 'Error showing autoclassification data';
case 'loading': return null;
case 'ready': return (!this.state.errorLines || this.state.errorLines.length === 0) ? 'No error lines reported' : null;
default: return `Unexpected status: ${this.state.loadStatus}`;
case 'job_pending':
return 'Job not complete, please wait';
case 'pending':
return 'Logs not fully parsed, please wait';
case 'failed':
return 'Log parsing failed';
case 'no_logs':
return 'No errors logged';
case 'error':
return 'Error showing autoclassification data';
case 'loading':
return null;
case 'ready':
return !this.state.errorLines || this.state.errorLines.length === 0
? 'No error lines reported'
: null;
default:
return `Unexpected status: ${this.state.loadStatus}`;
}
}
setEditable(lineIds, editable) {
const { editableLineIds } = this.state;
const f = editable ? lineId => editableLineIds.add(lineId) :
lineId => editableLineIds.delete(lineId);
const f = editable
? lineId => editableLineIds.add(lineId)
: lineId => editableLineIds.delete(lineId);
lineIds.forEach(f);
this.setState({ editableLineIds });
@ -154,18 +164,23 @@ class AutoclassifyTab extends React.Component {
async fetchErrorData() {
const { selectedJob } = this.props;
this.setState({
this.setState(
{
loadStatus: 'loading',
errorLines: [],
selectedLineIds: new Set(),
editableLineIds: new Set(),
inputByLine: new Map(),
autoclassifyStatusOnLoad: null,
}, async () => {
},
async () => {
if (selectedJob.id) {
const errorLineResp = await fetch(getProjectJobUrl('/text_log_errors/', selectedJob.id));
const errorLineResp = await fetch(
getProjectJobUrl('/text_log_errors/', selectedJob.id),
);
const errorLineData = await errorLineResp.json();
const errorLines = errorLineData.map(line => new ErrorLineData(line))
const errorLines = errorLineData
.map(line => new ErrorLineData(line))
.sort((a, b) => a.data.id - b.data.id);
if (errorLines.length) {
@ -180,10 +195,10 @@ class AutoclassifyTab extends React.Component {
loadStatus: 'ready',
});
}
});
},
);
}
/**
* Test if it is possible to save a specific line.
* @param {number} lineId - Line id to test.
@ -214,8 +229,11 @@ class AutoclassifyTab extends React.Component {
canSaveAll() {
const pendingLines = this.getPendingLines();
return (this.state.canClassify && !!pendingLines.length &&
pendingLines.every(line => this.canSave(line.id)));
return (
this.state.canClassify &&
!!pendingLines.length &&
pendingLines.every(line => this.canSave(line.id))
);
}
/**
@ -236,19 +254,23 @@ class AutoclassifyTab extends React.Component {
}));
this.setState({ loadStatus: 'loading' });
return TextLogErrorsModel
.verifyMany(data)
.then((data) => {
const newErrorLines = data.reduce((newLines, updatedLine) => {
const idx = newLines.findIndex(line => line.id === updatedLine.id);
newLines[idx] = new ErrorLineData(updatedLine);
return newLines;
}, [...errorLines]);
return TextLogErrorsModel.verifyMany(data)
.then(data => {
const newErrorLines = data.reduce(
(newLines, updatedLine) => {
const idx = newLines.findIndex(line => line.id === updatedLine.id);
newLines[idx] = new ErrorLineData(updatedLine);
return newLines;
},
[...errorLines],
);
this.setState({ errorLines: newErrorLines, loadStatus: 'ready' });
})
.catch((err) => {
.catch(err => {
const prefix = 'Error saving classifications: ';
const msg = err.stack ? `${prefix}${err}${err.stack}` : `${prefix}${err.statusText} - ${err.data.detail}`;
const msg = err.stack
? `${prefix}${err}${err.stack}`
: `${prefix}${err.statusText} - ${err.data.detail}`;
notify(msg, 'danger', { sticky: true });
});
}
@ -257,7 +279,13 @@ class AutoclassifyTab extends React.Component {
* Update the panel for a new job selection
*/
jobChanged() {
const { autoclassifyStatus, hasLogs, logsParsed, logParseStatus, selectedJob } = this.props;
const {
autoclassifyStatus,
hasLogs,
logsParsed,
logParseStatus,
selectedJob,
} = this.props;
const { loadStatus, autoclassifyStatusOnLoad } = this.state;
let newLoadStatus = 'loading';
@ -269,7 +297,10 @@ class AutoclassifyTab extends React.Component {
newLoadStatus = 'failed';
} else if (!hasLogs) {
newLoadStatus = 'no_logs';
} else if (autoclassifyStatusOnLoad === null || autoclassifyStatusOnLoad === 'cross_referenced') {
} else if (
autoclassifyStatusOnLoad === null ||
autoclassifyStatusOnLoad === 'cross_referenced'
) {
if (loadStatus !== 'ready') {
newLoadStatus = 'loading';
}
@ -331,46 +362,52 @@ class AutoclassifyTab extends React.Component {
return (
<React.Fragment>
{canClassify && <AutoclassifyToolbar
loadStatus={loadStatus}
autoclassifyStatus={autoclassifyStatus}
user={user}
hasSelection={!!selectedLineIds.size}
canSave={canSave}
canSaveAll={canSaveAll}
canClassify={canClassify}
onPin={this.onPin}
onIgnore={this.onIgnore}
onEdit={this.onToggleEditable}
onSave={this.onSave}
onSaveAll={() => this.onSaveAll()}
/>}
{canClassify && (
<AutoclassifyToolbar
loadStatus={loadStatus}
autoclassifyStatus={autoclassifyStatus}
user={user}
hasSelection={!!selectedLineIds.size}
canSave={canSave}
canSaveAll={canSaveAll}
canClassify={canClassify}
onPin={this.onPin}
onIgnore={this.onIgnore}
onEdit={this.onToggleEditable}
onSave={this.onSave}
onSaveAll={() => this.onSaveAll()}
/>
)}
<div>
{loadStatusText && <span>{loadStatusText}</span>}
{loadStatus === 'loading' && <div className="overlay">
<div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
{loadStatus === 'loading' && (
<div className="overlay">
<div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
</div>
</div>
</div>}
)}
</div>
<span className="autoclassify-error-lines">
<ul className="list-unstyled">
{errorLines.map((errorLine, idx) => (<li key={errorLine.id}>
<ErrorLine
errorMatchers={errorMatchers}
errorLine={errorLine}
prevErrorLine={errorLines[idx - 1]}
canClassify={canClassify}
isSelected={selectedLineIds.has(errorLine.id)}
isEditable={editableLineIds.has(errorLine.id)}
setEditable={() => this.setEditable([errorLine.id], true)}
setErrorLineInput={this.setErrorLineInput}
toggleSelect={this.toggleSelect}
repoName={repoName}
/>
</li>))}
{errorLines.map((errorLine, idx) => (
<li key={errorLine.id}>
<ErrorLine
errorMatchers={errorMatchers}
errorLine={errorLine}
prevErrorLine={errorLines[idx - 1]}
canClassify={canClassify}
isSelected={selectedLineIds.has(errorLine.id)}
isEditable={editableLineIds.has(errorLine.id)}
setEditable={() => this.setEditable([errorLine.id], true)}
setErrorLineInput={this.setErrorLineInput}
toggleSelect={this.toggleSelect}
repoName={repoName}
/>
</li>
))}
</ul>
</span>
</React.Fragment>
@ -396,4 +433,6 @@ AutoclassifyTab.defaultProps = {
logParseStatus: 'pending',
};
export default withNotifications(withSelectedJob(withPinnedJobs(AutoclassifyTab)));
export default withNotifications(
withSelectedJob(withPinnedJobs(AutoclassifyTab)),
);

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

@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types';
export default class AutoclassifyToolbar extends React.Component {
getButtonTitle(condition, activeTitle, inactiveTitle) {
const { user } = this.props;
@ -20,60 +19,88 @@ export default class AutoclassifyToolbar extends React.Component {
render() {
const {
hasSelection, canSave, canSaveAll, canClassify, onPin, onIgnore, onSave,
onSaveAll, onEdit, autoclassifyStatus,
hasSelection,
canSave,
canSaveAll,
canClassify,
onPin,
onIgnore,
onSave,
onSaveAll,
onEdit,
autoclassifyStatus,
} = this.props;
return (
<div className="autoclassify-toolbar th-context-navbar navbar-right">
{
// TODO: This is broken (bug 1504711)
// eslint-disable-next-line no-restricted-globals
status === 'ready' && (
<div>
{autoclassifyStatus === 'cross_referenced' && (
<span>Autoclassification pending</span>
)}
{autoclassifyStatus === 'failed' && (
<span>Autoclassification failed</span>
)}
</div>
{// TODO: This is broken (bug 1504711)
// eslint-disable-next-line no-restricted-globals
status === 'ready' && (
<div>
{autoclassifyStatus === 'cross_referenced' && (
<span>Autoclassification pending</span>
)}
{autoclassifyStatus === 'failed' && (
<span>Autoclassification failed</span>
)}
</div>
)}
<button
className="btn btn-view-nav btn-sm nav-menu-btn"
title="Pin job for bustage"
onClick={onPin}
>Bustage
>
Bustage
</button>
<button
className="btn btn-view-nav btn-sm nav-menu-btn"
title={this.getButtonTitle(hasSelection, 'Edit selected lines', 'Nothing selected')}
title={this.getButtonTitle(
hasSelection,
'Edit selected lines',
'Nothing selected',
)}
onClick={onEdit}
disabled={hasSelection && !canClassify}
>Edit</button>
>
Edit
</button>
<button
className="btn btn-view-nav btn-sm nav-menu-btn"
title={this.getButtonTitle(hasSelection, 'Ignore selected lines', 'Nothing selected')}
title={this.getButtonTitle(
hasSelection,
'Ignore selected lines',
'Nothing selected',
)}
onClick={onIgnore}
disabled={hasSelection && !canClassify}
>Ignore</button>
>
Ignore
</button>
<button
className="btn btn-view-nav btn-sm nav-menu-btn"
title={this.getButtonTitle(canSave, 'Save', 'Nothing selected')}
onClick={onSave}
disabled={!canSave}
>Save</button>
>
Save
</button>
<button
className="btn btn-view-nav btn-sm nav-menu-btn"
title={this.getButtonTitle(canSaveAll, 'Save All', 'Lines not classified')}
title={this.getButtonTitle(
canSaveAll,
'Save All',
'Lines not classified',
)}
onClick={onSaveAll}
disabled={!canSaveAll}
>Save All</button>
>
Save All
</button>
</div>
);
}

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

@ -3,7 +3,10 @@ import PropTypes from 'prop-types';
import { FormGroup } from 'reactstrap';
import { thEvents } from '../../../../helpers/constants';
import { stringOverlap, highlightLogLine } from '../../../../helpers/autoclassify';
import {
stringOverlap,
highlightLogLine,
} from '../../../../helpers/autoclassify';
import { getBugUrl, getLogViewerUrl } from '../../../../helpers/url';
import { withSelectedJob } from '../../../context/SelectedJob';
@ -127,8 +130,7 @@ class ErrorLine extends React.Component {
return 'unverified-ignore';
}
if (selectedOption.type === 'manual' &&
!selectedOption.manualBugNumber) {
if (selectedOption.type === 'manual' && !selectedOption.manualBugNumber) {
return 'unverified-no-bug';
}
@ -141,40 +143,53 @@ class ErrorLine extends React.Component {
getOptions() {
const bugSuggestions = [].concat(
this.props.errorLine.data.bug_suggestions.bugs.open_recent,
this.props.errorLine.data.bug_suggestions.bugs.all_others);
this.props.errorLine.data.bug_suggestions.bugs.all_others,
);
const classificationMatches = this.getClassifiedFailureMatcher();
const autoclassifyOptions = this.props.errorLine.data.classified_failures
.filter(cf => cf.bug_number !== 0)
.map(cf => new LineOptionModel(
'classifiedFailure',
`${this.props.errorLine.id}-${cf.id}`,
cf.id,
cf.bug_number,
cf.bug ? cf.bug.summary : '',
cf.bug ? cf.bug.resolution : '',
classificationMatches(cf.id),
));
const autoclassifiedBugs = autoclassifyOptions
.reduce((classifiedBugs, option) => classifiedBugs.add(option.bugNumber),
new Set());
.map(
cf =>
new LineOptionModel(
'classifiedFailure',
`${this.props.errorLine.id}-${cf.id}`,
cf.id,
cf.bug_number,
cf.bug ? cf.bug.summary : '',
cf.bug ? cf.bug.resolution : '',
classificationMatches(cf.id),
),
);
const autoclassifiedBugs = autoclassifyOptions.reduce(
(classifiedBugs, option) => classifiedBugs.add(option.bugNumber),
new Set(),
);
const bugSuggestionOptions = bugSuggestions
.filter(bug => !autoclassifiedBugs.has(bug.id))
.map(bugSuggestion => new LineOptionModel(
'unstructuredBug',
`${this.props.errorLine.id}-ub-${bugSuggestion.id}`,
null,
bugSuggestion.id,
bugSuggestion.summary,
bugSuggestion.resolution));
.map(
bugSuggestion =>
new LineOptionModel(
'unstructuredBug',
`${this.props.errorLine.id}-ub-${bugSuggestion.id}`,
null,
bugSuggestion.id,
bugSuggestion.summary,
bugSuggestion.resolution,
),
);
this.bestOption = null;
// Look for an option that has been marked as the best classification.
// This is always sorted first and never hidden, so we remove it and readd it.
if (!this.bestIsIgnore()) {
const bestIndex = this.props.errorLine.bestClassification ?
autoclassifyOptions
.findIndex(option => option.classifiedFailureId === this.props.errorLine.bestClassification.id) : -1;
const bestIndex = this.props.errorLine.bestClassification
? autoclassifyOptions.findIndex(
option =>
option.classifiedFailureId ===
this.props.errorLine.bestClassification.id,
)
: -1;
if (bestIndex > -1) {
this.bestOption = autoclassifyOptions[bestIndex];
@ -199,8 +214,14 @@ class ErrorLine extends React.Component {
* Build a list of the default options that apply to all lines.
*/
getExtraOptions() {
const extraOptions = [new LineOptionModel('manual', `${this.props.errorLine.id}-manual`)];
const ignoreOption = new LineOptionModel('ignore', `${this.props.errorLine.id}-ignore`, 0);
const extraOptions = [
new LineOptionModel('manual', `${this.props.errorLine.id}-manual`),
];
const ignoreOption = new LineOptionModel(
'ignore',
`${this.props.errorLine.id}-ignore`,
0,
);
extraOptions.push(ignoreOption);
if (this.bestIsIgnore()) {
@ -250,8 +271,9 @@ class ErrorLine extends React.Component {
*/
// Get the test id for this line and the last line, if any
const thisTest = failureLine ? failureLine.test :
parseTest(errorLine.data.bug_suggestions.search);
const thisTest = failureLine
? failureLine.test
: parseTest(errorLine.data.bug_suggestions.search);
let prevTest;
if (prevErrorLine) {
prevTest = prevErrorLine.data.failure_line
@ -274,16 +296,19 @@ class ErrorLine extends React.Component {
// suggestions, we assume that is the signature line
// and this is ignorable
ignore = true;
} else if (failureLine &&
(failureLine.action === 'crash' ||
failureLine.action === 'test_result')) {
} else if (
failureLine &&
(failureLine.action === 'crash' || failureLine.action === 'test_result')
) {
// Don't ignore crashes or test results
ignore = false;
} else {
// Don't ignore lines containing a well-known string
let message;
if (failureLine) {
message = failureLine.signature ? failureLine.signature : failureLine.message;
message = failureLine.signature
? failureLine.signature
: failureLine.message;
} else {
message = this.props.errorLine.data.bug_suggestions.search;
}
@ -312,13 +337,15 @@ class ErrorLine extends React.Component {
}
matchesByCF.get(match.classified_failure).push(match);
return matchesByCF;
}, new Map());
},
new Map(),
);
const matchFunc = cf_id => matchesByCF.get(cf_id).map(
match => ({
matcher: match.matcher_name,
score: match.score,
}));
const matchFunc = cf_id =>
matchesByCF.get(cf_id).map(match => ({
matcher: match.matcher_name,
score: match.score,
}));
return matchFunc.bind(this);
}
@ -329,11 +356,12 @@ class ErrorLine extends React.Component {
// TODO: consider adding the update/create options back here, although it's
// not clear anyone ever understood how they were supposed to work
const { errorLine, setErrorLineInput } = this.props;
const classifiedFailureId = ((this.bestOption &&
const classifiedFailureId =
this.bestOption &&
this.bestOption.classifiedFailureId &&
this.bestOption.bugNumber === null) ?
this.bestOption.classifiedFailureId :
option.classifiedFailureId);
this.bestOption.bugNumber === null
? this.bestOption.classifiedFailureId
: option.classifiedFailureId;
let bug;
if (option.type === 'manual') {
@ -386,10 +414,11 @@ class ErrorLine extends React.Component {
const bestScore = this.bestOption.score;
options.forEach((option, idx) => {
option.hidden = idx > (minOptions - 1) &&
option.hidden =
idx > minOptions - 1 &&
(option.score < lowerCutoff ||
option.score < bestRatio * bestScore ||
idx > (maxOptions - 1));
idx > maxOptions - 1);
});
}
@ -399,22 +428,25 @@ class ErrorLine extends React.Component {
* @param {Object[]} options - List of options to score
*/
scoreOptions(options) {
options
.forEach((option) => {
let score;
const { data } = this.props.errorLine;
if (option.type === 'classifiedFailure') {
score = parseFloat(
data.matches.find(
x => x.classified_failure === option.classifiedFailureId).score);
} else {
score = stringOverlap(data.bug_suggestions.search,
option.bugSummary.replace(/^\s*Intermittent\s+/, ''));
// Artificially reduce the score of resolved bugs
score *= option.bugResolution ? 0.8 : 1;
}
option.score = score;
});
options.forEach(option => {
let score;
const { data } = this.props.errorLine;
if (option.type === 'classifiedFailure') {
score = parseFloat(
data.matches.find(
x => x.classified_failure === option.classifiedFailureId,
).score,
);
} else {
score = stringOverlap(
data.bug_suggestions.search,
option.bugSummary.replace(/^\s*Intermittent\s+/, ''),
);
// Artificially reduce the score of resolved bugs
score *= option.bugResolution ? 0.8 : 1;
}
option.score = score;
});
}
/**
@ -429,11 +461,15 @@ class ErrorLine extends React.Component {
* Test if the initial best option is to ignore the line
*/
bestIsIgnore() {
const { errorLine: { data: errorData } } = this.props;
const {
errorLine: { data: errorData },
} = this.props;
if (errorData.metaData) {
return (errorData.metadata.best_classification &&
errorData.metadata.best_classification.bugNumber === 0);
return (
errorData.metadata.best_classification &&
errorData.metadata.best_classification.bugNumber === 0
);
}
return false;
}
@ -442,21 +478,37 @@ class ErrorLine extends React.Component {
* Determine whether the line should be open for editing by default
*/
defaultEditable(option) {
return option ? !(option.score >= GOOD_MATCH_SCORE || option.type === 'ignore') : false;
return option
? !(option.score >= GOOD_MATCH_SCORE || option.type === 'ignore')
: false;
}
render() {
const {
errorLine, selectedJob, canClassify, isSelected, isEditable, setEditable,
toggleSelect, repoName,
errorLine,
selectedJob,
canClassify,
isSelected,
isEditable,
setEditable,
toggleSelect,
repoName,
} = this.props;
const {
messageExpanded, showHidden, selectedOption, options, extraOptions,
messageExpanded,
showHidden,
selectedOption,
options,
extraOptions,
} = this.state;
const failureLine = errorLine.data.metadata.failure_line;
const searchLine = errorLine.data.bug_suggestions.search;
const logUrl = getLogViewerUrl(selectedJob.id, repoName, errorLine.data.line_number + 1);
const logUrl = getLogViewerUrl(
selectedJob.id,
repoName,
errorLine.data.line_number + 1,
);
const status = this.getStatus();
return (
@ -465,131 +517,215 @@ class ErrorLine extends React.Component {
onClick={evt => toggleSelect(evt, errorLine)}
>
<div className={status}>&nbsp;</div>
{errorLine.verified && <div>
{!errorLine.verifiedIgnore && <span
className="badge badge-xs badge-primary"
title="This line is verified"
>Verified</span>}
{errorLine.verifiedIgnore && <span
className="badge badge-xs badge-ignored"
title="This line is ignored"
>Ignored</span>}
</div>}
{errorLine.verified && (
<div>
{!errorLine.verifiedIgnore && (
<span
className="badge badge-xs badge-primary"
title="This line is verified"
>
Verified
</span>
)}
{errorLine.verifiedIgnore && (
<span
className="badge badge-xs badge-ignored"
title="This line is ignored"
>
Ignored
</span>
)}
</div>
)}
<div className="classification-line-detail">
{failureLine && <div>
{failureLine.action === 'test_result' && <span>
<span className={errorLine.verifiedIgnore ? 'ignored-line' : ''}>
<strong className="failure-line-status">{failureLine.status}</strong>
{failureLine.expected !== 'PASS' && failureLine.expected !== 'OK' && <span>
(expected <strong>{failureLine.expected}</strong>)
</span>} | <strong>{failureLine.test}</strong>
{failureLine.subtest && <span>| {failureLine.subtest}</span>}
</span>
{failureLine.message && !errorLine.verifiedIgnore &&
<div className="failure-line-message">
{failureLine && (
<div>
{failureLine.action === 'test_result' && (
<span>
<span
className={`failure-line-message-toggle fa fa-fw fa-lg${messageExpanded ? 'fa-caret-down' : 'fa-carat-right'}`}
onClick={() => this.setState({ messageExpanded: !messageExpanded })}
/>
{messageExpanded ?
<span className="failure-line-message-expanded">{failureLine.message}</span> :
<span className="failure-line-message-collapsed">{failureLine.message}</span>}
</div>}
</span>}
{failureLine.action === 'log' && <span>
className={errorLine.verifiedIgnore ? 'ignored-line' : ''}
>
<strong className="failure-line-status">
{failureLine.status}
</strong>
{failureLine.expected !== 'PASS' &&
failureLine.expected !== 'OK' && (
<span>
(expected <strong>{failureLine.expected}</strong>)
</span>
)}{' '}
| <strong>{failureLine.test}</strong>
{failureLine.subtest && (
<span>| {failureLine.subtest}</span>
)}
</span>
{failureLine.message && !errorLine.verifiedIgnore && (
<div className="failure-line-message">
<span
className={`failure-line-message-toggle fa fa-fw fa-lg${
messageExpanded ? 'fa-caret-down' : 'fa-carat-right'
}`}
onClick={() =>
this.setState({ messageExpanded: !messageExpanded })
}
/>
{messageExpanded ? (
<span className="failure-line-message-expanded">
{failureLine.message}
</span>
) : (
<span className="failure-line-message-collapsed">
{failureLine.message}
</span>
)}
</div>
)}
</span>
)}
{failureLine.action === 'log' && (
<span>
LOG {failureLine.level} | {failureLine.message}
</span>}
{failureLine.action === 'crash' && <span>
</span>
)}
{failureLine.action === 'crash' && (
<span>
CRASH |
{failureLine.test && <span><strong>{failureLine.test}</strong> |
</span>}
{failureLine.test && (
<span>
<strong>{failureLine.test}</strong> |
</span>
)}
application crashed [{failureLine.signature}]
</span>}
<span> [<a
title="Open the log viewer in a new window"
target="_blank"
rel="noopener noreferrer"
href={logUrl}
className=""
>log</a>]</span>
</div>}
{!failureLine && <div>
{highlightLogLine(searchLine)}
<span> [<a
title="Open the log viewer in a new window"
target="_blank"
rel="noopener noreferrer"
href={logUrl}
>log</a>]</span>
</div>}
{errorLine.verified && !errorLine.verifiedIgnore && <div>
<span className="fa fa-star best-classification-star" />
{errorLine.bugNumber && <span className="line-option-text">
<a
href={getBugUrl(errorLine.bugNumber)}
target="_blank"
rel="noopener noreferrer"
>Bug {errorLine.bugNumber} - {errorLine.bugSummary && <span>{errorLine.bugSummary}</span>}</a>
{!errorLine.bugNumber && <span className="line-option-text">
Classifed failure with no bug number
</span>}
</span>}
</div>}
{((!errorLine.verified && isEditable) || !canClassify) && <div>
<FormGroup>
<ul className="list-unstyled">
{options.map(option => (
(showHidden || !option.hidden) && <li key={option.id}>
<LineOption
errorLine={errorLine}
optionModel={option}
selectedOption={selectedOption}
canClassify={canClassify}
onOptionChange={this.onOptionChange}
ignoreAlways={option.ignoreAlways}
/>
</li>))}
</ul>
{this.hasHidden(options) &&
</span>
)}
<span>
{' '}
[
<a
onClick={() => this.setState({ showHidden: !this.state.showHidden })}
className="link-style has-hidden"
>{!showHidden ? <span>More</span> : <span>Fewer</span>}</a>
}
{canClassify && <ul className="list-unstyled extra-options">
{/* classification options for line */}
{extraOptions.map(option => (
<li key={option.id}>
<LineOption
errorLine={errorLine}
optionModel={option}
selectedOption={selectedOption}
canClassify={canClassify}
onOptionChange={this.onOptionChange}
onIgnoreAlwaysChange={this.onIgnoreAlwaysChange}
onManualBugNumberChange={this.onManualBugNumberChange}
manualBugNumber={option.manualBugNumber}
ignoreAlways={option.ignoreAlways}
/>
</li>))}
</ul>}
</FormGroup>
</div>}
title="Open the log viewer in a new window"
target="_blank"
rel="noopener noreferrer"
href={logUrl}
className=""
>
log
</a>
]
</span>
</div>
)}
{!failureLine && (
<div>
{highlightLogLine(searchLine)}
<span>
{' '}
[
<a
title="Open the log viewer in a new window"
target="_blank"
rel="noopener noreferrer"
href={logUrl}
>
log
</a>
]
</span>
</div>
)}
{!errorLine.verified && !isEditable && canClassify && <div>
<StaticLineOption
errorLine={errorLine}
option={selectedOption}
numOptions={options.length}
canClassify={canClassify}
setEditable={setEditable}
ignoreAlways={selectedOption.ignoreAlways}
manualBugNumber={selectedOption.manualBugNumber}
/>
</div>}
{errorLine.verified && !errorLine.verifiedIgnore && (
<div>
<span className="fa fa-star best-classification-star" />
{errorLine.bugNumber && (
<span className="line-option-text">
<a
href={getBugUrl(errorLine.bugNumber)}
target="_blank"
rel="noopener noreferrer"
>
Bug {errorLine.bugNumber} -{' '}
{errorLine.bugSummary && (
<span>{errorLine.bugSummary}</span>
)}
</a>
{!errorLine.bugNumber && (
<span className="line-option-text">
Classifed failure with no bug number
</span>
)}
</span>
)}
</div>
)}
{((!errorLine.verified && isEditable) || !canClassify) && (
<div>
<FormGroup>
<ul className="list-unstyled">
{options.map(
option =>
(showHidden || !option.hidden) && (
<li key={option.id}>
<LineOption
errorLine={errorLine}
optionModel={option}
selectedOption={selectedOption}
canClassify={canClassify}
onOptionChange={this.onOptionChange}
ignoreAlways={option.ignoreAlways}
/>
</li>
),
)}
</ul>
{this.hasHidden(options) && (
<a
onClick={() =>
this.setState({ showHidden: !this.state.showHidden })
}
className="link-style has-hidden"
>
{!showHidden ? <span>More</span> : <span>Fewer</span>}
</a>
)}
{canClassify && (
<ul className="list-unstyled extra-options">
{/* classification options for line */}
{extraOptions.map(option => (
<li key={option.id}>
<LineOption
errorLine={errorLine}
optionModel={option}
selectedOption={selectedOption}
canClassify={canClassify}
onOptionChange={this.onOptionChange}
onIgnoreAlwaysChange={this.onIgnoreAlwaysChange}
onManualBugNumberChange={this.onManualBugNumberChange}
manualBugNumber={option.manualBugNumber}
ignoreAlways={option.ignoreAlways}
/>
</li>
))}
</ul>
)}
</FormGroup>
</div>
)}
{!errorLine.verified && !isEditable && canClassify && (
<div>
<StaticLineOption
errorLine={errorLine}
option={selectedOption}
numOptions={options.length}
canClassify={canClassify}
setEditable={setEditable}
ignoreAlways={selectedOption.ignoreAlways}
manualBugNumber={selectedOption.manualBugNumber}
/>
</div>
)}
</div>
</div>
);

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

@ -9,19 +9,25 @@ export default class ErrorLineModel {
// an actual boolean later
if (line.metadata) {
this.verified = line.metadata.best_is_verified;
this.bestClassification = line.metadata.best_classification ?
line.classified_failures
.find(cf => cf.id === line.metadata.best_classification) : null;
this.bestClassification = line.metadata.best_classification
? line.classified_failures.find(
cf => cf.id === line.metadata.best_classification,
)
: null;
} else {
this.verified = false;
this.bestClassification = null;
line.metadata = {};
}
this.bugNumber = this.bestClassification ?
this.bestClassification.bug_number : null;
this.verifiedIgnore = this.verified && (this.bugNumber === 0 ||
this.bestClassification === null);
this.bugSummary = (this.bestClassification && this.bestClassification.bug) ?
this.bestClassification.bug.summary : null;
this.bugNumber = this.bestClassification
? this.bestClassification.bug_number
: null;
this.verifiedIgnore =
this.verified &&
(this.bugNumber === 0 || this.bestClassification === null);
this.bugSummary =
this.bestClassification && this.bestClassification.bug
? this.bestClassification.bug.summary
: null;
}
}

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

@ -6,7 +6,11 @@ import Highlighter from 'react-highlight-words';
import { getSearchWords } from '../../../../helpers/display';
import { isReftest } from '../../../../helpers/job';
import { getBugUrl, getLogViewerUrl, getReftestUrl } from '../../../../helpers/url';
import {
getBugUrl,
getLogViewerUrl,
getReftestUrl,
} from '../../../../helpers/url';
import BugFiler from '../../BugFiler';
import { thEvents } from '../../../../helpers/constants';
import { getAllUrlParams } from '../../../../helpers/location';
@ -81,52 +85,77 @@ class LineOption extends React.Component {
return (
<div className="classification-option">
<span className="classification-icon">
{option.isBest ?
<span className="fa fa-star-o" title="Autoclassifier best match" /> :
<span className="classification-no-icon">&nbsp;</span>}
{option.isBest ? (
<span className="fa fa-star-o" title="Autoclassifier best match" />
) : (
<span className="classification-no-icon">&nbsp;</span>
)}
</span>
<FormGroup check>
<Label check>
{!(option.type === 'classifiedFailure' && !option.bugNumber) && <Input
type="radio"
value={option}
id={option.id}
checked={selectedOption.id === option.id}
name={errorLine.id}
onChange={() => onOptionChange(option)}
className={canClassify ? '' : 'hidden'}
/>}
{!!option.bugNumber && <span className="line-option-text">
{(!canClassify || selectedJob.id in pinnedJobs) &&
<button
className="btn btn-xs btn-light-bordered"
onClick={() => addBug({ id: option.bugNumber }, selectedJob)}
title="add to list of bugs to associate with all pinned jobs"
><i className="fa fa-thumb-tack" /></button>}
{!!option.bugResolution &&
<span className="classification-bug-resolution"> [{option.bugResolution}] </span>}
<a
href={getBugUrl(option.bugNumber)}
target="_blank"
rel="noopener noreferrer"
>{option.bugNumber} -
<Highlighter
searchWords={getSearchWords(errorLine.data.bug_suggestions.search)}
textToHighlight={option.bugSummary}
caseSensitive
highlightTag="strong"
/>
</a>
<span> [ {Number.parseFloat(option.score).toPrecision(2)} ]</span>
</span>}
{!(option.type === 'classifiedFailure' && !option.bugNumber) && (
<Input
type="radio"
value={option}
id={option.id}
checked={selectedOption.id === option.id}
name={errorLine.id}
onChange={() => onOptionChange(option)}
className={canClassify ? '' : 'hidden'}
/>
)}
{!!option.bugNumber && (
<span className="line-option-text">
{(!canClassify || selectedJob.id in pinnedJobs) && (
<button
className="btn btn-xs btn-light-bordered"
onClick={() =>
addBug({ id: option.bugNumber }, selectedJob)
}
title="add to list of bugs to associate with all pinned jobs"
>
<i className="fa fa-thumb-tack" />
</button>
)}
{!!option.bugResolution && (
<span className="classification-bug-resolution">
{' '}
[{option.bugResolution}]{' '}
</span>
)}
<a
href={getBugUrl(option.bugNumber)}
target="_blank"
rel="noopener noreferrer"
>
{option.bugNumber} -
<Highlighter
searchWords={getSearchWords(
errorLine.data.bug_suggestions.search,
)}
textToHighlight={option.bugSummary}
caseSensitive
highlightTag="strong"
/>
</a>
<span>
{' '}
[ {Number.parseFloat(option.score).toPrecision(2)} ]
</span>
</span>
)}
{option.type === 'classifiedFailure' && !option.bugNumber && <span>
Autoclassified failure with no associated bug number
</span>}
{option.type === 'classifiedFailure' && !option.bugNumber && (
<span>Autoclassified failure with no associated bug number</span>
)}
{option.type === 'manual' &&
<div className={`line-option-text manual-bug ${!canClassify ? 'hidden' : ''}`}>
{option.type === 'manual' && (
<div
className={`line-option-text manual-bug ${
!canClassify ? 'hidden' : ''
}`}
>
Other bug:
<Input
className="manual-bug-input"
@ -135,63 +164,84 @@ class LineOption extends React.Component {
size="7"
placeholder="Number"
value={manualBugNumber}
onChange={evt => onManualBugNumberChange(option, evt.target.value)}
onChange={evt =>
onManualBugNumberChange(option, evt.target.value)
}
/>
<a
className="btn btn-xs btn-light-bordered btn-file-bug"
onClick={() => this.fileBug()}
title="File a bug for this failure"
><i className="fa fa-bug" /></a>
>
<i className="fa fa-bug" />
</a>
{option.id === 'manual' && !!option.manualBugNumber && (
<a
href={getBugUrl(option.manualBugNumber)}
target="_blank"
rel="noopener noreferrer"
>
[view]
</a>
)}
</div>
)}
{option.id === 'manual' && !!option.manualBugNumber &&
<a
href={getBugUrl(option.manualBugNumber)}
target="_blank"
rel="noopener noreferrer"
>[view]</a>}
</div>}
{option.type === 'ignore' && <span
className={`line-option-text ignore ${canClassify ? '' : 'hidden'}`}
>Ignore line
<Select
value={ignoreAlways}
clearable={false}
classNamePrefix="ignore-option"
onChange={onIgnoreAlwaysChange}
bsSize="small"
options={[
{ value: false, label: 'Here only' },
{ value: true, label: 'For future classifications' },
]}
/>
</span>}
{option.type === 'ignore' && (
<span
className={`line-option-text ignore ${
canClassify ? '' : 'hidden'
}`}
>
Ignore line
<Select
value={ignoreAlways}
clearable={false}
classNamePrefix="ignore-option"
onChange={onIgnoreAlwaysChange}
bsSize="small"
options={[
{ value: false, label: 'Here only' },
{ value: true, label: 'For future classifications' },
]}
/>
</span>
)}
</Label>
</FormGroup>
{option.type === 'classifiedFailure' && <div className="classification-matchers">
Matched by:
{option.matches && option.matches.map(match => (<span key={match.matcher_name}>
{match.matcher_name} ({match.score})
</span>))}
</div>}
{isBugFilerOpen && <BugFiler
isOpen={isBugFilerOpen}
toggle={this.toggleBugFiler}
suggestion={errorLine.data.bug_suggestions}
suggestions={[errorLine.data.bug_suggestions]}
fullLog={logUrl}
parsedLog={`${window.location.origin}/${getLogViewerUrl(selectedJob.id, repoName)}`}
reftestUrl={isReftest(selectedJob) ? getReftestUrl(logUrl) : ''}
successCallback={this.bugFilerCallback}
jobGroupName={selectedJob.job_group_name}
/>}
{option.type === 'classifiedFailure' && (
<div className="classification-matchers">
Matched by:
{option.matches &&
option.matches.map(match => (
<span key={match.matcher_name}>
{match.matcher_name} ({match.score})
</span>
))}
</div>
)}
{isBugFilerOpen && (
<BugFiler
isOpen={isBugFilerOpen}
toggle={this.toggleBugFiler}
suggestion={errorLine.data.bug_suggestions}
suggestions={[errorLine.data.bug_suggestions]}
fullLog={logUrl}
parsedLog={`${window.location.origin}/${getLogViewerUrl(
selectedJob.id,
repoName,
)}`}
reftestUrl={isReftest(selectedJob) ? getReftestUrl(logUrl) : ''}
successCallback={this.bugFilerCallback}
jobGroupName={selectedJob.job_group_name}
/>
)}
</div>
);
}
}
LineOption.propTypes = {
selectedJob: PropTypes.object.isRequired,
errorLine: PropTypes.object.isRequired,

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

@ -1,8 +1,15 @@
import { extendProperties } from '../../../../helpers/object';
export default class LineOptionModel {
constructor(type, id, classifiedFailureId, bugNumber,
bugSummary, bugResolution, matches) {
constructor(
type,
id,
classifiedFailureId,
bugNumber,
bugSummary,
bugResolution,
matches,
) {
extendProperties(this, {
type,
id,

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

@ -12,66 +12,100 @@ import { withPinnedJobs } from '../../../context/PinnedJobs';
*/
function StaticLineOption(props) {
const {
selectedJob, canClassify, errorLine, option, numOptions, setEditable, ignoreAlways,
manualBugNumber, pinnedJobs, addBug,
selectedJob,
canClassify,
errorLine,
option,
numOptions,
setEditable,
ignoreAlways,
manualBugNumber,
pinnedJobs,
addBug,
} = props;
const optionCount = numOptions - 1;
const ignoreAlwaysText = ignoreAlways ? 'for future classifications' : 'here only';
const ignoreAlwaysText = ignoreAlways
? 'for future classifications'
: 'here only';
return (
<div className="static-classification-option">
<div className="classification-icon">
{option.isBest ?
<span className="fa fa-star-o" title="Autoclassifier best match" /> :
<span className="classification-no-icon">&nbsp;</span>}
{option.isBest ? (
<span className="fa fa-star-o" title="Autoclassifier best match" />
) : (
<span className="classification-no-icon">&nbsp;</span>
)}
</div>
{!!option.bugNumber && <span className="line-option-text">
{(!canClassify || selectedJob.id in pinnedJobs) &&
<button
className="btn btn-xs btn-light-bordered"
onClick={() => addBug({ id: option.bugNumber }, selectedJob)}
title="add to list of bugs to associate with all pinned jobs"
><i className="fa fa-thumb-tack" /></button>}
{!!option.bugResolution &&
<span className="classification-bug-resolution">[{option.bugResolution}]</span>}
<a
href={getBugUrl(option.bugNumber)}
target="_blank"
rel="noopener noreferrer"
>{option.bugNumber} -
<Highlighter
searchWords={getSearchWords(errorLine.data.bug_suggestions.search)}
textToHighlight={option.bugSummary}
caseSensitive
highlightTag="strong"
/>
</a>
<span>[ {Number.parseFloat(option.score).toPrecision(2)} ]</span>
</span>}
{!!option.bugNumber && (
<span className="line-option-text">
{(!canClassify || selectedJob.id in pinnedJobs) && (
<button
className="btn btn-xs btn-light-bordered"
onClick={() => addBug({ id: option.bugNumber }, selectedJob)}
title="add to list of bugs to associate with all pinned jobs"
>
<i className="fa fa-thumb-tack" />
</button>
)}
{!!option.bugResolution && (
<span className="classification-bug-resolution">
[{option.bugResolution}]
</span>
)}
<a
href={getBugUrl(option.bugNumber)}
target="_blank"
rel="noopener noreferrer"
>
{option.bugNumber} -
<Highlighter
searchWords={getSearchWords(
errorLine.data.bug_suggestions.search,
)}
textToHighlight={option.bugSummary}
caseSensitive
highlightTag="strong"
/>
</a>
<span>[ {Number.parseFloat(option.score).toPrecision(2)} ]</span>
</span>
)}
{option.type === 'classifiedFailure' && !option.bugNumber && <span>
Autoclassified failure with no associated bug number
</span>}
{option.type === 'classifiedFailure' && !option.bugNumber && (
<span>Autoclassified failure with no associated bug number</span>
)}
{option.type === 'manual' && <span className="line-option-text">
Bug
{!!manualBugNumber && <a
href={getBugUrl(option.manualBugNumber)}
target="_blank"
rel="noopener noreferrer"
>{manualBugNumber}</a>}
{!!manualBugNumber && <span>No bug number specified</span>}
</span>}
{option.type === 'manual' && (
<span className="line-option-text">
Bug
{!!manualBugNumber && (
<a
href={getBugUrl(option.manualBugNumber)}
target="_blank"
rel="noopener noreferrer"
>
{manualBugNumber}
</a>
)}
{!!manualBugNumber && <span>No bug number specified</span>}
</span>
)}
{option.type === 'ignore' &&
<span className="line-option-text">Ignore {ignoreAlwaysText}</span>}
{optionCount > 0 && <span>, {optionCount} other {optionCount === 1 ? 'option' : 'options'}
</span>}
{option.type === 'ignore' && (
<span className="line-option-text">Ignore {ignoreAlwaysText}</span>
)}
{optionCount > 0 && (
<span>
, {optionCount} other {optionCount === 1 ? 'option' : 'options'}
</span>
)}
<div>
<a onClick={setEditable} className="link-style">Edit</a>
<a onClick={setEditable} className="link-style">
Edit
</a>
</div>
</div>
);

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

@ -7,11 +7,8 @@ import { getBugUrl } from '../../../../helpers/url';
import { withSelectedJob } from '../../../context/SelectedJob';
import { withPinnedJobs } from '../../../context/PinnedJobs';
function BugListItem(props) {
const {
bug, suggestion, bugClassName, title, selectedJob, addBug,
} = props;
const { bug, suggestion, bugClassName, title, selectedJob, addBug } = props;
const bugUrl = getBugUrl(bug.id);
return (
@ -29,7 +26,8 @@ function BugListItem(props) {
target="_blank"
rel="noopener noreferrer"
title={title}
>{bug.id}
>
{bug.id}
<Highlighter
className={`${bugClassName} ml-1`}
searchWords={getSearchWords(suggestion.search)}

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

@ -5,19 +5,23 @@ export default function ErrorsList(props) {
const errorListItem = props.errors.map((error, key) => (
<li
key={key} // eslint-disable-line react/no-array-index-key
>{error.name} : {error.result}.
>
{error.name} : {error.result}.
<a
title="Open in Log Viewer"
target="_blank"
rel="noopener noreferrer"
href={error.logViewerUrl}
><span className="ml-1">View log</span></a>
>
<span className="ml-1">View log</span>
</a>
</li>
));
return (
<li>
No Bug Suggestions Available.<br />
No Bug Suggestions Available.
<br />
<span className="font-weight-bold">Unsuccessful Execution Steps</span>
<ul>{errorListItem}</ul>
</li>

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

@ -51,74 +51,104 @@ class FailureSummaryTab extends React.Component {
render() {
const {
jobLogUrls, logParseStatus, suggestions, errors, logViewerFullUrl,
bugSuggestionsLoading, selectedJob, reftestUrl,
jobLogUrls,
logParseStatus,
suggestions,
errors,
logViewerFullUrl,
bugSuggestionsLoading,
selectedJob,
reftestUrl,
} = this.props;
const { isBugFilerOpen, suggestion } = this.state;
const logs = jobLogUrls;
const jobLogsAllParsed = logs.every(jlu => (jlu.parse_status !== 'pending'));
const jobLogsAllParsed = logs.every(jlu => jlu.parse_status !== 'pending');
return (
<div className="w-100 h-100">
<ul className="list-unstyled failure-summary-list" ref={this.fsMount}>
{suggestions.map((suggestion, index) =>
(<SuggestionsListItem
{suggestions.map((suggestion, index) => (
<SuggestionsListItem
key={index} // eslint-disable-line react/no-array-index-key
index={index}
suggestion={suggestion}
toggleBugFiler={() => this.fileBug(suggestion)}
/>))}
/>
))}
{!!errors.length &&
<ErrorsList errors={errors} />}
{!!errors.length && <ErrorsList errors={errors} />}
{!bugSuggestionsLoading && jobLogsAllParsed &&
!logs.length && !suggestions.length && !errors.length &&
<ListItem text="Failure summary is empty" />}
{!bugSuggestionsLoading &&
jobLogsAllParsed &&
!logs.length &&
!suggestions.length &&
!errors.length && <ListItem text="Failure summary is empty" />}
{!bugSuggestionsLoading && jobLogsAllParsed && !!logs.length &&
logParseStatus === 'success' &&
<li>
<p className="failure-summary-line-empty mb-0">Log parsing complete. Generating bug suggestions.<br />
<span>The content of this panel will refresh in 5 seconds.</span></p>
</li>}
{!bugSuggestionsLoading &&
jobLogsAllParsed &&
!!logs.length &&
logParseStatus === 'success' && (
<li>
<p className="failure-summary-line-empty mb-0">
Log parsing complete. Generating bug suggestions.
<br />
<span>
The content of this panel will refresh in 5 seconds.
</span>
</p>
</li>
)}
{!bugSuggestionsLoading && !jobLogsAllParsed &&
logs.map(jobLog =>
(<li key={jobLog.id}>
<p className="failure-summary-line-empty mb-0">Log parsing in progress.<br />
<a
title="Open the raw log in a new window"
target="_blank"
rel="noopener noreferrer"
href={jobLog.url}
>The raw log</a> is available. This panel will automatically recheck every 5 seconds.</p>
</li>))}
{!bugSuggestionsLoading &&
!jobLogsAllParsed &&
logs.map(jobLog => (
<li key={jobLog.id}>
<p className="failure-summary-line-empty mb-0">
Log parsing in progress.
<br />
<a
title="Open the raw log in a new window"
target="_blank"
rel="noopener noreferrer"
href={jobLog.url}
>
The raw log
</a>{' '}
is available. This panel will automatically recheck every 5
seconds.
</p>
</li>
))}
{!bugSuggestionsLoading && logParseStatus === 'failed' &&
<ListItem text="Log parsing failed. Unable to generate failure summary." />}
{!bugSuggestionsLoading && logParseStatus === 'failed' && (
<ListItem text="Log parsing failed. Unable to generate failure summary." />
)}
{!bugSuggestionsLoading && !logs.length &&
<ListItem text="No logs available for this job." />}
{!bugSuggestionsLoading && !logs.length && (
<ListItem text="No logs available for this job." />
)}
{bugSuggestionsLoading &&
{bugSuggestionsLoading && (
<div className="overlay">
<div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
</div>
</div>}
</div>
)}
</ul>
{isBugFilerOpen && <BugFiler
isOpen={isBugFilerOpen}
toggle={this.toggleBugFiler}
suggestion={suggestion}
suggestions={suggestions}
fullLog={jobLogUrls[0].url}
parsedLog={logViewerFullUrl}
reftestUrl={isReftest(selectedJob) ? reftestUrl : ''}
successCallback={this.bugFilerCallback}
jobGroupName={selectedJob.job_group_name}
/>}
{isBugFilerOpen && (
<BugFiler
isOpen={isBugFilerOpen}
toggle={this.toggleBugFiler}
suggestion={suggestion}
suggestions={suggestions}
fullLog={jobLogUrls[0].url}
parsedLog={logViewerFullUrl}
reftestUrl={isReftest(selectedJob) ? reftestUrl : ''}
successCallback={this.bugFilerCallback}
jobGroupName={selectedJob.job_group_name}
/>
)}
</div>
);
}

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

@ -20,9 +20,7 @@ export default class SuggestionsListItem extends React.Component {
}
render() {
const {
suggestion, toggleBugFiler,
} = this.props;
const { suggestion, toggleBugFiler } = this.props;
const { suggestionShowMore } = this.state;
return (
@ -39,41 +37,48 @@ export default class SuggestionsListItem extends React.Component {
</div>
{/* <!--Open recent bugs--> */}
{suggestion.valid_open_recent &&
<ul className="list-unstyled failure-summary-bugs">
{suggestion.bugs.open_recent.map(bug =>
(<BugListItem
key={bug.id}
bug={bug}
suggestion={suggestion}
/>))}
</ul>}
{suggestion.valid_open_recent && (
<ul className="list-unstyled failure-summary-bugs">
{suggestion.bugs.open_recent.map(bug => (
<BugListItem key={bug.id} bug={bug} suggestion={suggestion} />
))}
</ul>
)}
{/* <!--All other bugs--> */}
{suggestion.valid_all_others && suggestion.valid_open_recent &&
<span
rel="noopener"
onClick={this.clickShowMore}
className="show-hide-more"
>Show / Hide more</span>}
{suggestion.valid_all_others && suggestion.valid_open_recent && (
<span
rel="noopener"
onClick={this.clickShowMore}
className="show-hide-more"
>
Show / Hide more
</span>
)}
{suggestion.valid_all_others && (suggestionShowMore
|| !suggestion.valid_open_recent) &&
<ul className="list-unstyled failure-summary-bugs">
{suggestion.bugs.all_others.map(bug =>
(<BugListItem
key={bug.id}
bug={bug}
suggestion={suggestion}
bugClassName={bug.resolution !== '' ? 'deleted' : ''}
title={bug.resolution !== '' ? bug.resolution : ''}
/>))}
</ul>}
{suggestion.valid_all_others &&
(suggestionShowMore || !suggestion.valid_open_recent) && (
<ul className="list-unstyled failure-summary-bugs">
{suggestion.bugs.all_others.map(bug => (
<BugListItem
key={bug.id}
bug={bug}
suggestion={suggestion}
bugClassName={bug.resolution !== '' ? 'deleted' : ''}
title={bug.resolution !== '' ? bug.resolution : ''}
/>
))}
</ul>
)}
{(suggestion.bugs.too_many_open_recent || (suggestion.bugs.too_many_all_others
&& !suggestion.valid_open_recent)) &&
<mark>Exceeded max {thBugSuggestionLimit} bug suggestions, most of which are likely false positives.</mark>}
{(suggestion.bugs.too_many_open_recent ||
(suggestion.bugs.too_many_all_others &&
!suggestion.valid_open_recent)) && (
<mark>
Exceeded max {thBugSuggestionLimit} bug suggestions, most of which
are likely false positives.
</mark>
)}
</li>
);
}

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

@ -48,7 +48,9 @@ export default class ActiveFilters extends React.Component {
const choice = fieldChoices[field];
const choiceValue = choice.choices.find(c => String(c.id) === value);
return choice.matchType === 'choice' && choiceValue ? choiceValue.name : value;
return choice.matchType === 'choice' && choiceValue
? choiceValue.name
: value;
}
addNewFieldFilter() {
@ -75,95 +77,142 @@ export default class ActiveFilters extends React.Component {
render() {
const { isFieldFilterVisible, filterModel, filterBarFilters } = this.props;
const {
newFilterField, newFilterMatchType, newFilterValue, newFilterChoices,
newFilterField,
newFilterMatchType,
newFilterValue,
newFilterChoices,
fieldChoices,
} = this.state;
return (
<div className="alert-info active-filters-bar">
{!!filterBarFilters.length && <div>
<span
className="pointable"
title="Clear all of these filters"
onClick={filterModel.clearNonStatusFilters}
><i className="fa fa-times-circle" /> </span>
<span className="active-filters-title">
<b>Active Filters</b>
</span>
{filterBarFilters.map(filter => (
filter.value.map(filterValue => (
<span className="filtersbar-filter" key={`${filter.field}${filterValue}`}>
{!!filterBarFilters.length && (
<div>
<span
className="pointable"
title="Clear all of these filters"
onClick={filterModel.clearNonStatusFilters}
>
<i className="fa fa-times-circle" />{' '}
</span>
<span className="active-filters-title">
<b>Active Filters</b>
</span>
{filterBarFilters.map(filter =>
filter.value.map(filterValue => (
<span
className="pointable"
title={`Clear filter: ${filter.field}`}
onClick={() => filterModel.removeFilter(filter.field, filterValue)}
className="filtersbar-filter"
key={`${filter.field}${filterValue}`}
>
<i className="fa fa-times-circle" />&nbsp;
<span
className="pointable"
title={`Clear filter: ${filter.field}`}
onClick={() =>
filterModel.removeFilter(filter.field, filterValue)
}
>
<i className="fa fa-times-circle" />
&nbsp;
</span>
<span title={`Filter by ${filter.field}: ${filterValue}`}>
<b>{filter.field}:</b>
{filter.field === 'failure_classification_id' && (
<span>
{' '}
{this.getFilterValue(filter.field, filterValue)}
</span>
)}
{filter.field === 'author' && (
<span> {filterValue.split('@')[0].substr(0, 20)}</span>
)}
{filter.field !== 'author' &&
filter.field !== 'failure_classification_id' && (
<span> {filterValue.substr(0, 12)}</span>
)}
</span>
</span>
<span title={`Filter by ${filter.field}: ${filterValue}`}>
<b>{filter.field}:</b>
{filter.field === 'failure_classification_id' && (
<span> {this.getFilterValue(filter.field, filterValue)}</span>
)),
)}
</div>
)}
{isFieldFilterVisible && (
<div>
<form className="form-inline">
<div className="form-group input-group-sm new-filter-input">
<label className="sr-only" htmlFor="job-filter-field">
Field
</label>
<select
id="job-filter-field"
className="form-control"
value={newFilterField}
onChange={evt => this.setNewFilterField(evt.target.value)}
placeholder="filter field"
required
>
<option value="" disabled>
select filter field
</option>
{Object.entries(fieldChoices).map(([field, obj]) =>
obj.name !== 'tier' ? (
<option value={field} key={field}>
{obj.name}
</option>
) : null,
)}
{filter.field === 'author' && <span> {filterValue.split('@')[0].substr(0, 20)}</span>}
{filter.field !== 'author' && filter.field !== 'failure_classification_id' && <span> {filterValue.substr(0, 12)}</span>}
</span>
</span>
))
))}
</div>}
{isFieldFilterVisible && <div>
<form className="form-inline">
<div className="form-group input-group-sm new-filter-input">
<label className="sr-only" htmlFor="job-filter-field">Field</label>
<select
id="job-filter-field"
className="form-control"
value={newFilterField}
onChange={evt => this.setNewFilterField(evt.target.value)}
placeholder="filter field"
required
>
<option value="" disabled>select filter field</option>
{Object.entries(fieldChoices).map(([field, obj]) => (
obj.name !== 'tier' ? <option value={field} key={field}>{obj.name}</option> : null
))}
</select>
<label className="sr-only" htmlFor="job-filter-value">Value</label>
{newFilterMatchType !== 'choice' && <input
className="form-control"
value={newFilterValue}
onChange={evt => this.setNewFilterValue(evt.target.value)}
id="job-filter-value"
type="text"
required
placeholder="enter filter value"
/>}
<label className="sr-only" htmlFor="job-filter-choice-value">Value</label>
{newFilterMatchType === 'choice' && <select
className="form-control"
value={newFilterValue}
onChange={evt => this.setNewFilterValue(evt.target.value)}
id="job-filter-choice-value"
>
<option value="" disabled>select value</option>
{Object.entries(newFilterChoices).map(([fci, fci_obj]) => (
<option value={fci_obj.id} key={fci}>{fci_obj.name}</option>
)) }
</select>}
<button
type="submit"
className="btn btn-light-bordered btn-sm"
onClick={this.addNewFieldFilter}
>add</button>
<button
type="reset"
className="btn btn-light-bordered btn-sm"
onClick={this.clearNewFieldFilter}
>cancel</button>
</div>
</form>
</div>}
</select>
<label className="sr-only" htmlFor="job-filter-value">
Value
</label>
{newFilterMatchType !== 'choice' && (
<input
className="form-control"
value={newFilterValue}
onChange={evt => this.setNewFilterValue(evt.target.value)}
id="job-filter-value"
type="text"
required
placeholder="enter filter value"
/>
)}
<label className="sr-only" htmlFor="job-filter-choice-value">
Value
</label>
{newFilterMatchType === 'choice' && (
<select
className="form-control"
value={newFilterValue}
onChange={evt => this.setNewFilterValue(evt.target.value)}
id="job-filter-choice-value"
>
<option value="" disabled>
select value
</option>
{Object.entries(newFilterChoices).map(([fci, fci_obj]) => (
<option value={fci_obj.id} key={fci}>
{fci_obj.name}
</option>
))}
</select>
)}
<button
type="submit"
className="btn btn-light-bordered btn-sm"
onClick={this.addNewFieldFilter}
>
add
</button>
<button
type="reset"
className="btn btn-light-bordered btn-sm"
onClick={this.clearNewFieldFilter}
>
cancel
</button>
</div>
</form>
</div>
)}
</div>
);
}

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

@ -6,11 +6,21 @@ import { withPinnedJobs } from '../context/PinnedJobs';
import { withSelectedJob } from '../context/SelectedJob';
import { withPushes } from '../context/Pushes';
const resultStatusMenuItems = thAllResultStatuses.filter(rs => rs !== 'runnable');
const resultStatusMenuItems = thAllResultStatuses.filter(
rs => rs !== 'runnable',
);
function FiltersMenu(props) {
const { filterModel, pinJobs, getAllShownJobs, selectedJob, setSelectedJob } = props;
const { urlParams: { resultStatus, classifiedState } } = filterModel;
const {
filterModel,
pinJobs,
getAllShownJobs,
selectedJob,
setSelectedJob,
} = props;
const {
urlParams: { resultStatus, classifiedState },
} = filterModel;
const pinAllShownJobs = () => {
const shownJobs = getAllShownJobs();
@ -29,7 +39,9 @@ function FiltersMenu(props) {
title="Set filters"
data-toggle="dropdown"
className="btn btn-view-nav nav-menu-btn dropdown-toggle"
>Filters</button>
>
Filters
</button>
<ul
id="filter-dropdown"
className="dropdown-menu nav-dropdown-menu-right checkbox-dropdown-menu"
@ -46,8 +58,11 @@ function FiltersMenu(props) {
className="mousetrap"
id={filterName}
checked={resultStatus.includes(filterName)}
onChange={() => filterModel.toggleResultStatuses([filterName])}
/>{filterName}
onChange={() =>
filterModel.toggleResultStatuses([filterName])
}
/>
{filterName}
</label>
</span>
</span>
@ -60,32 +75,42 @@ function FiltersMenu(props) {
id="classified"
checked={classifiedState.includes('classified')}
onChange={() => filterModel.toggleClassifiedFilter('classified')}
/>classified
/>
classified
</label>
<label className="dropdown-item">
<input
type="checkbox"
id="unclassified"
checked={classifiedState.includes('unclassified')}
onChange={() => filterModel.toggleClassifiedFilter('unclassified')}
/>unclassified
onChange={() =>
filterModel.toggleClassifiedFilter('unclassified')
}
/>
unclassified
</label>
<li className="dropdown-divider separator" />
<li
title="Pin all jobs that pass the global filters"
className="dropdown-item"
onClick={pinAllShownJobs}
>Pin all showing</li>
>
Pin all showing
</li>
<li
title="Show only superseded jobs"
className="dropdown-item"
onClick={filterModel.setOnlySuperseded}
>Superseded only</li>
>
Superseded only
</li>
<li
title="Reset to default status filters"
className="dropdown-item"
onClick={filterModel.resetNonFieldFilters}
>Reset</li>
>
Reset
</li>
</ul>
</span>
</span>

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

@ -17,7 +17,8 @@ const menuItems = [
text: 'API Reference',
},
{
href: 'https://wiki.mozilla.org/EngineeringProductivity/Projects/Treeherder',
href:
'https://wiki.mozilla.org/EngineeringProductivity/Projects/Treeherder',
icon: 'fa-file-word-o',
text: 'Project Wiki',
},
@ -27,7 +28,8 @@ const menuItems = [
text: 'Mailing List',
},
{
href: 'https://bugzilla.mozilla.org/enter_bug.cgi?product=Tree+Management&component=Treeherder',
href:
'https://bugzilla.mozilla.org/enter_bug.cgi?product=Tree+Management&component=Treeherder',
icon: 'fa-bug',
text: 'Report a Bug',
},
@ -37,9 +39,10 @@ const menuItems = [
text: 'Source',
},
{
href: 'https://whatsdeployed.io/?owner=mozilla&repo=treeherder&name[]=Stage&url[]=https://treeherder.allizom.org/revision.txt&name[]=Prod&url[]=https://treeherder.mozilla.org/revision.txt',
href:
'https://whatsdeployed.io/?owner=mozilla&repo=treeherder&name[]=Stage&url[]=https://treeherder.allizom.org/revision.txt&name[]=Prod&url[]=https://treeherder.mozilla.org/revision.txt',
icon: 'fa-question',
text: 'What\'s Deployed?',
text: "What's Deployed?",
},
];
@ -60,11 +63,19 @@ export default function HelpMenu() {
role="menu"
aria-labelledby="helpLabel"
>
{menuItems.map(item => (<li key={item.text}>
<a href={item.href} target="_blank" rel="noopener noreferrer" className="dropdown-item">
<span className={`fa ${item.icon} midgray`} />{item.text}
</a>
</li>))}
{menuItems.map(item => (
<li key={item.text}>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="dropdown-item"
>
<span className={`fa ${item.icon} midgray`} />
{item.text}
</a>
</li>
))}
</ul>
</span>
);

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

@ -8,21 +8,27 @@ export default function InfraMenu() {
title="Infrastructure status"
data-toggle="dropdown"
className="btn btn-view-nav nav-menu-btn dropdown-toggle"
>Infra</button>
>
Infra
</button>
<ul
id="infra-dropdown"
className="dropdown-menu nav-dropdown-menu-right container"
role="menu"
aria-labelledby="infraLabel"
>
<li role="presentation" className="dropdown-header">Buildbot</li>
<li role="presentation" className="dropdown-header">
Buildbot
</li>
<li>
<a
className="dropdown-item"
href="https://secure.pub.build.mozilla.org/buildapi/pending"
target="_blank"
rel="noopener noreferrer"
>BuildAPI: Pending</a>
>
BuildAPI: Pending
</a>
</li>
<li>
<a
@ -30,7 +36,9 @@ export default function InfraMenu() {
href="https://secure.pub.build.mozilla.org/buildapi/running"
target="_blank"
rel="noopener noreferrer"
>BuildAPI: Running</a>
>
BuildAPI: Running
</a>
</li>
<li>
<a
@ -38,7 +46,9 @@ export default function InfraMenu() {
href="https://www.hostedgraphite.com/da5c920d/86a8384e-d9cf-4208-989b-9538a1a53e4b/grafana2/#/dashboard/db/ec2-dashboard"
target="_blank"
rel="noopener noreferrer"
>EC2 Dashboard</a>
>
EC2 Dashboard
</a>
</li>
<li>
<a
@ -46,17 +56,23 @@ export default function InfraMenu() {
href="https://secure.pub.build.mozilla.org/builddata/reports/slave_health/"
target="_blank"
rel="noopener noreferrer"
>Slave Health</a>
>
Slave Health
</a>
</li>
<li role="presentation" className="dropdown-divider" />
<li role="presentation" className="dropdown-header">Other</li>
<li role="presentation" className="dropdown-header">
Other
</li>
<li>
<a
className="dropdown-item"
href="https://mozilla-releng.net/treestatus"
target="_blank"
rel="noopener noreferrer"
>TreeStatus</a>
>
TreeStatus
</a>
</li>
<li>
<a
@ -64,7 +80,9 @@ export default function InfraMenu() {
href="https://tools.taskcluster.net/diagnostics"
target="_blank"
rel="noopener noreferrer"
>Taskcluster</a>
>
Taskcluster
</a>
</li>
</ul>
</span>

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

@ -7,9 +7,12 @@ import { withNotifications } from '../../shared/context/Notifications';
class NotificationsMenu extends React.Component {
getSeverityClass(severity) {
switch (severity) {
case 'danger': return 'fa fa-ban text-danger';
case 'warning': return 'fa fa-warning text-warning';
case 'success': return 'fa fa-check text-success';
case 'danger':
return 'fa fa-ban text-danger';
case 'warning':
return 'fa fa-warning text-warning';
case 'success':
return 'fa fa-check text-success';
}
return 'fa fa-info-circle text-info';
}
@ -25,7 +28,9 @@ class NotificationsMenu extends React.Component {
aria-label="Recent notifications"
data-toggle="dropdown"
className="btn btn-view-nav nav-menu-btn"
><span className="fa fa-bell-o lightgray" /></button>
>
<span className="fa fa-bell-o lightgray" />
</button>
<ul
id="notification-dropdown"
className="dropdown-menu nav-dropdown-menu-right"
@ -36,35 +41,53 @@ class NotificationsMenu extends React.Component {
role="presentation"
className="dropdown-header"
title="Notifications"
>Recent notifications
{!!storedNotifications.length && <button
className="btn btn-xs btn-light-bordered notification-dropdown-btn"
title="Clear all notifications"
onClick={clearStoredNotifications}
>Clear all</button>}
>
Recent notifications
{!!storedNotifications.length && (
<button
className="btn btn-xs btn-light-bordered notification-dropdown-btn"
title="Clear all notifications"
onClick={clearStoredNotifications}
>
Clear all
</button>
)}
</li>
{storedNotifications.length ?
{storedNotifications.length ? (
storedNotifications.map(notification => (
<li
className="notification-dropdown-line"
key={`${notification.created}${notification.message}`}
>
<span title={`${notification.message} ${notification.linkText}`}>
<span className={this.getSeverityClass(notification.severity)} />&nbsp;
<span
title={`${notification.message} ${notification.linkText}`}
>
<span
className={this.getSeverityClass(notification.severity)}
/>
&nbsp;
<small className="text-muted">
{new Date(notification.created).toLocaleString('en-US', shortDateFormat)}
{new Date(notification.created).toLocaleString(
'en-US',
shortDateFormat,
)}
</small>
&nbsp;{notification.message}&nbsp;
<a
target="_blank"
rel="noopener noreferrer"
href={notification.url}
>{notification.linkText}</a>
>
{notification.linkText}
</a>
</span>
</li>
)) :
<li><span>No recent notifications</span></li>
}
))
) : (
<li>
<span>No recent notifications</span>
</li>
)}
</ul>
</span>
);

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

@ -15,9 +15,16 @@ import SecondaryNavBar from './SecondaryNavBar';
export default function PrimaryNavBar(props) {
const {
user, setUser, repos, updateButtonClick, serverChanged,
filterModel, setCurrentRepoTreeStatus, duplicateJobsVisible,
groupCountsExpanded, toggleFieldFilterVisible,
user,
setUser,
repos,
updateButtonClick,
serverChanged,
filterModel,
setCurrentRepoTreeStatus,
duplicateJobsVisible,
groupCountsExpanded,
toggleFieldFilterVisible,
} = props;
return (
@ -25,10 +32,7 @@ export default function PrimaryNavBar(props) {
<div id="th-global-top-nav-panel">
<nav id="th-global-navbar" className="navbar navbar-dark">
<div id="th-global-navbar-top">
<LogoMenu
menuText="Treeherder"
menuImage={Logo}
/>
<LogoMenu menuText="Treeherder" menuImage={Logo} />
<span className="navbar-right">
<NotificationsMenu />
<InfraMenu />

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

@ -16,9 +16,13 @@ const GROUP_ORDER = [
export default function ReposMenu(props) {
const { repos } = props;
const groups = repos.reduce((acc, repo, idx, arr, group = repo => repo.repository_group.name) => (
{ ...acc, [group(repo)]: [...acc[group(repo)] || [], repo] }
), {});
const groups = repos.reduce(
(acc, repo, idx, arr, group = repo => repo.repository_group.name) => ({
...acc,
[group(repo)]: [...(acc[group(repo)] || []), repo],
}),
{},
);
const groupedRepos = GROUP_ORDER.map(name => ({ name, repos: groups[name] }));
return (
@ -29,7 +33,9 @@ export default function ReposMenu(props) {
title="Watch a repo"
data-toggle="dropdown"
className="btn btn-view-nav nav-menu-btn dropdown-toggle"
>Repos</button>
>
Repos
</button>
<span
id="repo-dropdown"
className="dropdown-menu nav-dropdown-menu-right container"
@ -47,16 +53,21 @@ export default function ReposMenu(props) {
role="presentation"
className="dropdown-header"
title={group.name}
>{group.name} <span className="fa fa-info-circle" /></li>
{!!group.repos && group.repos.map(repo => (
<li key={repo.name}>
<a
title="Open repo"
className="dropdown-link"
href={getRepoUrl(repo.name)}
>{repo.name}</a>
</li>
))}
>
{group.name} <span className="fa fa-info-circle" />
</li>
{!!group.repos &&
group.repos.map(repo => (
<li key={repo.name}>
<a
title="Open repo"
className="dropdown-link"
href={getRepoUrl(repo.name)}
>
{repo.name}
</a>
</li>
))}
</span>
))}
</ul>
@ -66,7 +77,6 @@ export default function ReposMenu(props) {
);
}
ReposMenu.propTypes = {
repos: PropTypes.array.isRequired,
};

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

@ -3,11 +3,7 @@ import PropTypes from 'prop-types';
import { getBtnClass } from '../../helpers/job';
import { thFilterGroups } from '../../helpers/filter';
import {
getRepo,
getUrlParam,
setUrlParam,
} from '../../helpers/location';
import { getRepo, getUrlParam, setUrlParam } from '../../helpers/location';
import RepositoryModel from '../../models/repository';
import ErrorBoundary from '../../shared/ErrorBoundary';
import { withPushes } from '../context/Pushes';
@ -29,7 +25,8 @@ class SecondaryNavBar extends React.Component {
this.filterChicklets = [
'failures',
thFilterGroups.nonfailures,
'in progress'].reduce((acc, val) => acc.concat(val), []);
'in progress',
].reduce((acc, val) => acc.concat(val), []);
this.state = {
searchQueryStr: getSearchStrFromUrl(),
@ -40,7 +37,9 @@ class SecondaryNavBar extends React.Component {
componentDidMount() {
this.toggleGroupState = this.toggleGroupState.bind(this);
this.toggleUnclassifiedFailures = this.toggleUnclassifiedFailures.bind(this);
this.toggleUnclassifiedFailures = this.toggleUnclassifiedFailures.bind(
this,
);
this.clearFilterBox = this.clearFilterBox.bind(this);
this.unwatchRepo = this.unwatchRepo.bind(this);
this.handleUrlChanges = this.handleUrlChanges.bind(this);
@ -93,9 +92,10 @@ class SecondaryNavBar extends React.Component {
*/
toggleResultStatusFilterChicklet(filter) {
const { filterModel } = this.props;
const filterValues = filter in thFilterGroups ?
thFilterGroups[filter] : // this is a filter grouping, so toggle all on/off
[filter];
const filterValues =
filter in thFilterGroups
? thFilterGroups[filter] // this is a filter grouping, so toggle all on/off
: [filter];
filterModel.toggleResultStatuses(filterValues);
}
@ -136,11 +136,12 @@ class SecondaryNavBar extends React.Component {
const { repoName } = this.state;
try {
const storedWatched = JSON.parse(localStorage.getItem(WATCHED_REPOS_STORAGE_KEY)) || [];
const storedWatched =
JSON.parse(localStorage.getItem(WATCHED_REPOS_STORAGE_KEY)) || [];
// Ensure the current repo is first in the list
const watchedRepoNames = [
repoName,
...storedWatched.filter(value => (value !== repoName)),
...storedWatched.filter(value => value !== repoName),
].slice(0, MAX_WATCHED_REPOS);
// Re-save the list, in case it has now changed
@ -162,19 +163,27 @@ class SecondaryNavBar extends React.Component {
render() {
const {
updateButtonClick, serverChanged, setCurrentRepoTreeStatus, repos,
allUnclassifiedFailureCount, filteredUnclassifiedFailureCount,
groupCountsExpanded, duplicateJobsVisible, toggleFieldFilterVisible,
updateButtonClick,
serverChanged,
setCurrentRepoTreeStatus,
repos,
allUnclassifiedFailureCount,
filteredUnclassifiedFailureCount,
groupCountsExpanded,
duplicateJobsVisible,
toggleFieldFilterVisible,
} = this.props;
const {
watchedRepoNames, searchQueryStr, repoName,
} = this.state;
const { watchedRepoNames, searchQueryStr, repoName } = this.state;
// This array needs to be RepositoryModel objects, not strings.
// If ``repos`` is not yet populated, then leave as empty array.
// We need to filter just in case some of these repo names do not exist.
// This could happen if the user typed an invalid ``repo`` param on the URL
const watchedRepos = (repos.length && watchedRepoNames.map(
name => RepositoryModel.getRepo(name, repos)).filter(name => name)) || [];
const watchedRepos =
(repos.length &&
watchedRepoNames
.map(name => RepositoryModel.getRepo(name, repos))
.filter(name => name)) ||
[];
return (
<div
@ -200,42 +209,64 @@ class SecondaryNavBar extends React.Component {
))}
</span>
<form role="search" className="form-inline flex-row">
{serverChanged && <span
className="btn btn-sm btn-view-nav nav-menu-btn"
onClick={updateButtonClick}
id="revisionChangedLabel"
title="New version of Treeherder has been deployed. Reload to pick up changes."
>
<span className="fa fa-exclamation-circle" />&nbsp;Treeherder update available
</span>}
{serverChanged && (
<span
className="btn btn-sm btn-view-nav nav-menu-btn"
onClick={updateButtonClick}
id="revisionChangedLabel"
title="New version of Treeherder has been deployed. Reload to pick up changes."
>
<span className="fa fa-exclamation-circle" />
&nbsp;Treeherder update available
</span>
)}
{/* Unclassified Failures Button */}
<span
className={`btn btn-sm ${allUnclassifiedFailureCount ? 'btn-unclassified-failures' : 'btn-view-nav'}`}
className={`btn btn-sm ${
allUnclassifiedFailureCount
? 'btn-unclassified-failures'
: 'btn-view-nav'
}`}
title="Loaded failures / toggle filtering for unclassified failures"
tabIndex="-1"
role="button"
onClick={this.toggleUnclassifiedFailures}
>
<span id="unclassified-failure-count">{allUnclassifiedFailureCount}</span> unclassified
<span id="unclassified-failure-count">
{allUnclassifiedFailureCount}
</span>{' '}
unclassified
</span>
{/* Filtered Unclassified Failures Button */}
{filteredUnclassifiedFailureCount !== allUnclassifiedFailureCount &&
<span
className="navbar-badge badge badge-secondary badge-pill"
title="Reflects the unclassified failures which pass the current filters"
>
<span id="filtered-unclassified-failure-count">{filteredUnclassifiedFailureCount}</span>
</span>}
{filteredUnclassifiedFailureCount !==
allUnclassifiedFailureCount && (
<span
className="navbar-badge badge badge-secondary badge-pill"
title="Reflects the unclassified failures which pass the current filters"
>
<span id="filtered-unclassified-failure-count">
{filteredUnclassifiedFailureCount}
</span>
</span>
)}
{/* Toggle Duplicate Jobs */}
<span
className={`btn btn-view-nav btn-sm btn-toggle-duplicate-jobs ${groupCountsExpanded ? 'disabled' : ''} ${!duplicateJobsVisible ? 'strikethrough' : ''}`}
className={`btn btn-view-nav btn-sm btn-toggle-duplicate-jobs ${
groupCountsExpanded ? 'disabled' : ''
} ${!duplicateJobsVisible ? 'strikethrough' : ''}`}
tabIndex="0"
role="button"
title={duplicateJobsVisible ? 'Hide duplicate jobs' : 'Show duplicate jobs'}
onClick={() => !groupCountsExpanded && this.toggleShowDuplicateJobs()}
title={
duplicateJobsVisible
? 'Hide duplicate jobs'
: 'Show duplicate jobs'
}
onClick={() =>
!groupCountsExpanded && this.toggleShowDuplicateJobs()
}
/>
<span className="btn-group">
{/* Toggle Group State Button */}
@ -243,28 +274,45 @@ class SecondaryNavBar extends React.Component {
className="btn btn-view-nav btn-sm btn-toggle-group-state"
tabIndex="-1"
role="button"
title={groupCountsExpanded ? 'Collapse job groups' : 'Expand job groups'}
title={
groupCountsExpanded
? 'Collapse job groups'
: 'Expand job groups'
}
onClick={() => this.toggleGroupState()}
>( <span className="group-state-nav-icon">{groupCountsExpanded ? '-' : '+'}</span> )
>
({' '}
<span className="group-state-nav-icon">
{groupCountsExpanded ? '-' : '+'}
</span>{' '}
)
</span>
</span>
{/* Result Status Filter Chicklets */}
<span className="resultStatusChicklets">
<span id="filter-chicklets">
{this.filterChicklets.map((filterName) => {
{this.filterChicklets.map(filterName => {
const isOn = this.isFilterOn(filterName);
return (<span key={filterName}>
<span
className={`btn btn-view-nav btn-sm btn-nav-filter ${getBtnClass(filterName)}-filter-chicklet fa ${isOn ? 'fa-dot-circle-o' : 'fa-circle-thin'}`}
onClick={() => this.toggleResultStatusFilterChicklet(filterName)}
title={filterName}
aria-label={filterName}
role="checkbox"
aria-checked={isOn}
tabIndex={0}
/>
</span>);
return (
<span key={filterName}>
<span
className={`btn btn-view-nav btn-sm btn-nav-filter ${getBtnClass(
filterName,
)}-filter-chicklet fa ${
isOn ? 'fa-dot-circle-o' : 'fa-circle-thin'
}`}
onClick={() =>
this.toggleResultStatusFilterChicklet(filterName)
}
title={filterName}
aria-label={filterName}
role="checkbox"
aria-checked={isOn}
tabIndex={0}
/>
</span>
);
})}
</span>
</span>
@ -274,7 +322,9 @@ class SecondaryNavBar extends React.Component {
className="btn btn-view-nav btn-sm"
onClick={toggleFieldFilterVisible}
title="Filter by a job field"
><i className="fa fa-filter" /></span>
>
<i className="fa fa-filter" />
</span>
</span>
{/* Quick Filter Field */}

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

@ -15,30 +15,36 @@ export default function TiersMenu(props) {
title="Show/hide job tiers"
data-toggle="dropdown"
className="btn btn-view-nav btn-sm nav-menu-btn dropdown-toggle"
>Tiers</span>
<ul
className="dropdown-menu checkbox-dropdown-menu"
role="menu"
>
{TIERS.map((tier) => {
Tiers
</span>
<ul className="dropdown-menu checkbox-dropdown-menu" role="menu">
{TIERS.map(tier => {
const isOnlyTier = shownTiers.length === 1 && tier === shownTiers[0];
return (<li key={tier}>
<div>
<label
title={isOnlyTier ? 'Must have at least one tier selected at all times' : ''}
className={`dropdown-item ${isOnlyTier ? 'disabled' : ''}`}
>
<input
id="tier-checkbox"
type="checkbox"
className="mousetrap"
disabled={isOnlyTier}
checked={shownTiers.includes(tier)}
onChange={() => filterModel.toggleFilter('tier', tier)}
/>tier {tier}
</label>
</div>
</li>);
return (
<li key={tier}>
<div>
<label
title={
isOnlyTier
? 'Must have at least one tier selected at all times'
: ''
}
className={`dropdown-item ${isOnlyTier ? 'disabled' : ''}`}
>
<input
id="tier-checkbox"
type="checkbox"
className="mousetrap"
disabled={isOnlyTier}
checked={shownTiers.includes(tier)}
onChange={() => filterModel.toggleFilter('tier', tier)}
/>
tier {tier}
</label>
</div>
</li>
);
})}
</ul>
</span>

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

@ -8,12 +8,15 @@ export default function UpdateAvailable(props) {
return (
<div className="alert alert-info update-alert-panel">
<i className="fa fa-info-circle" aria-hidden="true" />
Treeherder has updated. To pick up the changes, you can reload the page &nbsp;
Treeherder has updated. To pick up the changes, you can reload the page
&nbsp;
<button
onClick={updateButtonClick}
className="btn btn-xs btn-danger"
type="button"
>Reload</button>
>
Reload
</button>
</div>
);
}

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

@ -6,37 +6,37 @@ import BugLinkify from '../../shared/BugLinkify';
import { getRepoUrl } from '../../helpers/url';
const statusInfoMap = {
open: {
icon: 'fa-circle-o',
color: 'tree-open',
btnClass: 'btn-view-nav',
},
'approval required': {
icon: 'fa-lock',
color: 'tree-approval',
btnClass: 'btn-view-nav',
},
closed: {
icon: 'fa-times-circle',
color: 'tree-closed',
btnClass: 'btn-view-nav-closed',
},
unsupported: {
icon: 'fa-question',
color: 'tree-unavailable',
btnClass: 'btn-view-nav',
},
'not retrieved yet': {
icon: 'fa-spinner',
pulseIcon: 'fa-pulse',
color: 'tree-unavailable',
btnClass: 'btn-view-nav',
},
error: {
icon: 'fa-question',
color: 'tree-unavailable',
btnClass: 'btn-view-nav',
},
open: {
icon: 'fa-circle-o',
color: 'tree-open',
btnClass: 'btn-view-nav',
},
'approval required': {
icon: 'fa-lock',
color: 'tree-approval',
btnClass: 'btn-view-nav',
},
closed: {
icon: 'fa-times-circle',
color: 'tree-closed',
btnClass: 'btn-view-nav-closed',
},
unsupported: {
icon: 'fa-question',
color: 'tree-unavailable',
btnClass: 'btn-view-nav',
},
'not retrieved yet': {
icon: 'fa-spinner',
pulseIcon: 'fa-pulse',
color: 'tree-unavailable',
btnClass: 'btn-view-nav',
},
error: {
icon: 'fa-question',
color: 'tree-unavailable',
btnClass: 'btn-view-nav',
},
};
export default class WatchedRepo extends React.Component {
@ -63,7 +63,10 @@ export default class WatchedRepo extends React.Component {
this.updateTreeStatus();
// update the TreeStatus every 2 minutes
this.treeStatusIntervalId = setInterval(this.updateTreeStatus, 2 * 60 * 1000);
this.treeStatusIntervalId = setInterval(
this.updateTreeStatus,
2 * 60 * 1000,
);
}
componentWillUnmount() {
@ -81,7 +84,7 @@ export default class WatchedRepo extends React.Component {
const { repo, repoName, setCurrentRepoTreeStatus } = this.props;
const watchedRepoName = repo.name;
TreeStatusModel.get(watchedRepoName).then((data) => {
TreeStatusModel.get(watchedRepoName).then(data => {
const treeStatus = data.result;
if (watchedRepoName === repoName) {
@ -100,7 +103,11 @@ export default class WatchedRepo extends React.Component {
render() {
const { repoName, unwatchRepo, repo } = this.props;
const {
status, messageOfTheDay, reason, statusInfo, hasBoundaryError,
status,
messageOfTheDay,
reason,
statusInfo,
hasBoundaryError,
boundaryError,
} = this.state;
const watchedRepo = repo.name;
@ -115,7 +122,9 @@ export default class WatchedRepo extends React.Component {
<span
className="btn-view-nav pl-1 pr-1 border-right"
title={boundaryError.toString()}
>Error getting {watchedRepo} info</span>
>
Error getting {watchedRepo} info
</span>
);
}
return (
@ -134,39 +143,64 @@ export default class WatchedRepo extends React.Component {
title={`${watchedRepo} info`}
aria-label={`${watchedRepo} info`}
data-toggle="dropdown"
><span className="fa fa-info-circle" /></button>
{watchedRepo !== repoName && <button
className={`watched-repo-unwatch-btn btn btn-sm btn-view-nav ${activeClass}`}
onClick={() => unwatchRepo(watchedRepo)}
title={`Unwatch ${watchedRepo}`}
><span className="fa fa-times" /></button>}
>
<span className="fa fa-info-circle" />
</button>
{watchedRepo !== repoName && (
<button
className={`watched-repo-unwatch-btn btn btn-sm btn-view-nav ${activeClass}`}
onClick={() => unwatchRepo(watchedRepo)}
title={`Unwatch ${watchedRepo}`}
>
<span className="fa fa-times" />
</button>
)}
<ul className="dropdown-menu" role="menu">
{status === 'unsupported' && <React.Fragment>
{status === 'unsupported' && (
<React.Fragment>
<li className="watched-repo-dropdown-item">
<span>
{watchedRepo} is not listed on{' '}
<a
href="https://mozilla-releng.net/treestatus"
target="_blank"
rel="noopener noreferrer"
>
Tree Status
</a>
</span>
</li>
<li className="dropdown-divider" />
</React.Fragment>
)}
{!!reason && (
<li className="watched-repo-dropdown-item">
<span>{watchedRepo} is not listed on <a
href="https://mozilla-releng.net/treestatus"
target="_blank"
rel="noopener noreferrer"
>Tree Status</a></span>
<span>
<BugLinkify>{reason}</BugLinkify>
</span>
</li>
<li className="dropdown-divider" />
</React.Fragment>}
{!!reason && <li className="watched-repo-dropdown-item">
<span><BugLinkify>{reason}</BugLinkify></span>
</li>}
)}
{!!reason && !!messageOfTheDay && <li className="dropdown-divider" />}
{!!messageOfTheDay && <li className="watched-repo-dropdown-item">
<span><BugLinkify>{messageOfTheDay}</BugLinkify></span>
</li>}
{(!!reason || !!messageOfTheDay) && <li className="dropdown-divider" />}
{!!messageOfTheDay && (
<li className="watched-repo-dropdown-item">
<span>
<BugLinkify>{messageOfTheDay}</BugLinkify>
</span>
</li>
)}
{(!!reason || !!messageOfTheDay) && (
<li className="dropdown-divider" />
)}
<li className="watched-repo-dropdown-item">
<a
href={`https://mozilla-releng.net/treestatus/show/${treeStatusName}`}
className="dropdown-item"
target="_blank"
rel="noopener noreferrer"
>Tree Status</a>
>
Tree Status
</a>
</li>
<li className="watched-repo-dropdown-item">
<a
@ -174,7 +208,9 @@ export default class WatchedRepo extends React.Component {
className="dropdown-item"
target="_blank"
rel="noopener noreferrer"
>Pushlog</a>
>
Pushlog
</a>
</li>
</ul>
</span>

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

@ -71,9 +71,18 @@ export default class JobButtonComponent extends React.Component {
render() {
const { job } = this.props;
const { isSelected, isRunnableSelected } = this.state;
const { state, job_type_name, failure_classification_id, end_timestamp,
start_timestamp, ref_data_name, visible, id,
job_type_symbol, result } = job;
const {
state,
job_type_name,
failure_classification_id,
end_timestamp,
start_timestamp,
ref_data_name,
visible,
id,
job_type_symbol,
result,
} = job;
if (!visible) return null;
const resultStatus = state === 'completed' ? result : state;
@ -111,9 +120,7 @@ export default class JobButtonComponent extends React.Component {
}
attributes.className = classes.join(' ');
return (
<button {...attributes}>{job_type_symbol}</button>
);
return <button {...attributes}>{job_type_symbol}</button>;
}
}

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

@ -3,14 +3,15 @@ import PropTypes from 'prop-types';
export default function JobCount(props) {
const { className, title, onClick, count } = props;
const classes = [className, 'btn group-btn btn-xs job-group-count filter-shown'];
const classes = [
className,
'btn group-btn btn-xs job-group-count filter-shown',
];
return (
<button
className={classes.join(' ')}
title={title}
onClick={onClick}
>{count}</button>
<button className={classes.join(' ')} title={title} onClick={onClick}>
{count}
</button>
);
}

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

@ -14,10 +14,9 @@ const GroupSymbol = function GroupSymbol(props) {
const { symbol, tier, toggleExpanded } = props;
return (
<button
className="btn group-symbol"
onClick={toggleExpanded}
>{symbol}{tier !== 1 && <span className="small text-muted">[tier {tier}]</span>}
<button className="btn group-symbol" onClick={toggleExpanded}>
{symbol}
{tier !== 1 && <span className="small text-muted">[tier {tier}]</span>}
</button>
);
};
@ -32,7 +31,6 @@ GroupSymbol.defaultProps = {
tier: 1,
};
export class JobGroupComponent extends React.Component {
constructor(props) {
super(props);
@ -49,7 +47,9 @@ export class JobGroupComponent extends React.Component {
static getDerivedStateFromProps(nextProps, state) {
// We should expand this group if it's own state is set to be expanded,
// or if the push was set to have all groups expanded.
return { expanded: state.expanded || nextProps.pushGroupState === 'expanded' };
return {
expanded: state.expanded || nextProps.pushGroupState === 'expanded',
};
}
setExpanded(isExpanded) {
@ -61,7 +61,11 @@ export class JobGroupComponent extends React.Component {
}
groupButtonsAndCounts(jobs, expanded) {
const { selectedJob, duplicateJobsVisible, groupCountsExpanded } = this.props;
const {
selectedJob,
duplicateJobsVisible,
groupCountsExpanded,
} = this.props;
let buttons = [];
const counts = [];
@ -71,20 +75,25 @@ export class JobGroupComponent extends React.Component {
} else {
const stateCounts = {};
const typeSymbolCounts = countBy(jobs, 'job_type_symbol');
jobs.forEach((job) => {
jobs.forEach(job => {
if (!job.visible) return;
const status = getStatus(job);
let countInfo = {
btnClass: getBtnClass(status, job.failure_classification_id),
countText: status,
};
if (thFailureResults.includes(status) ||
(typeSymbolCounts[job.job_type_symbol] > 1 && duplicateJobsVisible)) {
if (
thFailureResults.includes(status) ||
(typeSymbolCounts[job.job_type_symbol] > 1 && duplicateJobsVisible)
) {
// render the job itself, not a count
buttons.push(job);
} else {
countInfo = { ...countInfo, ...stateCounts[countInfo.btnClass] };
if ((selectedJob && selectedJob.id === job.id) || countInfo.selectedClasses) {
if (
(selectedJob && selectedJob.id === job.id) ||
countInfo.selectedClasses
) {
countInfo.selectedClasses = ' selected-count btn-lg-xform';
} else {
countInfo.selectedClasses = '';
@ -111,8 +120,17 @@ export class JobGroupComponent extends React.Component {
render() {
const {
repoName, filterPlatformCb, platform, filterModel,
group: { name: groupName, symbol: groupSymbol, tier: groupTier, jobs: groupJobs, mapKey: groupMapKey },
repoName,
filterPlatformCb,
platform,
filterModel,
group: {
name: groupName,
symbol: groupSymbol,
tier: groupTier,
jobs: groupJobs,
mapKey: groupMapKey,
},
} = this.props;
const { expanded } = this.state;
const { buttons, counts } = this.groupButtonsAndCounts(groupJobs, expanded);
@ -120,14 +138,8 @@ export class JobGroupComponent extends React.Component {
this.toggleExpanded = this.toggleExpanded.bind(this);
return (
<span
className="platform-group"
data-group-key={groupMapKey}
>
<span
className="disabled job-group"
title={groupName}
>
<span className="platform-group" data-group-key={groupMapKey}>
<span className="disabled job-group" title={groupName}>
<GroupSymbol
symbol={groupSymbol}
tier={groupTier}
@ -154,8 +166,12 @@ export class JobGroupComponent extends React.Component {
<JobCount
count={countInfo.count}
onClick={this.toggleExpanded}
className={`${countInfo.btnClass}-count${countInfo.selectedClasses}`}
title={`${countInfo.count} ${countInfo.countText} jobs in group`}
className={`${countInfo.btnClass}-count${
countInfo.selectedClasses
}`}
title={`${countInfo.count} ${
countInfo.countText
} jobs in group`}
key={countInfo.lastJob.id}
/>
))}

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

@ -9,43 +9,49 @@ import JobGroup from './JobGroup';
export default class JobsAndGroups extends React.Component {
render() {
const {
groups, repoName, platform, filterPlatformCb, filterModel,
pushGroupState, duplicateJobsVisible, groupCountsExpanded,
groups,
repoName,
platform,
filterPlatformCb,
filterModel,
pushGroupState,
duplicateJobsVisible,
groupCountsExpanded,
} = this.props;
return (
<td className="job-row">
{groups.map((group) => {
{groups.map(group => {
if (group.tier !== 1 || group.symbol !== '') {
return (
group.visible && <JobGroup
group={group}
repoName={repoName}
filterModel={filterModel}
filterPlatformCb={filterPlatformCb}
platform={platform}
key={group.mapKey}
pushGroupState={pushGroupState}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
/>
group.visible && (
<JobGroup
group={group}
repoName={repoName}
filterModel={filterModel}
filterPlatformCb={filterPlatformCb}
platform={platform}
key={group.mapKey}
pushGroupState={pushGroupState}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
/>
)
);
}
return (
group.jobs.map(job => (
<JobButton
job={job}
filterModel={filterModel}
repoName={repoName}
visible={job.visible}
status={getStatus(job)}
failureClassificationId={job.failure_classification_id}
filterPlatformCb={filterPlatformCb}
platform={platform}
key={job.id}
/>
))
);
return group.jobs.map(job => (
<JobButton
job={job}
filterModel={filterModel}
repoName={repoName}
visible={job.visible}
status={getStatus(job)}
failureClassificationId={job.failure_classification_id}
filterPlatformCb={filterPlatformCb}
platform={platform}
key={job.id}
/>
));
})}
</td>
);

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

@ -18,8 +18,13 @@ PlatformName.propTypes = {
export default function Platform(props) {
const {
platform, repoName, filterPlatformCb, filterModel, pushGroupState,
duplicateJobsVisible, groupCountsExpanded,
platform,
repoName,
filterPlatformCb,
filterModel,
pushGroupState,
duplicateJobsVisible,
groupCountsExpanded,
} = props;
const { title, groups, id } = platform;

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

@ -2,7 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import sortBy from 'lodash/sortBy';
import { thEvents, thOptionOrder, thPlatformMap } from '../../helpers/constants';
import {
thEvents,
thOptionOrder,
thPlatformMap,
} from '../../helpers/constants';
import { withPushes } from '../context/Pushes';
import { escapeId, getGroupMapKey } from '../../helpers/aggregateId';
import { getAllUrlParams } from '../../helpers/location';
@ -64,20 +68,32 @@ class Push extends React.Component {
}
getJobCount(jobList) {
return jobList.reduce((memo, job) => (
job.result !== 'superseded' ? { ...memo, [job.state]: memo[job.state] + 1 } : memo
), { running: 0, pending: 0, completed: 0 },
return jobList.reduce(
(memo, job) =>
job.result !== 'superseded'
? { ...memo, [job.state]: memo[job.state] + 1 }
: memo,
{ running: 0, pending: 0, completed: 0 },
);
}
getJobGroupInfo(job) {
const {
job_group_name: name, job_group_symbol,
platform, platform_option, tier, push_id,
job_group_name: name,
job_group_symbol,
platform,
platform_option,
tier,
push_id,
} = job;
const symbol = job_group_symbol === '?' ? '' : job_group_symbol;
const mapKey = getGroupMapKey(
push_id, symbol, tier, platform, platform_option);
push_id,
symbol,
tier,
platform,
platform_option,
);
return { name, tier, symbol, mapKey };
}
@ -87,7 +103,9 @@ class Push extends React.Component {
const percentComplete = getPercentComplete(this.state.jobCounts);
const title = `[${allUnclassifiedFailureCount}] ${repoName}`;
document.title = `${percentComplete}% - ${title}: ${getRevisionTitle(push.revisions)}`;
document.title = `${percentComplete}% - ${title}: ${getRevisionTitle(
push.revisions,
)}`;
}
handleApplyNewJobs(event) {
@ -132,7 +150,9 @@ class Push extends React.Component {
// remove old versions of jobs we just fetched.
const existingJobs = jobList.filter(job => !newIds.includes(job.id));
const newJobList = [...existingJobs, ...jobs];
const platforms = this.sortGroupedJobs(this.groupJobByPlatform(newJobList));
const platforms = this.sortGroupedJobs(
this.groupJobByPlatform(newJobList),
);
const jobCounts = this.getJobCount(newJobList);
this.setState({
@ -156,12 +176,13 @@ class Push extends React.Component {
if (jobList.length === 0) {
return platforms;
}
jobList.forEach((job) => {
jobList.forEach(job => {
// search for the right platform
const platformName = thPlatformMap[job.platform] || job.platform;
let platform = platforms.find(platform =>
platformName === platform.name &&
job.platform_option === platform.option,
let platform = platforms.find(
platform =>
platformName === platform.name &&
job.platform_option === platform.option,
);
if (platform === undefined) {
platform = {
@ -174,9 +195,9 @@ class Push extends React.Component {
const groupInfo = this.getJobGroupInfo(job);
// search for the right group
let group = platform.groups.find(group =>
groupInfo.symbol === group.symbol &&
groupInfo.tier === group.tier,
let group = platform.groups.find(
group =>
groupInfo.symbol === group.symbol && groupInfo.tier === group.tier,
);
if (group === undefined) {
group = { ...groupInfo, jobs: [] };
@ -188,21 +209,26 @@ class Push extends React.Component {
}
sortGroupedJobs(platforms) {
platforms.forEach((platform) => {
platform.groups.forEach((group) => {
group.jobs = sortBy(group.jobs, job => (
platforms.forEach(platform => {
platform.groups.forEach(group => {
group.jobs = sortBy(group.jobs, job =>
// Symbol could be something like 1, 2 or 3. Or A, B, C or R1, R2, R10.
// So this will pad the numeric portion with 0s like R001, R010, etc.
job.job_type_symbol.replace(/([\D]*)([\d]*)/g,
(matcher, s1, s2) => (s2 !== '' ? s1 + `00${s2}`.slice(-3) : matcher))
));
job.job_type_symbol.replace(/([\D]*)([\d]*)/g, (matcher, s1, s2) =>
s2 !== '' ? s1 + `00${s2}`.slice(-3) : matcher,
),
);
});
platform.groups.sort((a, b) => a.symbol.length + a.tier - b.symbol.length - b.tier);
platform.groups.sort(
(a, b) => a.symbol.length + a.tier - b.symbol.length - b.tier,
);
});
platforms.sort((a, b) => (
(platformArray.indexOf(a.name) * 100 + (thOptionOrder[a.option] || 10)) -
(platformArray.indexOf(b.name) * 100 + (thOptionOrder[b.option] || 10))
));
platforms.sort(
(a, b) =>
platformArray.indexOf(a.name) * 100 +
(thOptionOrder[a.option] || 10) -
(platformArray.indexOf(b.name) * 100 + (thOptionOrder[b.option] || 10)),
);
return platforms;
}
@ -219,10 +245,17 @@ class Push extends React.Component {
showUpdateNotifications(prevState) {
const { watched, jobCounts } = this.state;
const {
repoName, notificationSupported, push: { revision, id: pushId }, notify,
repoName,
notificationSupported,
push: { revision, id: pushId },
notify,
} = this.props;
if (!notificationSupported || Notification.permission !== 'granted' || watched === 'none') {
if (
!notificationSupported ||
Notification.permission !== 'granted' ||
watched === 'none'
) {
return;
}
@ -249,11 +282,11 @@ class Push extends React.Component {
tag: pushId,
});
notification.onerror = (event) => {
notification.onerror = event => {
notify(`${event.target.title}: ${event.target.body}`, 'danger');
};
notification.onclick = (event) => {
notification.onclick = event => {
if (this.container) {
this.container.scrollIntoView();
event.target.close();
@ -268,10 +301,12 @@ class Push extends React.Component {
try {
const decisionTaskId = await getGeckoDecisionTaskId(push.id, repoName);
const jobList = await RunnableJobModel.getList(repoName, { decision_task_id: decisionTaskId });
const jobList = await RunnableJobModel.getList(repoName, {
decision_task_id: decisionTaskId,
});
const { id } = push;
jobList.forEach((job) => {
jobList.forEach(job => {
job.push_id = id;
job.id = escapeId(job.push_id + job.ref_data_name);
});
@ -281,7 +316,10 @@ class Push extends React.Component {
this.mapPushJobs(jobList, true);
this.setState({ runnableVisible: jobList.length > 0 });
} catch (error) {
notify(`Error fetching runnable jobs: Failed to fetch task ID (${error})`, 'danger');
notify(
`Error fetching runnable jobs: Failed to fetch task ID (${error})`,
'danger',
);
}
}
@ -289,11 +327,14 @@ class Push extends React.Component {
const { jobList } = this.state;
const newJobList = jobList.filter(job => job.state !== 'runnable');
this.setState({
runnableVisible: false,
selectedRunnableJobs: [],
jobList: newJobList,
}, () => this.mapPushJobs(newJobList));
this.setState(
{
runnableVisible: false,
selectedRunnableJobs: [],
jobList: newJobList,
},
() => this.mapPushJobs(newJobList),
);
}
async cycleWatchState() {
@ -303,7 +344,8 @@ class Push extends React.Component {
return;
}
let next = watchCycleStates[watchCycleStates.indexOf(this.state.watched) + 1];
let next =
watchCycleStates[watchCycleStates.indexOf(this.state.watched) + 1];
if (next !== 'none' && Notification.permission !== 'granted') {
const result = await Notification.requestPermission();
@ -319,13 +361,24 @@ class Push extends React.Component {
render() {
const {
push, isLoggedIn, repoName, currentRepo, duplicateJobsVisible,
filterModel, notificationSupported, getAllShownJobs, groupCountsExpanded,
push,
isLoggedIn,
repoName,
currentRepo,
duplicateJobsVisible,
filterModel,
notificationSupported,
getAllShownJobs,
groupCountsExpanded,
isOnlyRevision,
} = this.props;
const {
watched, runnableVisible, pushGroupState,
platforms, jobCounts, selectedRunnableJobs,
watched,
runnableVisible,
pushGroupState,
platforms,
jobCounts,
selectedRunnableJobs,
} = this.state;
const { id, push_timestamp, revision, author } = push;
@ -334,7 +387,12 @@ class Push extends React.Component {
}
return (
<div className="push" ref={(ref) => { this.container = ref; }}>
<div
className="push"
ref={ref => {
this.container = ref;
}}
>
<PushHeader
push={push}
pushId={id}
@ -357,12 +415,7 @@ class Push extends React.Component {
/>
<div className="push-body-divider" />
<div className="row push clearfix">
{currentRepo &&
<RevisionList
push={push}
repo={currentRepo}
/>
}
{currentRepo && <RevisionList push={push} repo={currentRepo} />}
<span className="job-list job-list-pad col-7">
<PushJobs
push={push}

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

@ -56,19 +56,27 @@ class PushActionMenu extends React.PureComponent {
triggerMissingJobs() {
const { getGeckoDecisionTaskId, notify } = this.props;
if (!window.confirm(`This will trigger all missing jobs for revision ${this.revision}!\n\nClick "OK" if you want to proceed.`)) {
if (
!window.confirm(
`This will trigger all missing jobs for revision ${
this.revision
}!\n\nClick "OK" if you want to proceed.`,
)
) {
return;
}
getGeckoDecisionTaskId(this.pushId)
.then((decisionTaskID) => {
.then(decisionTaskID => {
PushModel.triggerMissingJobs(decisionTaskID)
.then((msg) => {
.then(msg => {
notify(msg, 'success');
}).catch((e) => {
})
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
}).catch((e) => {
})
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
}
@ -76,24 +84,38 @@ class PushActionMenu extends React.PureComponent {
triggerAllTalosJobs() {
const { getGeckoDecisionTaskId, notify } = this.props;
if (!window.confirm(`This will trigger all Talos jobs for revision ${this.revision}!\n\nClick "OK" if you want to proceed.`)) {
if (
!window.confirm(
`This will trigger all Talos jobs for revision ${
this.revision
}!\n\nClick "OK" if you want to proceed.`,
)
) {
return;
}
let times = parseInt(window.prompt('Enter number of instances to have for each talos job', 6), 10);
let times = parseInt(
window.prompt('Enter number of instances to have for each talos job', 6),
10,
);
while (times < 1 || times > 6 || Number.isNaN(times)) {
times = window.prompt('We only allow instances of each talos job to be between 1 to 6 times. Enter again', 6);
times = window.prompt(
'We only allow instances of each talos job to be between 1 to 6 times. Enter again',
6,
);
}
getGeckoDecisionTaskId(this.pushId)
.then((decisionTaskID) => {
.then(decisionTaskID => {
PushModel.triggerAllTalosJobs(times, decisionTaskID)
.then((msg) => {
.then(msg => {
notify(msg, 'success');
}).catch((e) => {
})
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
}).catch((e) => {
})
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
}
@ -105,9 +127,20 @@ class PushActionMenu extends React.PureComponent {
}
render() {
const { isLoggedIn, repoName, revision, runnableVisible,
hideRunnableJobs, showRunnableJobs, pushId } = this.props;
const { topOfRangeUrl, bottomOfRangeUrl, customJobActionsShowing } = this.state;
const {
isLoggedIn,
repoName,
revision,
runnableVisible,
hideRunnableJobs,
showRunnableJobs,
pushId,
} = this.props;
const {
topOfRangeUrl,
bottomOfRangeUrl,
customJobActionsShowing,
} = this.state;
return (
<span className="btn-group dropdown" dropdown="true">
@ -124,57 +157,96 @@ class PushActionMenu extends React.PureComponent {
</button>
<ul className="dropdown-menu pull-right">
{runnableVisible ?
{runnableVisible ? (
<li
title="Hide Runnable Jobs"
className="dropdown-item"
onClick={hideRunnableJobs}
>Hide Runnable Jobs</li> :
>
Hide Runnable Jobs
</li>
) : (
<li
title={isLoggedIn ? 'Add new jobs to this push' : 'Must be logged in'}
className={isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'}
title={
isLoggedIn ? 'Add new jobs to this push' : 'Must be logged in'
}
className={
isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'
}
onClick={showRunnableJobs}
>Add new jobs</li>
}
{this.triggerMissingRepos.includes(repoName) &&
>
Add new jobs
</li>
)}
{this.triggerMissingRepos.includes(repoName) && (
<li
title={isLoggedIn ? 'Trigger all jobs that were optimized away' : 'Must be logged in'}
className={isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'}
title={
isLoggedIn
? 'Trigger all jobs that were optimized away'
: 'Must be logged in'
}
className={
isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'
}
onClick={() => this.triggerMissingJobs(revision)}
>Trigger missing jobs</li>
}
>
Trigger missing jobs
</li>
)}
<li
title={isLoggedIn ? 'Trigger all talos performance tests' : 'Must be logged in'}
title={
isLoggedIn
? 'Trigger all talos performance tests'
: 'Must be logged in'
}
className={isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'}
onClick={() => this.triggerAllTalosJobs(revision)}
>Trigger all Talos jobs</li>
<li><a
target="_blank"
rel="noopener noreferrer"
className="dropdown-item"
href={`https://bugherder.mozilla.org/?cset=${revision}&tree=${repoName}`}
title="Use Bugherder to mark the bugs in this push"
>Mark with Bugherder</a></li>
>
Trigger all Talos jobs
</li>
<li>
<a
target="_blank"
rel="noopener noreferrer"
className="dropdown-item"
href={`https://bugherder.mozilla.org/?cset=${revision}&tree=${repoName}`}
title="Use Bugherder to mark the bugs in this push"
>
Mark with Bugherder
</a>
</li>
<li
className="dropdown-item"
onClick={this.toggleCustomJobActions}
title="View/Edit/Submit Action tasks for this push"
>Custom Push Action...</li>
<li><a
className="dropdown-item top-of-range-menu-item"
href={topOfRangeUrl}
>Set as top of range</a></li>
<li><a
className="dropdown-item bottom-of-range-menu-item"
href={bottomOfRangeUrl}
>Set as bottom of range</a></li>
>
Custom Push Action...
</li>
<li>
<a
className="dropdown-item top-of-range-menu-item"
href={topOfRangeUrl}
>
Set as top of range
</a>
</li>
<li>
<a
className="dropdown-item bottom-of-range-menu-item"
href={bottomOfRangeUrl}
>
Set as bottom of range
</a>
</li>
</ul>
{customJobActionsShowing && <CustomJobActions
job={null}
pushId={pushId}
isLoggedIn={isLoggedIn}
toggle={this.toggleCustomJobActions}
/>}
{customJobActionsShowing && (
<CustomJobActions
job={null}
pushId={pushId}
isLoggedIn={isLoggedIn}
toggle={this.toggleCustomJobActions}
/>
)}
</span>
);
}

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

@ -16,7 +16,13 @@ import PushActionMenu from './PushActionMenu';
// url params we don't want added from the current querystring to the revision
// and author links.
const SKIPPED_LINK_PARAMS = [
'revision', 'fromchange', 'tochange', 'nojobs', 'startdate', 'enddate', 'author',
'revision',
'fromchange',
'tochange',
'nojobs',
'startdate',
'enddate',
'author',
];
function Author(props) {
@ -43,14 +49,12 @@ function PushCounts(props) {
return (
<span className="push-progress">
{percentComplete === 100 &&
<span>- Complete -</span>
}
{percentComplete < 100 && total > 0 &&
<span
title="Proportion of jobs that are complete"
>{percentComplete}% - {inProgress} in progress</span>
}
{percentComplete === 100 && <span>- Complete -</span>}
{percentComplete < 100 && total > 0 && (
<span title="Proportion of jobs that are complete">
{percentComplete}% - {inProgress} in progress
</span>
)}
</span>
);
}
@ -78,34 +82,45 @@ class PushHeader extends React.PureComponent {
getLinkParams() {
const { filterModel } = this.props;
return Object.entries(filterModel.getUrlParamsWithoutDefaults())
.reduce((acc, [field, values]) => (
SKIPPED_LINK_PARAMS.includes(field) ? acc : { ...acc, [field]: values }
), {});
return Object.entries(filterModel.getUrlParamsWithoutDefaults()).reduce(
(acc, [field, values]) =>
SKIPPED_LINK_PARAMS.includes(field) ? acc : { ...acc, [field]: values },
{},
);
}
triggerNewJobs() {
const {
isLoggedIn, pushId, getGeckoDecisionTaskId, selectedRunnableJobs,
hideRunnableJobs, notify,
isLoggedIn,
pushId,
getGeckoDecisionTaskId,
selectedRunnableJobs,
hideRunnableJobs,
notify,
} = this.props;
if (!window.confirm(
'This will trigger all selected jobs. Click "OK" if you want to proceed.')) {
if (
!window.confirm(
'This will trigger all selected jobs. Click "OK" if you want to proceed.',
)
) {
return;
}
if (isLoggedIn) {
const builderNames = selectedRunnableJobs;
getGeckoDecisionTaskId(pushId)
.then((decisionTaskID) => {
PushModel.triggerNewJobs(builderNames, decisionTaskID).then((result) => {
notify(result, 'success');
hideRunnableJobs(pushId);
this.props.hideRunnableJobs();
}).catch((e) => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
}).catch((e) => {
.then(decisionTaskID => {
PushModel.triggerNewJobs(builderNames, decisionTaskID)
.then(result => {
notify(result, 'success');
hideRunnableJobs(pushId);
this.props.hideRunnableJobs();
})
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
})
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
} else {
@ -116,7 +131,11 @@ class PushHeader extends React.PureComponent {
cancelAllJobs() {
const { notify, repoName } = this.props;
if (window.confirm('This will cancel all pending and running jobs for this push. It cannot be undone! Are you sure?')) {
if (
window.confirm(
'This will cancel all pending and running jobs for this push. It cannot be undone! Are you sure?',
)
) {
const { push, isLoggedIn, getGeckoDecisionTaskId } = this.props;
if (!isLoggedIn) return;
@ -127,8 +146,13 @@ class PushHeader extends React.PureComponent {
pinAllShownJobs() {
const {
selectedJob, setSelectedJob, pinJobs, expandAllPushGroups, getAllShownJobs,
notify, pushId,
selectedJob,
setSelectedJob,
pinJobs,
expandAllPushGroups,
getAllShownJobs,
notify,
pushId,
} = this.props;
const shownJobs = getAllShownJobs(pushId);
@ -145,13 +169,24 @@ class PushHeader extends React.PureComponent {
}
render() {
const { repoName, isLoggedIn, pushId, jobCounts, author,
revision, runnableVisible, watchState,
showRunnableJobs, hideRunnableJobs, cycleWatchState,
notificationSupported, selectedRunnableJobs } = this.props;
const cancelJobsTitle = isLoggedIn ?
'Cancel all jobs' :
'Must be logged in to cancel jobs';
const {
repoName,
isLoggedIn,
pushId,
jobCounts,
author,
revision,
runnableVisible,
watchState,
showRunnableJobs,
hideRunnableJobs,
cycleWatchState,
notificationSupported,
selectedRunnableJobs,
} = this.props;
const cancelJobsTitle = isLoggedIn
? 'Cancel all jobs'
: 'Must be logged in to cancel jobs';
const linkParams = this.getLinkParams();
const revisionPushFilterUrl = getJobsUrl({ ...linkParams, revision });
const authorPushFilterUrl = getJobsUrl({ ...linkParams, author });
@ -168,11 +203,12 @@ class PushHeader extends React.PureComponent {
<span className="push-left">
<span className="push-title-left">
<span>
<a
href={revisionPushFilterUrl}
title="View only this push"
>{this.pushDateStr} <span className="fa fa-external-link icon-superscript" />
</a> - </span>
<a href={revisionPushFilterUrl} title="View only this push">
{this.pushDateStr}{' '}
<span className="fa fa-external-link icon-superscript" />
</a>{' '}
-{' '}
</span>
<Author author={author} url={authorPushFilterUrl} />
</span>
</span>
@ -183,49 +219,56 @@ class PushHeader extends React.PureComponent {
completed={jobCounts.completed}
/>
<span className="push-buttons">
{jobCounts.pending + jobCounts.running > 0 &&
{jobCounts.pending + jobCounts.running > 0 && (
<button
className="btn btn-sm btn-push watch-commit-btn"
disabled={!notificationSupported}
title={notificationSupported ? 'Get Desktop Notifications for this Push' : 'Desktop notifications not supported in this browser'}
title={
notificationSupported
? 'Get Desktop Notifications for this Push'
: 'Desktop notifications not supported in this browser'
}
data-watch-state={watchState}
onClick={() => cycleWatchState()}
>{watchStateLabel}</button>}
>
{watchStateLabel}
</button>
)}
<a
className="btn btn-sm btn-push test-view-btn"
href={`/testview.html?repo=${repoName}&revision=${revision}`}
target="_blank"
rel="noopener noreferrer"
title="View details on failed test results for this push"
>View Tests</a>
{isLoggedIn &&
>
View Tests
</a>
{isLoggedIn && (
<button
className="btn btn-sm btn-push cancel-all-jobs-btn"
title={cancelJobsTitle}
onClick={this.cancelAllJobs}
>
<span
className="fa fa-times-circle cancel-job-icon dim-quarter"
/>
<span className="fa fa-times-circle cancel-job-icon dim-quarter" />
</button>
}
)}
<button
className="btn btn-sm btn-push pin-all-jobs-btn"
title="Pin all available jobs in this push"
aria-label="Pin all available jobs in this push"
onClick={this.pinAllShownJobs}
>
<span
className="fa fa-thumb-tack"
/>
<span className="fa fa-thumb-tack" />
</button>
{!!selectedRunnableJobs.length && runnableVisible &&
{!!selectedRunnableJobs.length && runnableVisible && (
<button
className="btn btn-sm btn-push trigger-new-jobs-btn"
title="Trigger new jobs"
onClick={this.triggerNewJobs}
>Trigger New Jobs</button>
}
>
Trigger New Jobs
</button>
)}
<PushActionMenu
isLoggedIn={isLoggedIn}
runnableVisible={runnableVisible}
@ -273,4 +316,6 @@ PushHeader.defaultProps = {
watchState: 'none',
};
export default withNotifications(withPushes(withSelectedJob(withPinnedJobs(PushHeader))));
export default withNotifications(
withPushes(withSelectedJob(withPinnedJobs(PushHeader))),
);

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

@ -19,20 +19,38 @@ class PushJobs extends React.Component {
const selectedJobId = parseInt(getUrlParam('selectedJob'), 10);
const filteredPlatforms = platforms.reduce((acc, platform) => {
const thisPlatform = { ...platform };
const suffix = (thSimplePlatforms.includes(platform.name) && platform.option === 'opt') ? '' : ` ${platform.option}`;
const suffix =
thSimplePlatforms.includes(platform.name) && platform.option === 'opt'
? ''
: ` ${platform.option}`;
thisPlatform.title = `${thisPlatform.name}${suffix}`;
thisPlatform.visible = true;
return [...acc, PushJobs.filterPlatform(thisPlatform, selectedJobId, push, filterModel, runnableVisible)];
return [
...acc,
PushJobs.filterPlatform(
thisPlatform,
selectedJobId,
push,
filterModel,
runnableVisible,
),
];
}, []);
return { filteredPlatforms };
}
static filterPlatform(platform, selectedJobId, push, filterModel, runnableVisible) {
static filterPlatform(
platform,
selectedJobId,
push,
filterModel,
runnableVisible,
) {
platform.visible = false;
platform.groups.forEach((group) => {
platform.groups.forEach(group => {
group.visible = false;
group.jobs.forEach((job) => {
group.jobs.forEach(job => {
job.visible = filterModel.showJob(job) || job.id === selectedJobId;
if (job.state === 'runnable') {
job.visible = job.visible && runnableVisible;
@ -52,11 +70,7 @@ class PushJobs extends React.Component {
const { push, repoName } = this.props;
this.pushId = push.id;
this.aggregateId = getPushTableId(
repoName,
this.pushId,
push.revision,
);
this.aggregateId = getPushTableId(repoName, this.pushId, push.revision);
this.state = {
filteredPlatforms: [],
@ -75,14 +89,17 @@ class PushJobs extends React.Component {
if (jobInstance && jobInstance.props.job) {
const { job } = jobInstance.props;
if (ev.button === 1) { // Middle click
if (ev.button === 1) {
// Middle click
this.handleLogViewerClick(job.id);
} else if (ev.metaKey || ev.ctrlKey) { // Pin job
} else if (ev.metaKey || ev.ctrlKey) {
// Pin job
if (!selectedJob) {
this.selectJob(job, ev.target);
}
togglePinJob(job);
} else if (job && job.state === 'runnable') { // Toggle runnable
} else if (job && job.state === 'runnable') {
// Toggle runnable
this.handleRunnableClick(jobInstance);
} else {
this.selectJob(job, ev.target); // Left click
@ -106,13 +123,11 @@ class PushJobs extends React.Component {
handleLogViewerClick(jobId) {
// Open logviewer in a new window
const { repoName } = this.props;
JobModel.get(
repoName,
jobId,
).then((data) => {
JobModel.get(repoName, jobId).then(data => {
if (data.logs.length > 0) {
window.open(`${window.location.origin}/${
getLogViewerUrl(jobId, repoName)}`);
window.open(
`${window.location.origin}/${getLogViewerUrl(jobId, repoName)}`,
);
}
});
}
@ -130,7 +145,13 @@ class PushJobs extends React.Component {
// This actually filters the platform in-place. So we just need to
// trigger a re-render by giving it a new ``filteredPlatforms`` object instance.
PushJobs.filterPlatform(platform, selectedJobId, push, filterModel, runnableVisible);
PushJobs.filterPlatform(
platform,
selectedJobId,
push,
filterModel,
runnableVisible,
);
if (filteredPlatforms.length) {
this.setState({ filteredPlatforms: [...filteredPlatforms] });
}
@ -139,28 +160,39 @@ class PushJobs extends React.Component {
render() {
const filteredPlatforms = this.state.filteredPlatforms || [];
const {
repoName, filterModel, pushGroupState, duplicateJobsVisible,
repoName,
filterModel,
pushGroupState,
duplicateJobsVisible,
groupCountsExpanded,
} = this.props;
return (
<table id={this.aggregateId} className="table-hover">
<tbody onMouseDown={this.onMouseDown}>
{filteredPlatforms ? filteredPlatforms.map(platform => (
platform.visible &&
<Platform
platform={platform}
repoName={repoName}
key={platform.title}
filterModel={filterModel}
pushGroupState={pushGroupState}
filterPlatformCb={this.filterPlatformCallback}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
/>
)) : <tr>
<td><span className="fa fa-spinner fa-pulse th-spinner" /></td>
</tr>}
{filteredPlatforms ? (
filteredPlatforms.map(
platform =>
platform.visible && (
<Platform
platform={platform}
repoName={repoName}
key={platform.title}
filterModel={filterModel}
pushGroupState={pushGroupState}
filterPlatformCb={this.filterPlatformCallback}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
/>
),
)
) : (
<tr>
<td>
<span className="fa fa-spinner fa-pulse th-spinner" />
</td>
</tr>
)}
</tbody>
</table>
);

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

@ -24,8 +24,16 @@ class PushList extends React.Component {
render() {
const {
user, repoName, revision, currentRepo, filterModel, pushList,
loadingPushes, getNextPushes, jobsLoaded, duplicateJobsVisible,
user,
repoName,
revision,
currentRepo,
filterModel,
pushList,
loadingPushes,
getNextPushes,
jobsLoaded,
duplicateJobsVisible,
groupCountsExpanded,
} = this.props;
const { notificationSupported } = this.state;
@ -38,48 +46,51 @@ class PushList extends React.Component {
return (
<div>
{jobsLoaded && <span className="hidden ready" />}
{repoName && pushList.map(push => (
<ErrorBoundary
errorClasses="pl-2 border-top border-bottom border-dark d-block"
message={`Error on push with revision ${push.revision}: `}
key={push.id}
>
<Push
push={push}
isLoggedIn={isLoggedIn || false}
currentRepo={currentRepo}
repoName={repoName}
filterModel={filterModel}
notificationSupported={notificationSupported}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
isOnlyRevision={push.revision === revision}
/>
</ErrorBoundary>
))}
{loadingPushes &&
{repoName &&
pushList.map(push => (
<ErrorBoundary
errorClasses="pl-2 border-top border-bottom border-dark d-block"
message={`Error on push with revision ${push.revision}: `}
key={push.id}
>
<Push
push={push}
isLoggedIn={isLoggedIn || false}
currentRepo={currentRepo}
repoName={repoName}
filterModel={filterModel}
notificationSupported={notificationSupported}
duplicateJobsVisible={duplicateJobsVisible}
groupCountsExpanded={groupCountsExpanded}
isOnlyRevision={push.revision === revision}
/>
</ErrorBoundary>
))}
{loadingPushes && (
<div
className="progress active progress-bar progress-bar-striped"
role="progressbar"
/>
}
{pushList.length === 0 && !loadingPushes &&
)}
{pushList.length === 0 && !loadingPushes && (
<PushLoadErrors
loadingPushes={loadingPushes}
currentRepo={currentRepo}
repoName={repoName}
revision={revision}
/>
}
)}
<div className="card card-body get-next">
<span>get next:</span>
<div className="btn-group">
{[10, 20, 50].map(count => (
<div
className="btn btn-light-bordered"
onClick={() => (getNextPushes(count))}
onClick={() => getNextPushes(count)}
key={count}
>{count}</div>
>
{count}
</div>
))}
</div>
</div>

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

@ -10,60 +10,79 @@ function PushLoadErrors(props) {
const urlParams = getAllUrlParams();
urlParams.delete('revision');
const isRevision = revision => (
revision && (revision.length === 12 || revision.length === 40)
);
const isRevision = revision =>
revision && (revision.length === 12 || revision.length === 40);
return (
<div className="push-load-errors">
{!loadingPushes && isRevision(revision) && currentRepo && currentRepo.url &&
<div className="push-body unknown-message-body">
<span>
{revision &&
<div>
<p>
Waiting for push with revision&nbsp;
<a
href={currentRepo.getPushLogHref(revision)}
target="_blank"
rel="noopener noreferrer"
title={`Open revision ${revision} on ${currentRepo.url}`}
>{revision}</a>
&nbsp;
<span className="fa fa-spinner fa-pulse th-spinner" />
</p>
<p>If the push exists, it will appear in a few minutes once it has been processed.</p>
</div>
}
</span>
</div>
}
{!loadingPushes && !isRevision(revision) && currentRepo.url &&
{!loadingPushes &&
isRevision(revision) &&
currentRepo &&
currentRepo.url && (
<div className="push-body unknown-message-body">
<span>
{revision && (
<div>
<p>
Waiting for push with revision&nbsp;
<a
href={currentRepo.getPushLogHref(revision)}
target="_blank"
rel="noopener noreferrer"
title={`Open revision ${revision} on ${currentRepo.url}`}
>
{revision}
</a>
&nbsp;
<span className="fa fa-spinner fa-pulse th-spinner" />
</p>
<p>
If the push exists, it will appear in a few minutes once it
has been processed.
</p>
</div>
)}
</span>
</div>
)}
{!loadingPushes && !isRevision(revision) && currentRepo.url && (
<div className="push-body unknown-message-body">
This is an invalid or unknown revision. Please change it, or click
<a href={`${uiJobsUrlBase}?${urlParams.toString()}`}> here</a> to reload the latest revisions from {repoName}.
<a href={`${uiJobsUrlBase}?${urlParams.toString()}`}> here</a> to
reload the latest revisions from {repoName}.
</div>
}
{!loadingPushes && !revision && currentRepo && currentRepo.url &&
)}
{!loadingPushes && !revision && currentRepo && currentRepo.url && (
<div className="push-body unknown-message-body">
<span>
<div><b>No pushes found.</b></div>
<span>No commit information could be loaded for this repository.
More information about this repository can be found <a href={currentRepo.url}>here</a>.</span>
</span>
</div>
}
{!loadingPushes && !currentRepo.url &&
<div className="push-body unknown-message-body">
<span>
<div><b>Unknown repository.</b></div>
<span>This repository is either unknown to Treeherder or it does not exist.
If this repository does exist, please <a href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Tree%20Management&component=Treeherder">
file a bug against the Treeherder product in Bugzilla</a> to get it added to the system.
<div>
<b>No pushes found.</b>
</div>
<span>
No commit information could be loaded for this repository. More
information about this repository can be found{' '}
<a href={currentRepo.url}>here</a>.
</span>
</span>
</div>
}
)}
{!loadingPushes && !currentRepo.url && (
<div className="push-body unknown-message-body">
<span>
<div>
<b>Unknown repository.</b>
</div>
<span>
This repository is either unknown to Treeherder or it does not
exist. If this repository does exist, please{' '}
<a href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Tree%20Management&component=Treeherder">
file a bug against the Treeherder product in Bugzilla
</a>{' '}
to get it added to the system.
</span>
</span>
</div>
)}
</div>
);
}

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

@ -7,9 +7,9 @@ import BugLinkify from '../../shared/BugLinkify';
export function Initials(props) {
const str = props.author || '';
const words = str.split(' ');
const firstLetters = words.map(
word => word.replace(/[^A-Z]/gi, '')[0],
).filter(firstLetter => typeof firstLetter !== 'undefined');
const firstLetters = words
.map(word => word.replace(/[^A-Z]/gi, '')[0])
.filter(firstLetter => typeof firstLetter !== 'undefined');
let initials = '';
if (firstLetters.length === 1) {
@ -41,8 +41,11 @@ export class Revision extends React.PureComponent {
// eslint-disable-next-line prefer-destructuring
this.comment = revision.comments.split('\n')[0];
this.tags = this.comment.search('Backed out') >= 0 || this.comment.search('Back out') >= 0 ?
'backout' : '';
this.tags =
this.comment.search('Backed out') >= 0 ||
this.comment.search('Back out') >= 0
? 'backout'
: '';
}
render() {
@ -50,28 +53,28 @@ export class Revision extends React.PureComponent {
const { name, email } = parseAuthor(revision.author);
const commitRevision = revision.revision;
return (<li className="clearfix">
<span className="revision" data-tags={this.tags}>
<span className="revision-holder">
<a
title={`Open revision ${commitRevision} on ${repo.url}`}
href={repo.getRevisionHref(commitRevision)}
>{commitRevision.substring(0, 12)}
</a>
</span>
<Initials
title={`${name}: ${email}`}
author={name}
/>
<span title={this.comment}>
<span className="revision-comment">
<em>
<BugLinkify>{this.comment}</BugLinkify>
</em>
return (
<li className="clearfix">
<span className="revision" data-tags={this.tags}>
<span className="revision-holder">
<a
title={`Open revision ${commitRevision} on ${repo.url}`}
href={repo.getRevisionHref(commitRevision)}
>
{commitRevision.substring(0, 12)}
</a>
</span>
<Initials title={`${name}: ${email}`} author={name} />
<span title={this.comment}>
<span className="revision-comment">
<em>
<BugLinkify>{this.comment}</BugLinkify>
</em>
</span>
</span>
</span>
</span>
</li>);
</li>
);
}
}

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

@ -15,19 +15,15 @@ export class RevisionList extends React.PureComponent {
return (
<span className="revision-list col-5">
<ul className="list-unstyled">
{push.revisions.map(revision =>
(<Revision
revision={revision}
repo={repo}
key={revision.revision}
/>),
{push.revisions.map(revision => (
<Revision revision={revision} repo={repo} key={revision.revision} />
))}
{this.hasMore && (
<MoreRevisionsLink
key="more"
href={repo.getPushLogHref(push.revision)}
/>
)}
{this.hasMore &&
<MoreRevisionsLink
key="more"
href={repo.getPushLogHref(push.revision)}
/>
}
</ul>
</span>
);
@ -42,11 +38,10 @@ RevisionList.propTypes = {
export function MoreRevisionsLink(props) {
return (
<li>
<a
href={props.href}
target="_blank"
rel="noopener noreferrer"
>{'\u2026and more'}<i className="fa fa-external-link-square" /></a>
<a href={props.href} target="_blank" rel="noopener noreferrer">
{'\u2026and more'}
<i className="fa fa-external-link-square" />
</a>
</li>
);
}

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

@ -49,7 +49,9 @@ class LoginCallback extends React.PureComponent {
}
setError(err) {
this.setState({ loginError: err.message ? err.message : err.errorDescription });
this.setState({
loginError: err.message ? err.message : err.errorDescription,
});
}
render() {

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

@ -37,7 +37,6 @@ const errorLinesCss = function errorLinesCss(errors) {
style.sheet.insertRule(rule);
};
class App extends React.PureComponent {
constructor(props) {
super(props);
@ -64,48 +63,64 @@ class App extends React.PureComponent {
componentDidMount() {
const { repoName, jobId } = this.state;
JobModel.get(repoName, jobId).then(async (job) => {
// set the title of the browser window/tab
document.title = job.getTitle();
const rawLogUrl = job.logs && job.logs.length ? job.logs[0].url : null;
// other properties, in order of appearance
// Test to disable successful steps checkbox on taskcluster jobs
// Test to expose the reftest button in the logviewer actionbar
const reftestUrl = rawLogUrl && job.job_group_name && isReftest(job)
? getReftestUrl(rawLogUrl) : null;
const jobDetails = await JobDetailModel.getJobDetails({ job_id: jobId });
JobModel.get(repoName, jobId)
.then(async job => {
// set the title of the browser window/tab
document.title = job.getTitle();
const rawLogUrl = job.logs && job.logs.length ? job.logs[0].url : null;
// other properties, in order of appearance
// Test to disable successful steps checkbox on taskcluster jobs
// Test to expose the reftest button in the logviewer actionbar
const reftestUrl =
rawLogUrl && job.job_group_name && isReftest(job)
? getReftestUrl(rawLogUrl)
: null;
const jobDetails = await JobDetailModel.getJobDetails({
job_id: jobId,
});
this.setState({
job,
rawLogUrl,
reftestUrl,
jobDetails,
jobExists: true,
}, async () => {
// get the revision and linkify it
PushModel.get(job.push_id).then(async (resp) => {
const push = await resp.json();
const { revision } = push;
this.setState(
{
job,
rawLogUrl,
reftestUrl,
jobDetails,
jobExists: true,
},
async () => {
// get the revision and linkify it
PushModel.get(job.push_id).then(async resp => {
const push = await resp.json();
const { revision } = push;
this.setState({
revision,
jobUrl: getJobsUrl({ repo: repoName, revision, selectedJob: jobId }),
});
this.setState({
revision,
jobUrl: getJobsUrl({
repo: repoName,
revision,
selectedJob: jobId,
}),
});
});
},
);
})
.catch(error => {
this.setState({
jobExists: false,
jobError: error.toString(),
});
});
}).catch((error) => {
this.setState({
jobExists: false,
jobError: error.toString(),
});
});
TextLogStepModel.get(jobId).then((textLogSteps) => {
TextLogStepModel.get(jobId).then(textLogSteps => {
const stepErrors = textLogSteps.length ? textLogSteps[0].errors : [];
const errors = stepErrors.map(error => (
{ line: error.line, lineNumber: error.line_number + 1 }
));
const firstErrorLineNumber = errors.length ? [errors[0].lineNumber] : null;
const errors = stepErrors.map(error => ({
line: error.line,
lineNumber: error.line_number + 1,
}));
const firstErrorLineNumber = errors.length
? [errors[0].lineNumber]
: null;
const urlLN = getUrlLineNumber();
const highlight = urlLN || firstErrorLineNumber;
@ -173,14 +188,24 @@ class App extends React.PureComponent {
render() {
const {
job, rawLogUrl, reftestUrl, jobDetails, jobError, jobExists,
revision, errors, highlight, jobUrl,
job,
rawLogUrl,
reftestUrl,
jobDetails,
jobError,
jobExists,
revision,
errors,
highlight,
jobUrl,
} = this.state;
const extraFields = [{
title: 'Revision',
url: jobUrl,
value: revision,
}];
const extraFields = [
{
title: 'Revision',
url: jobUrl,
value: revision,
},
];
return (
<div className="d-flex flex-column body-logviewer h-100">
@ -205,10 +230,7 @@ class App extends React.PureComponent {
/>
<JobDetails jobDetails={jobDetails} />
</div>
<ErrorLines
errors={errors}
onClickLine={this.setSelectedLine}
/>
<ErrorLines errors={errors} onClickLine={this.setSelectedLine} />
</div>
<div className="log-contents flex-fill">
<LazyLog

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

@ -9,7 +9,12 @@ const getShadingClass = result => `result-status-shading-${result}`;
export default class Navigation extends React.PureComponent {
render() {
const {
jobExists, result, jobError, jobUrl, rawLogUrl, reftestUrl,
jobExists,
result,
jobError,
jobUrl,
rawLogUrl,
reftestUrl,
} = this.props;
const resultStatusShading = getShadingClass(result);
@ -19,21 +24,18 @@ export default class Navigation extends React.PureComponent {
<span id="lv-logo">
<LogoMenu menuText="Logviewer" />
</span>
{jobExists
? (
<span className={`lightgray ${resultStatusShading} pt-2 pl-2 pr-2`}>
<strong>Result: </strong>
{result}
{jobExists ? (
<span className={`lightgray ${resultStatusShading} pt-2 pl-2 pr-2`}>
<strong>Result: </strong>
{result}
</span>
) : (
<span className="alert-danger">
<span title="The job does not exist or has expired">
{`Unavailable: ${jobError}`}
</span>
) : (
<span className="alert-danger">
<span
title="The job does not exist or has expired"
>
{`Unavailable: ${jobError}`}
</span>
</span>
)}
</span>
)}
{!!jobUrl && (
<span>
<a

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

@ -10,10 +10,9 @@ export default class BugJobMapModel {
// the options parameter is used to filter/limit the list of objects
static getList(options) {
return fetch(`${uri}${createQueryParams(options)}`)
.then(resp => resp.json().then(data => (
data.map(elem => new BugJobMapModel(elem))
)));
return fetch(`${uri}${createQueryParams(options)}`).then(resp =>
resp.json().then(data => data.map(elem => new BugJobMapModel(elem))),
);
}
create() {

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше