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 = { module.exports = {
root: true, 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', parser: 'babel-eslint',
settings: { settings: {
react: { react: {
@ -17,17 +28,12 @@ module.exports = {
'class-methods-use-this': 'off', 'class-methods-use-this': 'off',
'consistent-return': 'off', 'consistent-return': 'off',
'default-case': '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/anchor-is-valid': 'off',
'jsx-a11y/click-events-have-key-events': 'off', 'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/label-has-associated-control': 'off', 'jsx-a11y/label-has-associated-control': 'off',
'jsx-a11y/label-has-for': 'off', 'jsx-a11y/label-has-for': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off', 'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/no-static-element-interactions': 'off', 'jsx-a11y/no-static-element-interactions': 'off',
'max-len': 'off',
'no-alert': 'off', 'no-alert': 'off',
'no-continue': 'off', 'no-continue': 'off',
'no-param-reassign': 'off', 'no-param-reassign': 'off',
@ -35,17 +41,11 @@ module.exports = {
'no-restricted-syntax': 'off', 'no-restricted-syntax': 'off',
'no-shadow': 'off', 'no-shadow': 'off',
'no-underscore-dangle': 'off', 'no-underscore-dangle': 'off',
'object-curly-newline': 'off',
'operator-linebreak': 'off',
'padded-blocks': 'off',
'prefer-promise-reject-errors': 'off', 'prefer-promise-reject-errors': 'off',
'react/button-has-type': 'off', 'react/button-has-type': 'off',
'react/default-props-match-prop-types': 'off', 'react/default-props-match-prop-types': 'off',
'react/destructuring-assignment': 'off', 'react/destructuring-assignment': 'off',
'react/forbid-prop-types': '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', 'react/no-access-state-in-setstate': 'off',
// Override AirBnB's config for this rule to make it more strict. // 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 // 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/', tests: 'tests/ui/',
}, },
use: [ use: [
process.env.NODE_ENV === 'development' && ['@neutrinojs/eslint', { process.env.NODE_ENV === 'development' && [
eslint: { '@neutrinojs/eslint',
// Treat ESLint errors as warnings so they don't block the webpack build. {
// Remove if/when changed in @neutrinojs/eslint. eslint: {
emitWarning: true, // Treat ESLint errors as warnings so they don't block the webpack build.
// We manage our lint config in .eslintrc.js instead of here. // Remove if/when changed in @neutrinojs/eslint.
useEslintrc: true, emitWarning: true,
// We manage our lint config in .eslintrc.js instead of here.
useEslintrc: true,
},
}, },
}], ],
['@neutrinojs/react', { [
devServer: { '@neutrinojs/react',
historyApiFallback: false, {
open: !process.env.MOZ_HEADLESS, devServer: {
// Remove when enabled by default (https://github.com/neutrinojs/neutrino/issues/1131). historyApiFallback: false,
overlay: true, open: !process.env.MOZ_HEADLESS,
proxy: { // Remove when enabled by default (https://github.com/neutrinojs/neutrino/issues/1131).
// Proxy any paths not recognised by webpack to the specified backend. overlay: true,
'*': { proxy: {
changeOrigin: true, // Proxy any paths not recognised by webpack to the specified backend.
headers: { '*': {
// Prevent Django CSRF errors, whilst still making it clear changeOrigin: true,
// that the requests were from local development. headers: {
referer: `${BACKEND}/webpack-dev-server`, // Prevent Django CSRF errors, whilst still making it clear
}, // that the requests were from local development.
target: BACKEND, referer: `${BACKEND}/webpack-dev-server`,
onProxyRes: (proxyRes) => { },
// Strip the cookie `secure` attribute, otherwise production's cookies target: BACKEND,
// will be rejected by the browser when using non-HTTPS localhost: onProxyRes: proxyRes => {
// https://github.com/nodejitsu/node-http-proxy/pull/1166 // Strip the cookie `secure` attribute, otherwise production's cookies
const removeSecure = str => str.replace(/; secure/i, ''); // will be rejected by the browser when using non-HTTPS localhost:
const cookieHeader = proxyRes.headers['set-cookie']; // https://github.com/nodejitsu/node-http-proxy/pull/1166
if (cookieHeader) { const removeSecure = str => str.replace(/; secure/i, '');
proxyRes.headers['set-cookie'] = Array.isArray(cookieHeader) const cookieHeader = proxyRes.headers['set-cookie'];
? cookieHeader.map(removeSecure) if (cookieHeader) {
: removeSecure(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, devtool: {
// since inotify doesn't work with Virtualbox shared folders. // Enable source maps for `yarn build` too (but not on CI, since it doubles build times).
watchOptions: process.env.USE_WATCH_POLLING && { production: process.env.CI ? false : 'source-map',
// Poll only once a second and ignore the node_modules folder to keep CPU usage down. },
poll: 1000, html: {
ignored: /node_modules/, // 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 neutrino => {
// 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.config neutrino.config
.plugin('provide') .plugin('provide')
.use(require.resolve('webpack/lib/ProvidePlugin'), [{ .use(require.resolve('webpack/lib/ProvidePlugin'), [
// Required since AngularJS and jquery.flot don't import jQuery themselves. {
jQuery: 'jquery', // Required since AngularJS and jquery.flot don't import jQuery themselves.
'window.jQuery': 'jquery', jQuery: 'jquery',
}]); 'window.jQuery': 'jquery',
},
]);
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
// Fail the build if these file size thresholds (in bytes) are exceeded, // Fail the build if these file size thresholds (in bytes) are exceeded,
// to help prevent unknowingly regressing the bundle size (bug 1384255). // to help prevent unknowingly regressing the bundle size (bug 1384255).
neutrino.config.performance neutrino.config.performance
.hints('error') .hints('error')
.maxAssetSize(1.30 * 1024 * 1024) .maxAssetSize(1.3 * 1024 * 1024)
.maxEntrypointSize(1.64 * 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 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): Imports in JS/JSX must be ordered like so (with newlines between each group):
1. external modules (eg `'react'`) 1. external modules (eg `'react'`)
2. modules from a parent directory (eg `'../foo'`) 2. modules from a parent directory (eg `'../foo'`)
3. "sibling" modules from the same or a sibling's directory (eg `'./bar'` or './bar/baz') 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 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 that new code has a consistent style and doesn't suffer from common
errors. Eslint will run automatically when you build the JavaScript 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 or run the development server. A production build will fail if your code
does not match the style requirements. 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 ```bash
$ yarn lint $ 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 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. 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 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/ [Node.js]: https://nodejs.org/en/download/current/
[Yarn]: https://yarnpkg.com/en/docs/install [Yarn]: https://yarnpkg.com/en/docs/install
[package.json]: https://github.com/mozilla/treeherder/blob/master/package.json [package.json]: https://github.com/mozilla/treeherder/blob/master/package.json
[eslint]: https://eslint.org [ESLint]: https://eslint.org
[Jasmine]: https://jasmine.github.io/ [Jasmine]: https://jasmine.github.io/
[enzyme]: http://airbnb.io/enzyme/ [enzyme]: http://airbnb.io/enzyme/

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

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

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

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

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

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

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

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

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

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

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

@ -18,7 +18,10 @@ describe('JobModel', () => {
describe('getList', () => { describe('getList', () => {
beforeEach(() => { 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', () => { it('should return a promise', () => {
@ -29,8 +32,14 @@ describe('JobModel', () => {
describe('pagination', () => { describe('pagination', () => {
beforeEach(() => { beforeEach(() => {
fetchMock.get(getProjectUrl('/jobs/?count=2'), getJSONFixture('job_list/pagination/page_1.json')); fetchMock.get(
fetchMock.get(getProjectUrl('/jobs/?count=2&offset=2'), getJSONFixture('job_list/pagination/page_2.json')); 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 () => { 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 () => { 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.length).toBe(3);
expect(jobList[2].id).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'; import { BugFilerClass } from '../../../../ui/job-view/details/BugFiler';
describe('BugFiler', () => { describe('BugFiler', () => {
const fullLog = 'https://queue.taskcluster.net/v1/task/AGs4CgN_RnCTb943uQn8NQ/runs/0/artifacts/public/logs/live_backing.log'; const fullLog =
const parsedLog = 'http://localhost:5000/logviewer.html#?job_id=89017089&repo=mozilla-inbound'; '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 reftest = '';
const selectedJob = { const selectedJob = {
job_group_name: 'Mochitests executed by TaskCluster', job_group_name: 'Mochitests executed by TaskCluster',
@ -16,9 +18,18 @@ describe('BugFiler', () => {
}; };
const suggestions = [ const suggestions = [
{ search: 'ShutdownLeaks | process() called before end of test suite' }, { 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:
{ search: 'leakcheck | default process: missing output line for total leaks!' }, '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 #' }, { search: '# TBPL FAILURE #' },
]; ];
const successCallback = () => {}; const successCallback = () => {};
@ -43,18 +54,20 @@ describe('BugFiler', () => {
fetchMock.get( fetchMock.get(
`${bzBaseUrl}rest/prod_comp_search/firefox%20::%20search?limit=5`, `${bzBaseUrl}rest/prod_comp_search/firefox%20::%20search?limit=5`,
{ products: [ {
{ product: 'Firefox' }, products: [
{ component: 'Search', product: 'Firefox' }, { product: 'Firefox' },
{ product: 'Marketplace' }, { component: 'Search', product: 'Firefox' },
{ component: 'Search', product: 'Marketplace' }, { product: 'Marketplace' },
{ product: 'Firefox for Android' }, { component: 'Search', product: 'Marketplace' },
{ component: 'Search Activity', product: 'Firefox for Android' }, { product: 'Firefox for Android' },
{ product: 'Firefox OS' }, { component: 'Search Activity', product: 'Firefox for Android' },
{ component: 'Gaia::Search', product: 'Firefox OS' }, { product: 'Firefox OS' },
{ product: 'Cloud Services' }, { component: 'Gaia::Search', product: 'Firefox OS' },
{ component: 'Operations: Storage', product: 'Cloud Services' }, { product: 'Cloud Services' },
] }, { component: 'Operations: Storage', product: 'Cloud Services' },
],
},
); );
}); });
@ -62,10 +75,12 @@ describe('BugFiler', () => {
fetchMock.reset(); fetchMock.reset();
}); });
const getBugFilerForSummary = (summary) => { const getBugFilerForSummary = summary => {
const suggestion = { const suggestion = {
summary, 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, search: summary,
}; };
@ -86,129 +101,191 @@ describe('BugFiler', () => {
}; };
it('parses a crash suggestion', () => { 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 bugFiler = getBugFilerForSummary(summary);
const { parsedSummary } = bugFiler.state(); 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', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('browser/components/sessionstore/test/browser_625016.js'); expect(summary[0][0]).toBe(
expect(summary[0][1]).toBe('observe1: 1 window in data written to disk - Got 0, expected 1'); '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'); expect(summary[1]).toBe('browser_625016.js');
}); });
it('should parse accessibility summaries', () => { 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 ' + ' | uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at ' +
'searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9'; 'searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9';
const bugFiler = getBugFilerForSummary(rawSummary); const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('accessible/tests/mochitest/states/test_expandable.xul'); expect(summary[0][0]).toBe(
expect(summary[0][1]).toBe('uncaught exception - TypeError: this.textbox.popup.oneOffButtons is undefined at ' + 'accessible/tests/mochitest/states/test_expandable.xul',
'searchbar_XBL_Constructor@chrome://browser/content/search/search.xml:95:9'); );
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'); expect(summary[1]).toBe('test_expandable.xul');
}); });
it('should parse xpcshell summaries', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js'); expect(summary[0][0]).toBe(
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]'); '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'); expect(summary[1]).toBe('test_rename_objectStore_errors.js');
}); });
it('should parse xpcshell unpack summaries', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js'); expect(summary[0][0]).toBe(
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]'); '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'); expect(summary[1]).toBe('test_rename_objectStore_errors.js');
}); });
it('should parse xpcshell dom summaries', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('dom/indexedDB/test/unit/test_rename_objectStore_errors.js'); expect(summary[0][0]).toBe(
expect(summary[0][1]).toBe('application crashed [@ mozalloc_abort(char const*)]'); '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'); expect(summary[1]).toBe('test_rename_objectStore_errors.js');
}); });
it('should parse Windows reftests on C drive summaries', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('layout/reftests/w3c-css/submitted/variables/variable-supports-12.html'); expect(summary[0][0]).toBe(
expect(summary[0][1]).toBe('application timed out after 330 seconds with no output'); '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'); expect(summary[1]).toBe('variable-supports-12.html');
}); });
it('should parse Linux reftest summaries', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('image/test/reftest/encoders-lossless/size-7x7.png'); expect(summary[0][0]).toBe(
expect(summary[0][1]).toBe('application timed out after 330 seconds with no output'); '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'); expect(summary[1]).toBe('size-7x7.png');
}); });
it('should parse Windows reftests on Z drive summaries', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; 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][0]).toBe(
expect(summary[0][1]).toBe('image comparison, max difference: 255, number of differing pixels: 5184'); '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'); expect(summary[1]).toBe('src-list-local-full.html');
}); });
it('should parse android reftests summaries', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; 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][0]).toBe(
expect(summary[0][1]).toBe('image comparison, max difference: 255, number of differing pixels: 699'); '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'); expect(summary[1]).toBe('display-contents-style-inheritance-1.html');
}); });
it('should parse reftest unexpected pass summaries', () => { 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/' + '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'; 'build/tests/reftest/tests/layout/reftests/backgrounds/vector/empty/ref-wide-lime.html | image comparison';
const bugFiler = getBugFilerForSummary(rawSummary); const bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('TEST-UNEXPECTED-PASS'); 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[0][2]).toBe('image comparison');
expect(summary[1]).toBe('wide--cover--width.html'); expect(summary[1]).toBe('wide--cover--width.html');
}); });
it('should parse finding the filename when the `TEST-FOO` is not omitted', () => { 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 bugFiler = getBugFilerForSummary(rawSummary);
const summary = bugFiler.state().parsedSummary; const summary = bugFiler.state().parsedSummary;
expect(summary[0][0]).toBe('TEST-UNEXPECTED-CRASH'); 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[0][2]).toBe('expected OK');
expect(summary[1]).toBe('xhr.https.html'); expect(summary[1]).toBe('xhr.https.html');
}); });
it('should strip omitted leads from thisFailure', () => { it('should strip omitted leads from thisFailure', () => {
const suggestions = [ const suggestions = [
{ bugs: {}, {
bugs: {},
search_terms: [], search_terms: [],
search: 'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -' }, search:
{ bugs: {}, 'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -',
},
{
bugs: {},
search_terms: [], search_terms: [],
search: 'TEST-UNEXPECTED-FAIL | browser/extensions/pdfjs/test/browser_pdfjs_views.js | Found a tab after previous test timed out: about:blank -' }, search:
{ bugs: {}, '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_terms: [],
search: 'REFTEST TEST-UNEXPECTED-PASS | flee | floo' }, search: 'REFTEST TEST-UNEXPECTED-PASS | flee | floo',
},
]; ];
const bugFiler = mount( const bugFiler = mount(
<BugFilerClass <BugFilerClass
@ -226,8 +303,10 @@ describe('BugFiler', () => {
); );
const { thisFailure } = bugFiler.state(); const { thisFailure } = bugFiler.state();
expect(thisFailure).toBe('browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -\n' + expect(thisFailure).toBe(
'browser/extensions/pdfjs/test/browser_pdfjs_views.js | Found a tab after previous test timed out: about:blank -\n' + 'browser/extensions/pdfjs/test/browser_pdfjs_views.js | Test timed out -\n' +
'TEST-UNEXPECTED-PASS | flee | floo'); '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 filterModel = new FilterModel();
const pushGroupState = 'collapsed'; const pushGroupState = 'collapsed';
beforeEach(() => { beforeEach(() => {
jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock'; jasmine.getJSONFixtures().fixturesPath = 'base/tests/ui/mock';
countGroup = getJSONFixture('mappedGroup.json'); 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">' + '<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>' + '<button class="btn group-symbol">W-e10s</button>' +
'<span class="group-content">' + '<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-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 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></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">' + '<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>' + '<button class="btn group-symbol">W-e10s</button>' +
'<span class="group-content">' + '<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-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 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></span></span></span>',
); );
}); });
@ -88,13 +87,13 @@ describe('JobGroup component', () => {
expect(jobGroup.html()).toEqual( expect(jobGroup.html()).toEqual(
'<span class="platform-group" data-group-key="313281W-e10s1linux64debug"><span class="disabled job-group" title="Web platform tests with e10s">' + '<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>' + '<button class="btn group-symbol">W-e10s</button>' +
'<span class="group-content">' + '<span class="group-content">' +
'<span class="group-job-list">' + '<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="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="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>' + '<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>' +
'<span class="group-count-list"></span></span></span></span>', '<span class="group-count-list"></span></span></span></span>',
); );
}); });
@ -116,13 +115,13 @@ describe('JobGroup component', () => {
expect(jobGroup.html()).toEqual( expect(jobGroup.html()).toEqual(
'<span class="platform-group" data-group-key="313281W-e10s1linux64debug"><span class="disabled job-group" title="Web platform tests with e10s">' + '<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>' + '<button class="btn group-symbol">W-e10s</button>' +
'<span class="group-content">' + '<span class="group-content">' +
'<span class="group-job-list">' + '<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="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="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>' + '<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>' +
'<span class="group-count-list"></span></span></span></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">' + '<span class="platform-group" data-group-key="313293SM1linux64opt"><span class="disabled job-group" title="Spidermonkey builds">' +
'<button class="btn group-symbol">SM</button>' + '<button class="btn group-symbol">SM</button>' +
'<span class="group-content"><span class="group-job-list">' + '<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>' +
'<span class="group-count-list">' + '<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>' + '<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>', '</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">' + '<span class="platform-group" data-group-key="313293SM1linux64opt"><span class="disabled job-group" title="Spidermonkey builds">' +
'<button class="btn group-symbol">SM</button>' + '<button class="btn group-symbol">SM</button>' +
'<span class="group-content"><span class="group-job-list">' + '<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>' +
'<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="166321182" title="success | spidermonkey-sm-msan-linux64/opt - (10 mins)" class="btn btn-green filter-shown job-btn btn-xs">msan</button>' +
'</span>' + '</span>' +
'<span class="group-count-list">' + '<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>' + '<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>', '</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">' + '<span class="platform-group" data-group-key="313293SM1linux64opt"><span class="disabled job-group" title="Spidermonkey builds">' +
'<button class="btn group-symbol">SM</button>' + '<button class="btn group-symbol">SM</button>' +
'<span class="group-content"><span class="group-job-list">' + '<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>' +
'<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="166321182" title="success | spidermonkey-sm-msan-linux64/opt - (10 mins)" class="btn btn-green filter-shown job-btn btn-xs">msan</button>' +
'</span>' + '</span>' +
'<span class="group-count-list">' + '<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>' + '<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>', '</span></span></span></span>',
); );
}); });
}); });

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

@ -12,7 +12,6 @@ describe('Revision list component', () => {
let mockData; let mockData;
beforeEach(() => { beforeEach(() => {
const repo = new RepositoryModel({ const repo = new RepositoryModel({
id: 2, id: 2,
repository_group: { repository_group: {
@ -27,48 +26,53 @@ describe('Revision list component', () => {
description: '', description: '',
active_status: 'active', active_status: 'active',
performance_alerts_enabled: true, performance_alerts_enabled: true,
pushlogURL: 'https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml', pushlogURL:
'https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml',
}); });
const push = { const push = {
id: 151371, id: 151371,
revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff', revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff',
revision_count: 3, revision_count: 3,
author: 'ryanvm@gmail.com', author: 'ryanvm@gmail.com',
push_timestamp: 1481326280, push_timestamp: 1481326280,
repository_id: 2, repository_id: 2,
revisions: [{ revisions: [
result_set_id: 151371, {
repository_id: 2, result_set_id: 151371,
revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff', repository_id: 2,
author: 'André Bargull <andre.bargull@gmail.com>', revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff',
comments: 'Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem', author: 'André Bargull <andre.bargull@gmail.com>',
}, { comments:
result_set_id: 151371, 'Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem',
repository_id: 2, },
revision: '07d6bf74b7a2552da91b5e2fce0fa0bc3b457394', {
author: 'André Bargull <andre.bargull@gmail.com>', result_set_id: 151371,
comments: 'Bug 1319926 - Part 1: Warn when deprecated String generics methods are used. r=jandem', repository_id: 2,
}, { revision: '07d6bf74b7a2552da91b5e2fce0fa0bc3b457394',
result_set_id: 151371, author: 'André Bargull <andre.bargull@gmail.com>',
repository_id: 2, comments:
revision: 'e83eaf2380c65400dc03c6f3615d4b2cef669af3', 'Bug 1319926 - Part 1: Warn when deprecated String generics methods are used. r=jandem',
author: 'Frédéric Wang <fred.wang@free.fr>', },
comments: 'Bug 1322743 - Add STIX Two Math to the list of math fonts. r=karlt', {
}], result_set_id: 151371,
}; repository_id: 2,
mockData = { revision: 'e83eaf2380c65400dc03c6f3615d4b2cef669af3',
push, author: 'Frédéric Wang <fred.wang@free.fr>',
repo, 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', () => { it('renders the correct number of revisions in a list', () => {
const wrapper = mount( const wrapper = mount(
<RevisionList <RevisionList repo={mockData.repo} push={mockData.push} />,
repo={mockData.repo}
push={mockData.push}
/>,
); );
expect(wrapper.find(Revision).length).toEqual(mockData.push.revision_count); expect(wrapper.find(Revision).length).toEqual(mockData.push.revision_count);
}); });
@ -77,14 +81,10 @@ describe('Revision list component', () => {
mockData.push.revision_count = 21; mockData.push.revision_count = 21;
const wrapper = mount( const wrapper = mount(
<RevisionList <RevisionList repo={mockData.repo} push={mockData.push} />,
repo={mockData.repo}
push={mockData.push}
/>,
); );
expect(wrapper.find(MoreRevisionsLink).length).toEqual(1); expect(wrapper.find(MoreRevisionsLink).length).toEqual(1);
}); });
}); });
describe('Revision item component', () => { describe('Revision item component', () => {
@ -105,14 +105,16 @@ describe('Revision item component', () => {
description: '', description: '',
active_status: 'active', active_status: 'active',
performance_alerts_enabled: true, performance_alerts_enabled: true,
pushlogURL: 'https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml', pushlogURL:
'https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml',
}); });
const revision = { const revision = {
result_set_id: 151371, result_set_id: 151371,
repository_id: 2, repository_id: 2,
revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff', revision: '5a110ad242ead60e71d2186bae78b1fb766ad5ff',
author: 'André Bargull <andre.bargull@gmail.com>', 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 = { mockData = {
@ -123,21 +125,21 @@ describe('Revision item component', () => {
it('renders a linked revision', () => { it('renders a linked revision', () => {
const wrapper = mount( const wrapper = mount(
<Revision <Revision repo={mockData.repo} revision={mockData.revision} />,
repo={mockData.repo} );
revision={mockData.revision}
/>);
const link = wrapper.find('a').first(); const link = wrapper.find('a').first();
expect(link.props().href).toEqual(mockData.repo.getRevisionHref(mockData.revision.revision)); expect(link.props().href).toEqual(
expect(link.props().title).toEqual(`Open revision ${mockData.revision.revision} on ${mockData.repo.url}`); 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( const wrapper = mount(
<Revision <Revision repo={mockData.repo} revision={mockData.revision} />,
repo={mockData.repo} );
revision={mockData.revision}
/>);
const initials = wrapper.find('.user-push-initials'); const initials = wrapper.find('.user-push-initials');
expect(initials.length).toEqual(1); expect(initials.length).toEqual(1);
expect(initials.text()).toEqual('AB'); expect(initials.text()).toEqual('AB');
@ -145,30 +147,28 @@ describe('Revision item component', () => {
it('linkifies bug IDs in the comments', () => { it('linkifies bug IDs in the comments', () => {
const wrapper = mount( const wrapper = mount(
<Revision <Revision repo={mockData.repo} revision={mockData.revision} />,
repo={mockData.repo} );
revision={mockData.revision}
/>);
const comment = wrapper.find('.revision-comment em'); 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', () => { 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( let wrapper = mount(
<Revision <Revision repo={mockData.repo} revision={mockData.revision} />,
repo={mockData.repo} );
revision={mockData.revision}
/>);
expect(wrapper.find({ 'data-tags': 'backout' }).length).toEqual(1); 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( wrapper = mount(
<Revision <Revision repo={mockData.repo} revision={mockData.revision} />,
repo={mockData.repo} );
revision={mockData.revision}
/>);
expect(wrapper.find({ 'data-tags': 'backout' }).length).toEqual(1); expect(wrapper.find({ 'data-tags': 'backout' }).length).toEqual(1);
}); });
}); });
@ -192,32 +192,32 @@ describe('initials filter', () => {
it('initializes a one-word name', () => { it('initializes a one-word name', () => {
const name = 'Starscream'; const name = 'Starscream';
const initials = mount( const initials = mount(
<Initials <Initials title={`${name}: ${email}`} author={name} />,
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>',
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', () => { it('initializes a two-word name', () => {
const name = 'Optimus Prime'; const name = 'Optimus Prime';
const initials = mount( const initials = mount(
<Initials <Initials title={`${name}: ${email}`} author={name} />,
title={`${name}: ${email}`} );
author={name}
/>);
const userPushInitials = initials.find('.user-push-initials'); 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', () => { it('initializes a three-word name', () => {
const name = 'Some Other Transformer'; const name = 'Some Other Transformer';
const initials = mount( const initials = mount(
<Initials <Initials title={`${name}: ${email}`} author={name} />,
title={`${name}: ${email}`} );
author={name}
/>);
const userPushInitials = initials.find('.user-push-initials'); 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 => ( export const escapeId = id => id.replace(/(:|\[|\]|\?|,|\.|\s+)/g, '-');
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) // 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) => ( export const getPushTableId = (repoName, pushId, revision) =>
escapeId(`${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 // 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', domain: 'auth.mozilla.auth0.com',
responseType: 'id_token token', responseType: 'id_token token',
audience: 'login.taskcluster.net', 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', scope: 'taskcluster-credentials openid profile email',
}); });
export const userSessionFromAuthResult = (authResult) => { export const userSessionFromAuthResult = authResult => {
const expiresAt = JSON.stringify((authResult.expiresIn * 1000) + Date.now()); const expiresAt = JSON.stringify(authResult.expiresIn * 1000 + Date.now());
const userSession = { const userSession = {
idToken: authResult.idToken, idToken: authResult.idToken,
accessToken: authResult.accessToken, accessToken: authResult.accessToken,
@ -31,7 +33,7 @@ export const userSessionFromAuthResult = (authResult) => {
}; };
// Wrapper around webAuth's renewAuth // Wrapper around webAuth's renewAuth
export const renew = () => ( export const renew = () =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
webAuth.renewAuth({}, (error, authResult) => { webAuth.renewAuth({}, (error, authResult) => {
if (error) { if (error) {
@ -40,11 +42,10 @@ export const renew = () => (
return resolve(authResult); return resolve(authResult);
}); });
}) });
);
// Wrapper around webAuth's parseHash // Wrapper around webAuth's parseHash
export const parseHash = options => ( export const parseHash = options =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
webAuth.parseHash(options, (error, authResult) => { webAuth.parseHash(options, (error, authResult) => {
if (error) { if (error) {
@ -53,7 +54,11 @@ export const parseHash = options => (
return resolve(authResult); 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 tokenCounts = tokens.map(tokens => countBy(tokens, x => x));
const overlap = Object.keys(tokenCounts[0]) const overlap = Object.keys(tokenCounts[0]).reduce((overlap, x) => {
.reduce((overlap, x) => { if (Object.prototype.hasOwnProperty.call(tokenCounts[1], x)) {
if (Object.prototype.hasOwnProperty.call(tokenCounts[1], x)) { overlap += 2 * Math.min(tokenCounts[0][x], tokenCounts[1][x]);
overlap += 2 * Math.min(tokenCounts[0][x], tokenCounts[1][x]); }
} return overlap;
return overlap; }, 0);
}, 0);
return overlap / (tokens[0].length + tokens[1].length); return overlap / (tokens[0].length + tokens[1].length);
}; };
@ -37,10 +36,12 @@ export const highlightLogLine = function highlightLogLine(logLine) {
const parts = logLine.split(' | ', 3); const parts = logLine.split(' | ', 3);
return ( return (
<span> <span>
{parts[0].startsWith('TEST-UNEXPECTED') && <span> {parts[0].startsWith('TEST-UNEXPECTED') && (
<strong className="failure-line-status">{parts[0]}</strong> <span>
<strong>{parts[1]}</strong> <strong className="failure-line-status">{parts[0]}</strong>
</span>} <strong>{parts[1]}</strong>
</span>
)}
{!parts[0].startsWith('TEST-UNEXPECTED') && logLine} {!parts[0].startsWith('TEST-UNEXPECTED') && logLine}
</span> </span>
); );

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

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

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

@ -28,9 +28,10 @@ export const toShortDateStr = function toDateStr(timestamp) {
export const getSearchWords = function getHighlighterArray(text) { export const getSearchWords = function getHighlighterArray(text) {
const tokens = text.split(/[^a-zA-Z0-9_-]+/); const tokens = text.split(/[^a-zA-Z0-9_-]+/);
return tokens.reduce((acc, token) => ( return tokens.reduce(
token.length > 1 ? [...acc, token] : acc (acc, token) => (token.length > 1 ? [...acc, token] : acc),
), []); [],
);
}; };
export const getPercentComplete = function getPercentComplete(counts) { export const getPercentComplete = function getPercentComplete(counts) {
@ -38,5 +39,5 @@ export const getPercentComplete = function getPercentComplete(counts) {
const inProgress = pending + running; const inProgress = pending + running;
const total = completed + inProgress; 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. found in the error object.
*/ */
export const formatModelError = function formatModelError(e, message) { export const formatModelError = function formatModelError(e, message) {
// Generic error message when we encounter 401 status codes from the // Generic error message when we encounter 401 status codes from the
// server. // server.
const AUTH_ERROR_MSG = 'Please login to Treeherder to complete this action'; 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 we failed to authenticate for some reason return a nicer error message.
if (e.status === 401 || e.status === 403) { if (e.status === 401 || e.status === 403) {
return AUTH_ERROR_MSG; 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 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(); const errorMessage = err.message || err.toString();
if (errorMessage.indexOf('----') !== -1) { if (errorMessage.indexOf('----') !== -1) {
return `${TC_ERROR_PREFIX}${errorMessage.split('----')[0]}`; return `${TC_ERROR_PREFIX}${errorMessage.split('----')[0]}`;
} }
return `${TC_ERROR_PREFIX}${errorMessage}`; return `${TC_ERROR_PREFIX}${errorMessage}`;

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

@ -20,7 +20,10 @@ export const thFieldChoices = {
machine_name: { name: 'machine name', matchType: thMatchType.substr }, machine_name: { name: 'machine name', matchType: thMatchType.substr },
platform: { name: 'platform', matchType: thMatchType.substr }, platform: { name: 'platform', matchType: thMatchType.substr },
tier: { name: 'tier', matchType: thMatchType.exactstr }, 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 // text search across multiple fields
searchStr: { name: 'search string', matchType: thMatchType.searchStr }, searchStr: { name: 'search string', matchType: thMatchType.searchStr },
}; };

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

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

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

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

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

@ -2,7 +2,9 @@ export const thTitleSuffixLimit = 70;
export const parseAuthor = function parseAuthor(author) { export const parseAuthor = function parseAuthor(author) {
const userTokens = author.split(/[<>]+/); 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] : ''; const email = userTokens.length > 1 ? userTokens[1] : '';
return { name, email }; return { name, email };
}; };

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,6 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; 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 DropdownMenuItems from './DropdownMenuItems';
import { treeOptions } from './constants'; import { treeOptions } from './constants';

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

@ -1,136 +1,71 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { graphsEndpoint, parseQueryParams, createQueryParams, createApiUrl, import {
bugzillaBugsApi } from '../helpers/url'; graphsEndpoint,
parseQueryParams,
createQueryParams,
createApiUrl,
bugzillaBugsApi,
} from '../helpers/url';
import { getData } from '../helpers/http'; import { getData } from '../helpers/http';
import { updateQueryParams, validateQueryParams, mergeData, formatBugs } from './helpers'; import {
updateQueryParams,
validateQueryParams,
mergeData,
formatBugs,
} from './helpers';
const withView = defaultState => WrappedComponent => const withView = defaultState => WrappedComponent =>
class View extends React.Component { class View extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.updateData = this.updateData.bind(this); this.updateData = this.updateData.bind(this);
this.setQueryParams = this.setQueryParams.bind(this); this.setQueryParams = this.setQueryParams.bind(this);
this.checkQueryValidation = this.checkQueryValidation.bind(this); this.checkQueryValidation = this.checkQueryValidation.bind(this);
this.getTableData = this.getTableData.bind(this); this.getTableData = this.getTableData.bind(this);
this.getGraphData = this.getGraphData.bind(this); this.getGraphData = this.getGraphData.bind(this);
this.updateState = this.updateState.bind(this); this.updateState = this.updateState.bind(this);
this.getBugDetails = this.getBugDetails.bind(this); this.getBugDetails = this.getBugDetails.bind(this);
this.default = (this.props.location.state || defaultState); this.default = this.props.location.state || defaultState;
this.state = { this.state = {
errorMessages: [], errorMessages: [],
initialParamsSet: false, initialParamsSet: false,
tree: (this.default.tree || null), tree: this.default.tree || null,
startday: (this.default.startday || null), startday: this.default.startday || null,
endday: (this.default.endday || null), endday: this.default.endday || null,
bug: (this.default.id || null), bug: this.default.id || null,
summary: (this.default.summary || null), summary: this.default.summary || null,
tableData: [], tableData: [],
tableFailureStatus: null, tableFailureStatus: null,
isFetchingTable: false, isFetchingTable: false,
graphData: [], graphData: [],
graphFailureStatus: null, graphFailureStatus: null,
isFetchingGraphs: false, isFetchingGraphs: false,
lastLocation: (this.default.location || null), 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;
} }
if (location.search !== '' && !location.state) { componentDidMount() {
// update data based on the params or show error if params are missing this.setQueryParams();
this.checkQueryValidation(parseQueryParams(location.search)); }
} else {
// if the query params are not specified for mainview, set params based on default state componentDidUpdate(prevProps) {
if (location.search === '') { const { location } = this.props;
const queryString = createQueryParams(params); // update all data if the user edits dates, tree or bug via the query params
updateQueryParams(defaultState.route, queryString, history, location); 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 }); setQueryParams() {
} const { location, history } = this.props;
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 { startday, endday, tree, bug } = this.state;
const params = { startday, endday, tree }; const params = { startday, endday, tree };
@ -138,62 +73,156 @@ const withView = defaultState => WrappedComponent =>
params.bug = bug; params.bug = bug;
} }
this.getGraphData(createApiUrl(graphsEndpoint, params)); if (location.search !== '' && !location.state) {
this.getTableData(createApiUrl(defaultState.endpoint, params)); // 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 this.setState({ initialParamsSet: true });
const queryString = createQueryParams(params); this.getGraphData(createApiUrl(graphsEndpoint, params));
updateQueryParams(defaultState.route, queryString, this.props.history, this.props.location); this.getTableData(createApiUrl(defaultState.endpoint, params));
}); }
}
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) { async getBugDetails(url) {
this.getBugDetails(bugzillaBugsApi('bug', { include_fields: 'summary', id: params.bug })); const { data, failureStatus } = await getData(url);
if (!failureStatus && data.bugs.length === 1) {
this.setState({ summary: data.bugs[0].summary });
}
} }
}
checkQueryValidation(params, urlChanged = false) { async getTableData(url) {
const { errorMessages, initialParamsSet, summary } = this.state; this.setState({ tableFailureStatus: null, isFetchingTable: true });
const messages = validateQueryParams(params, defaultState.route === '/bugdetails'); const { data, failureStatus } = await getData(url);
const updates = {}; let mergedData = null;
if (messages.length > 0) { if (defaultState.route === '/main' && !failureStatus && data.length) {
this.setState({ errorMessages: messages }); const bugIds = formatBugs(data);
} else { const bugzillaData = await this.batchBugRequests(bugIds);
if (errorMessages.length) { mergedData = mergeData(data, bugzillaData);
updates.errorMessages = [];
}
if (!initialParamsSet) {
updates.initialParamsSet = true;
}
if (summary) {
// reset summary
updates.summary = null;
} }
this.setState({ ...updates, ...params }); this.setState({
this.updateData(params, urlChanged); tableData: mergedData || data,
tableFailureStatus: failureStatus,
isFetchingTable: false,
});
} }
}
render() { async getGraphData(url) {
const updateState = { updateState: this.updateState }; this.setState({ graphFailureStatus: null, isFetchingGraphs: true });
const newProps = { ...this.props, ...this.state, ...updateState }; const { data, failureStatus } = await getData(url);
return ( this.setState({
<WrappedComponent {...newProps} /> 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 = { withView.propTypes = {
history: PropTypes.shape({}).isRequired, history: PropTypes.shape({}).isRequired,

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

@ -51,9 +51,11 @@ export const prettyErrorMessages = {
startday: 'startday is required and must be in YYYY-MM-DD format.', startday: 'startday is required and must be in YYYY-MM-DD format.',
endday: 'endday 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.', 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.', 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'; 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++) { for (let i = 0; i < data.length; i++) {
const failures = data[i].failure_count; const failures = data[i].failure_count;
const testRuns = data[i].test_runs; 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 // metrics graphics only accepts JS Date objects
const date = moment(data[i].date).toDate(); const date = moment(data[i].date).toDate();
@ -57,19 +57,29 @@ export const calculateMetrics = function calculateMetricsForGraphs(data) {
dateTestRunCounts.push({ date, value: testRuns }); dateTestRunCounts.push({ date, value: testRuns });
dateFreqs.push({ date, value: freq }); 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) { export const updateQueryParams = function updateHistoryWithQueryParams(
history.replace({ pathname: view, search: queryParams }); view,
// we do this so the api's won't be called twice (location/history updates will trigger a lifecycle hook) queryParams,
location.search = 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) { export const sortData = function sortData(data, sortBy, desc) {
data.sort((a, b) => { data.sort((a, b) => {
const item1 = (desc ? b[sortBy] : a[sortBy]); const item1 = desc ? b[sortBy] : a[sortBy];
const item2 = (desc ? a[sortBy] : b[sortBy]); const item2 = desc ? a[sortBy] : b[sortBy];
if (item1 < item2) { if (item1 < item2) {
return -1; return -1;
@ -82,7 +92,10 @@ export const sortData = function sortData(data, sortBy, desc) {
return data; return data;
}; };
export const processErrorMessage = function processErrorMessage(errorMessage, status) { export const processErrorMessage = function processErrorMessage(
errorMessage,
status,
) {
const messages = []; const messages = [];
if (status === 503) { if (status === 503) {
@ -101,7 +114,10 @@ export const processErrorMessage = function processErrorMessage(errorMessage, st
return messages || [prettyErrorMessages.default]; return messages || [prettyErrorMessages.default];
}; };
export const validateQueryParams = function validateQueryParams(params, bugRequired = false) { export const validateQueryParams = function validateQueryParams(
params,
bugRequired = false,
) {
const messages = []; const messages = [];
const dateFormat = /\d{4}[-]\d{2}[-]\d{2}/; 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_INTERVAL = 1000 * 60 * 5;
const REVISION_POLL_DELAYED_INTERVAL = 1000 * 60 * 60; const REVISION_POLL_DELAYED_INTERVAL = 1000 * 60 * 60;
const HIDDEN_URL_PARAMS = [ const HIDDEN_URL_PARAMS = [
'repo', 'classifiedState', 'resultStatus', 'selectedJob', 'searchStr', 'repo',
'classifiedState',
'resultStatus',
'selectedJob',
'searchStr',
]; ];
const getWindowHeight = function getWindowHeight() { const getWindowHeight = function getWindowHeight() {
@ -83,13 +87,14 @@ class App extends React.Component {
this.handleUrlChanges = this.handleUrlChanges.bind(this); this.handleUrlChanges = this.handleUrlChanges.bind(this);
this.showOnScreenShortcuts = this.showOnScreenShortcuts.bind(this); this.showOnScreenShortcuts = this.showOnScreenShortcuts.bind(this);
RepositoryModel.getList().then((repos) => { RepositoryModel.getList().then(repos => {
const currentRepo = repos.find(repo => repo.name === repoName) || this.state.currentRepo; const currentRepo =
repos.find(repo => repo.name === repoName) || this.state.currentRepo;
this.setState({ currentRepo, repos }); this.setState({ currentRepo, repos });
}); });
ClassificationTypeModel.getList().then((classificationTypes) => { ClassificationTypeModel.getList().then(classificationTypes => {
this.setState({ this.setState({
classificationTypes, classificationTypes,
classificationMap: ClassificationTypeModel.getMap(classificationTypes), classificationMap: ClassificationTypeModel.getMap(classificationTypes),
@ -100,29 +105,38 @@ class App extends React.Component {
window.addEventListener('hashchange', this.handleUrlChanges, false); window.addEventListener('hashchange', this.handleUrlChanges, false);
// Get the current Treeherder revision and poll to notify on updates. // Get the current Treeherder revision and poll to notify on updates.
this.fetchDeployedRevision().then((revision) => { this.fetchDeployedRevision().then(revision => {
this.setState({ serverRev: revision }); this.setState({ serverRev: revision });
this.updateInterval = setInterval(() => { this.updateInterval = setInterval(() => {
this.fetchDeployedRevision() this.fetchDeployedRevision().then(revision => {
.then((revision) => { const {
const { serverChangedTimestamp, serverRev, serverChanged } = this.state; serverChangedTimestamp,
serverRev,
serverChanged,
} = this.state;
if (serverChanged) { if (serverChanged) {
if (Date.now() - serverChangedTimestamp > REVISION_POLL_DELAYED_INTERVAL) { if (
this.setState({ serverChangedDelayed: true }); Date.now() - serverChangedTimestamp >
// Now that we know there's an update, stop polling. REVISION_POLL_DELAYED_INTERVAL
clearInterval(this.updateInterval); ) {
} 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 // This request returns the treeherder git revision running on the server
if (serverRev && serverRev !== revision) { // If this differs from the version chosen during the UI page load, show a warning
this.setState({ serverRev: revision }); if (serverRev && serverRev !== revision) {
if (serverChanged === false) { this.setState({ serverRev: revision });
this.setState({ serverChangedTimestamp: Date.now(), serverChanged: true }); if (serverChanged === false) {
} this.setState({
serverChangedTimestamp: Date.now(),
serverChanged: true,
});
} }
}); }
});
}, REVISION_POLL_INTERVAL); }, REVISION_POLL_INTERVAL);
}); });
} }
@ -136,8 +150,10 @@ class App extends React.Component {
const defaultPushListPct = hasSelectedJob ? 100 - DEFAULT_DETAILS_PCT : 100; const defaultPushListPct = hasSelectedJob ? 100 - DEFAULT_DETAILS_PCT : 100;
// calculate the height of the details panel to use if it has not been // calculate the height of the details panel to use if it has not been
// resized by the user. // resized by the user.
const defaultDetailsHeight = defaultPushListPct < 100 ? const defaultDetailsHeight =
DEFAULT_DETAILS_PCT / 100 * getWindowHeight() : 0; defaultPushListPct < 100
? (DEFAULT_DETAILS_PCT / 100) * getWindowHeight()
: 0;
return { return {
defaultPushListPct, defaultPushListPct,
@ -196,16 +212,29 @@ class App extends React.Component {
handleSplitChange(latestSplitSize) { handleSplitChange(latestSplitSize) {
this.setState({ this.setState({
latestSplitPct: latestSplitSize / getWindowHeight() * 100, latestSplitPct: (latestSplitSize / getWindowHeight()) * 100,
}); });
} }
render() { render() {
const { const {
user, isFieldFilterVisible, serverChangedDelayed, user,
defaultPushListPct, defaultDetailsHeight, latestSplitPct, serverChanged, isFieldFilterVisible,
currentRepo, repoName, repos, classificationTypes, classificationMap, serverChangedDelayed,
filterModel, hasSelectedJob, revision, duplicateJobsVisible, groupCountsExpanded, defaultPushListPct,
defaultDetailsHeight,
latestSplitPct,
serverChanged,
currentRepo,
repoName,
repos,
classificationTypes,
classificationMap,
filterModel,
hasSelectedJob,
revision,
duplicateJobsVisible,
groupCountsExpanded,
showShortCuts, showShortCuts,
} = this.state; } = this.state;
@ -220,16 +249,21 @@ class App extends React.Component {
// we resize. Therefore, we must calculate the new // we resize. Therefore, we must calculate the new
// height of the DetailsPanel based on the current height of the PushList. // height of the DetailsPanel based on the current height of the PushList.
// Reported this upstream: https://github.com/tomkp/react-split-pane/issues/282 // Reported this upstream: https://github.com/tomkp/react-split-pane/issues/282
const pushListPct = latestSplitPct === undefined || !hasSelectedJob ? const pushListPct =
defaultPushListPct : latestSplitPct === undefined || !hasSelectedJob
latestSplitPct; ? defaultPushListPct
const detailsHeight = latestSplitPct === undefined || !hasSelectedJob ? : latestSplitPct;
defaultDetailsHeight : const detailsHeight =
getWindowHeight() * (1 - latestSplitPct / 100); latestSplitPct === undefined || !hasSelectedJob
const filterBarFilters = Object.entries(filterModel.urlParams).reduce((acc, [field, value]) => ( ? defaultDetailsHeight
HIDDEN_URL_PARAMS.includes(field) || matchesDefaults(field, value) ? : getWindowHeight() * (1 - latestSplitPct / 100);
acc : [...acc, { field, value }] const filterBarFilters = Object.entries(filterModel.urlParams).reduce(
), []); (acc, [field, value]) =>
HIDDEN_URL_PARAMS.includes(field) || matchesDefaults(field, value)
? acc
: [...acc, { field, value }],
[],
);
return ( return (
<div id="global-container" className="height-minus-navbars"> <div id="global-container" className="height-minus-navbars">
@ -260,16 +294,22 @@ class App extends React.Component {
onChange={size => this.handleSplitChange(size)} onChange={size => this.handleSplitChange(size)}
> >
<div className="d-flex flex-column w-100"> <div className="d-flex flex-column w-100">
{(isFieldFilterVisible || !!filterBarFilters.length) && <ActiveFilters {(isFieldFilterVisible || !!filterBarFilters.length) && (
classificationTypes={classificationTypes} <ActiveFilters
filterModel={filterModel} classificationTypes={classificationTypes}
filterBarFilters={filterBarFilters} filterModel={filterModel}
isFieldFilterVisible={isFieldFilterVisible} filterBarFilters={filterBarFilters}
toggleFieldFilterVisible={this.toggleFieldFilterVisible} isFieldFilterVisible={isFieldFilterVisible}
/>} toggleFieldFilterVisible={
{serverChangedDelayed && <UpdateAvailable this.toggleFieldFilterVisible
updateButtonClick={this.updateButtonClick} }
/>} />
)}
{serverChangedDelayed && (
<UpdateAvailable
updateButtonClick={this.updateButtonClick}
/>
)}
<div id="th-global-content" className="th-global-content"> <div id="th-global-content" className="th-global-content">
<span className="th-view-content" tabIndex={-1}> <span className="th-view-content" tabIndex={-1}>
<PushList <PushList
@ -294,16 +334,18 @@ class App extends React.Component {
/> />
</SplitPane> </SplitPane>
<NotificationList /> <NotificationList />
{showShortCuts && <div {showShortCuts && (
id="onscreen-overlay" <div
onClick={() => this.showOnScreenShortcuts(false)} id="onscreen-overlay"
> onClick={() => this.showOnScreenShortcuts(false)}
<div id="onscreen-shortcuts"> >
<div className="col-8"> <div id="onscreen-shortcuts">
<ShortcutTable /> <div className="col-8">
<ShortcutTable />
</div>
</div> </div>
</div> </div>
</div>} )}
</KeyboardShortcuts> </KeyboardShortcuts>
</SelectedJob> </SelectedJob>
</PinnedJobs> </PinnedJobs>

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

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

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

@ -59,7 +59,9 @@ class KeyboardShortcuts extends React.Component {
// open panels and selected job // open panels and selected job
clearScreen() { clearScreen() {
const { const {
clearSelectedJob, showOnScreenShortcuts, notifications, clearSelectedJob,
showOnScreenShortcuts,
notifications,
clearOnScreenNotifications, clearOnScreenNotifications,
} = this.props; } = this.props;
@ -134,7 +136,9 @@ class KeyboardShortcuts extends React.Component {
if (selectedJob) { if (selectedJob) {
window.dispatchEvent( 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; return;
} }
if ((element.tagName === 'INPUT' && if (
element.type !== 'radio' && element.type !== 'checkbox') || (element.tagName === 'INPUT' &&
element.type !== 'radio' &&
element.type !== 'checkbox') ||
element.tagName === 'SELECT' || element.tagName === 'SELECT' ||
element.tagName === 'TEXTAREA' || element.tagName === 'TEXTAREA' ||
element.isContentEditable || ev.key === 'shift') { element.isContentEditable ||
ev.key === 'shift'
) {
return; return;
} }
@ -186,19 +194,26 @@ class KeyboardShortcuts extends React.Component {
} }
render() { render() {
const { filterModel, changeSelectedJob, showOnScreenShortcuts } = this.props; const {
filterModel,
changeSelectedJob,
showOnScreenShortcuts,
} = this.props;
const handlers = { const handlers = {
addRelatedBug: ev => this.doKey(ev, this.addRelatedBug), addRelatedBug: ev => this.doKey(ev, this.addRelatedBug),
pinEditComment: ev => this.doKey(ev, this.pinEditComment), pinEditComment: ev => this.doKey(ev, this.pinEditComment),
quickFilter: ev => this.doKey(ev, this.quickFilter), quickFilter: ev => this.doKey(ev, this.quickFilter),
clearFilter: ev => this.doKey(ev, this.clearFilter), clearFilter: ev => this.doKey(ev, this.clearFilter),
toggleInProgress: ev => this.doKey(ev, filterModel.toggleInProgress), toggleInProgress: ev => this.doKey(ev, filterModel.toggleInProgress),
nextUnclassified: ev => this.doKey(ev, () => changeSelectedJob('next', true)), nextUnclassified: ev =>
previousUnclassified: ev => this.doKey(ev, () => changeSelectedJob('previous', true)), this.doKey(ev, () => changeSelectedJob('next', true)),
previousUnclassified: ev =>
this.doKey(ev, () => changeSelectedJob('previous', true)),
openLogviewer: ev => this.doKey(ev, this.openLogviewer), openLogviewer: ev => this.doKey(ev, this.openLogviewer),
jobRetrigger: ev => this.doKey(ev, this.jobRetrigger), jobRetrigger: ev => this.doKey(ev, this.jobRetrigger),
selectNextTab: ev => this.doKey(ev, this.selectNextTab), 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), clearPinboard: ev => this.doKey(ev, this.clearPinboard),
previousJob: ev => this.doKey(ev, () => changeSelectedJob('previous')), previousJob: ev => this.doKey(ev, () => changeSelectedJob('previous')),
nextJob: ev => this.doKey(ev, () => changeSelectedJob('next')), nextJob: ev => this.doKey(ev, () => changeSelectedJob('next')),
@ -249,4 +264,6 @@ KeyboardShortcuts.defaultProps = {
selectedJob: null, 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; const { notify } = this.props;
if (MAX_SIZE - Object.keys(pinnedJobs).length > 0) { if (MAX_SIZE - Object.keys(pinnedJobs).length > 0) {
this.setValue({ this.setValue(
pinnedJobs: { ...pinnedJobs, [job.id]: job }, {
isPinBoardVisible: true, pinnedJobs: { ...pinnedJobs, [job.id]: job },
}, () => { if (callback) callback(); }); isPinBoardVisible: true,
},
() => {
if (callback) callback();
},
);
this.pulsePinCount(); this.pulsePinCount();
} else { } else {
notify(COUNT_ERROR, 'danger'); notify(COUNT_ERROR, 'danger');
@ -91,21 +96,26 @@ export class PinnedJobsClass extends React.Component {
const { notify } = this.props; const { notify } = this.props;
const spaceRemaining = MAX_SIZE - Object.keys(pinnedJobs).length; const spaceRemaining = MAX_SIZE - Object.keys(pinnedJobs).length;
const showError = jobsToPin.length > spaceRemaining; 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) { if (!spaceRemaining) {
notify(COUNT_ERROR, 'danger', { sticky: true }); notify(COUNT_ERROR, 'danger', { sticky: true });
return; return;
} }
this.setValue({ this.setValue(
pinnedJobs: { ...pinnedJobs, ...newPinnedJobs }, {
isPinBoardVisible: true, pinnedJobs: { ...pinnedJobs, ...newPinnedJobs },
}, () => { isPinBoardVisible: true,
if (showError) { },
notify(COUNT_ERROR, 'danger', { sticky: true }); () => {
} if (showError) {
}); notify(COUNT_ERROR, 'danger', { sticky: true });
}
},
);
} }
addBug(bug, job) { addBug(bug, job) {
@ -114,7 +124,7 @@ export class PinnedJobsClass extends React.Component {
pinnedJobBugs[bug.id] = bug; pinnedJobBugs[bug.id] = bug;
this.setValue({ pinnedJobBugs: { ...pinnedJobBugs } }); this.setValue({ pinnedJobBugs: { ...pinnedJobBugs } });
if (job) { if (job) {
this.pinJob(job); this.pinJob(job);
} }
} }

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

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

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

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

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

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

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

@ -50,19 +50,28 @@ class DetailsPanel extends React.Component {
componentDidMount() { componentDidMount() {
this.updateClassifications = this.updateClassifications.bind(this); this.updateClassifications = this.updateClassifications.bind(this);
window.addEventListener(thEvents.classificationChanged, this.updateClassifications); window.addEventListener(
thEvents.classificationChanged,
this.updateClassifications,
);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { selectedJob } = this.props; const { selectedJob } = this.props;
if (selectedJob && (!prevProps.selectedJob || prevProps.selectedJob !== selectedJob)) { if (
selectedJob &&
(!prevProps.selectedJob || prevProps.selectedJob !== selectedJob)
) {
this.selectJob(); this.selectJob();
} }
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener(thEvents.classificationChanged, this.updateClassifications); window.removeEventListener(
thEvents.classificationChanged,
this.updateClassifications,
);
} }
togglePinBoardVisibility() { togglePinBoardVisibility() {
@ -72,55 +81,57 @@ class DetailsPanel extends React.Component {
} }
loadBugSuggestions() { loadBugSuggestions() {
const { repoName, selectedJob } = this.props; const { repoName, selectedJob } = this.props;
if (!selectedJob) { if (!selectedJob) {
return; return;
} }
BugSuggestionsModel.get(selectedJob.id).then((suggestions) => { BugSuggestionsModel.get(selectedJob.id).then(suggestions => {
suggestions.forEach((suggestion) => { suggestions.forEach(suggestion => {
suggestion.bugs.too_many_open_recent = ( suggestion.bugs.too_many_open_recent =
suggestion.bugs.open_recent.length > thBugSuggestionLimit suggestion.bugs.open_recent.length > thBugSuggestionLimit;
); suggestion.bugs.too_many_all_others =
suggestion.bugs.too_many_all_others = ( suggestion.bugs.all_others.length > thBugSuggestionLimit;
suggestion.bugs.all_others.length > thBugSuggestionLimit suggestion.valid_open_recent =
); suggestion.bugs.open_recent.length > 0 &&
suggestion.valid_open_recent = ( !suggestion.bugs.too_many_open_recent;
suggestion.bugs.open_recent.length > 0 && suggestion.valid_all_others =
!suggestion.bugs.too_many_open_recent suggestion.bugs.all_others.length > 0 &&
); !suggestion.bugs.too_many_all_others &&
suggestion.valid_all_others = ( // If we have too many open_recent bugs, we're unlikely to have
suggestion.bugs.all_others.length > 0 && // relevant all_others bugs, so don't show them either.
!suggestion.bugs.too_many_all_others && !suggestion.bugs.too_many_open_recent;
// 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 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() { async updateClassifications() {
const { selectedJob } = this.props; 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 }); const bugs = await BugJobMapModel.getList({ job_id: selectedJob.id });
this.setState({ classifications, bugs }); this.setState({ classifications, bugs });
@ -129,115 +140,193 @@ class DetailsPanel extends React.Component {
selectJob() { selectJob() {
const { repoName, selectedJob, getPush } = this.props; const { repoName, selectedJob, getPush } = this.props;
this.setState({ jobDetails: [], suggestions: [], jobDetailLoading: true }, () => { this.setState(
if (this.selectJobController !== null) { { jobDetails: [], suggestions: [], jobDetailLoading: true },
// Cancel the in-progress fetch requests. () => {
this.selectJobController.abort(); 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 }];
} }
// the third result comes from the jobLogUrl promise this.selectJobController = new AbortController();
// exclude the json log URLs
const jobLogUrls = jobLogUrlResult.filter(log => !log.name.endsWith('_json'));
let logParseStatus = 'unavailable'; let jobDetails = [];
// Provide a parse status as a scope variable for logviewer shortcut const jobPromise =
if (jobLogUrls.length && jobLogUrls[0].parse_status) { 'logs' in selectedJob
logParseStatus = jobLogUrls[0].parse_status; ? Promise.resolve(selectedJob)
} : JobModel.get(
repoName,
selectedJob.id,
this.selectJobController.signal,
);
const logViewerUrl = getLogViewerUrl(selectedJob.id, repoName); const jobDetailPromise = JobDetailModel.getJobDetails(
const logViewerFullUrl = `${window.location.origin}/${logViewerUrl}`; { job_guid: selectedJob.job_guid },
const reftestUrl = jobLogUrls.length ? getReftestUrl(jobLogUrls[0].url) : ''; this.selectJobController.signal,
const performanceData = Object.values(phSeriesResult).reduce((a, b) => [...a, ...b], []); );
let perfJobDetail = [];
if (performanceData.length) { const jobLogUrlPromise = JobLogUrlModel.getList(
const signatureIds = [...new Set(performanceData.map(perf => perf.signature_id))]; { job_id: selectedJob.id },
const seriesListList = await Promise.all(chunk(signatureIds, 20).map( this.selectJobController.signal,
signatureIdChunk => PerfSeriesModel.getSeriesList(repoName, { id: signatureIdChunk }), );
));
const seriesList = seriesListList.reduce((a, b) => [...a, ...b], []);
perfJobDetail = performanceData.map(d => ({ const phSeriesPromise = PerfSeriesModel.getSeriesData(repoName, {
series: seriesList.find(s => d.signature_id === s.id), job_id: selectedJob.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; 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() { render() {
const { const {
repoName, user, currentRepo, resizedHeight, classificationMap, repoName,
classificationTypes, isPinBoardVisible, selectedJob, user,
currentRepo,
resizedHeight,
classificationMap,
classificationTypes,
isPinBoardVisible,
selectedJob,
} = this.props; } = this.props;
const { const {
jobDetails, jobRevision, jobLogUrls, jobDetailLoading, jobDetails,
perfJobDetail, suggestions, errors, bugSuggestionsLoading, logParseStatus, jobRevision,
classifications, logViewerUrl, logViewerFullUrl, bugs, reftestUrl, jobLogUrls,
jobDetailLoading,
perfJobDetail,
suggestions,
errors,
bugSuggestionsLoading,
logParseStatus,
classifications,
logViewerUrl,
logViewerFullUrl,
bugs,
reftestUrl,
} = this.state; } = this.state;
const detailsPanelHeight = isPinBoardVisible ? resizedHeight - pinboardHeight : resizedHeight; const detailsPanelHeight = isPinBoardVisible
? resizedHeight - pinboardHeight
: resizedHeight;
return ( return (
<div <div
@ -251,41 +340,47 @@ class DetailsPanel extends React.Component {
isLoggedIn={user.isLoggedIn || false} isLoggedIn={user.isLoggedIn || false}
classificationTypes={classificationTypes} classificationTypes={classificationTypes}
/> />
{!!selectedJob && <div id="details-panel-content"> {!!selectedJob && (
<SummaryPanel <div id="details-panel-content">
repoName={repoName} <SummaryPanel
currentRepo={currentRepo} repoName={repoName}
classificationMap={classificationMap} currentRepo={currentRepo}
jobLogUrls={jobLogUrls} classificationMap={classificationMap}
logParseStatus={logParseStatus} jobLogUrls={jobLogUrls}
jobDetailLoading={jobDetailLoading} logParseStatus={logParseStatus}
latestClassification={classifications.length ? classifications[0] : null} jobDetailLoading={jobDetailLoading}
logViewerUrl={logViewerUrl} latestClassification={
logViewerFullUrl={logViewerFullUrl} classifications.length ? classifications[0] : null
bugs={bugs} }
user={user} logViewerUrl={logViewerUrl}
/> logViewerFullUrl={logViewerFullUrl}
<span className="job-tabs-divider" /> bugs={bugs}
<TabsPanel user={user}
jobDetails={jobDetails} />
perfJobDetail={perfJobDetail} <span className="job-tabs-divider" />
repoName={repoName} <TabsPanel
jobRevision={jobRevision} jobDetails={jobDetails}
suggestions={suggestions} perfJobDetail={perfJobDetail}
errors={errors} repoName={repoName}
bugSuggestionsLoading={bugSuggestionsLoading} jobRevision={jobRevision}
logParseStatus={logParseStatus} suggestions={suggestions}
classifications={classifications} errors={errors}
classificationMap={classificationMap} bugSuggestionsLoading={bugSuggestionsLoading}
jobLogUrls={jobLogUrls} logParseStatus={logParseStatus}
bugs={bugs} classifications={classifications}
togglePinBoardVisibility={() => this.togglePinBoardVisibility()} classificationMap={classificationMap}
logViewerFullUrl={logViewerFullUrl} jobLogUrls={jobLogUrls}
reftestUrl={reftestUrl} bugs={bugs}
user={user} togglePinBoardVisibility={() => this.togglePinBoardVisibility()}
/> logViewerFullUrl={logViewerFullUrl}
</div>} reftestUrl={reftestUrl}
<div id="clipboard-container"><textarea id="clipboard" /></div> user={user}
/>
</div>
)}
<div id="clipboard-container">
<textarea id="clipboard" />
</div>
</div> </div>
); );
} }

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

@ -30,7 +30,9 @@ class PinBoard extends React.Component {
componentDidMount() { componentDidMount() {
this.bugNumberKeyPress = this.bugNumberKeyPress.bind(this); this.bugNumberKeyPress = this.bugNumberKeyPress.bind(this);
this.save = this.save.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.handleRelatedBugEscape = this.handleRelatedBugEscape.bind(this);
this.unPinAll = this.unPinAll.bind(this); this.unPinAll = this.unPinAll.bind(this);
this.retriggerAllPinnedJobs = this.retriggerAllPinnedJobs.bind(this); this.retriggerAllPinnedJobs = this.retriggerAllPinnedJobs.bind(this);
@ -62,7 +64,12 @@ class PinBoard extends React.Component {
} }
save() { save() {
const { isLoggedIn, pinnedJobs, recalculateUnclassifiedCounts, notify } = this.props; const {
isLoggedIn,
pinnedJobs,
recalculateUnclassifiedCounts,
notify,
} = this.props;
let errorFree = true; let errorFree = true;
if (this.state.enteringBugNumber) { if (this.state.enteringBugNumber) {
@ -107,7 +114,10 @@ class PinBoard extends React.Component {
createNewClassification() { createNewClassification() {
const { email } = this.props; const { email } = this.props;
const { failureClassificationId, failureClassificationComment } = this.state; const {
failureClassificationId,
failureClassificationComment,
} = this.state;
return new JobClassificationModel({ return new JobClassificationModel({
text: failureClassificationComment, text: failureClassificationComment,
@ -127,10 +137,18 @@ class PinBoard extends React.Component {
recalculateUnclassifiedCounts(); recalculateUnclassifiedCounts();
classification.job_id = job.id; classification.job_id = job.id;
return classification.create().then(() => { return classification
notify(`Classification saved for ${job.platform} ${job.job_type_name}`, 'success'); .create()
}).catch((response) => { .then(() => {
const message = `Error saving classification for ${job.platform} ${job.job_type_name}`; 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'); notify(formatModelError(response, message), 'danger');
}); });
} }
@ -139,21 +157,27 @@ class PinBoard extends React.Component {
saveBugs(job) { saveBugs(job) {
const { pinnedJobBugs, notify } = this.props; const { pinnedJobBugs, notify } = this.props;
Object.values(pinnedJobBugs).forEach((bug) => { Object.values(pinnedJobBugs).forEach(bug => {
const bjm = new BugJobMapModel({ const bjm = new BugJobMapModel({
bug_id: bug.id, bug_id: bug.id,
job_id: job.id, job_id: job.id,
type: 'annotation', type: 'annotation',
}); });
bjm.create() bjm
.create()
.then(() => { .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) => { .catch(response => {
const message = `Error saving bug association for ${job.platform} ${job.job_type_name}`; const message = `Error saving bug association for ${job.platform} ${
job.job_type_name
}`;
notify(formatModelError(response, message), 'danger'); notify(formatModelError(response, message), 'danger');
}); });
}); });
} }
@ -182,7 +206,8 @@ class PinBoard extends React.Component {
canCancelAllPinnedJobs() { canCancelAllPinnedJobs() {
const cancellableJobs = Object.values(this.props.pinnedJobs).filter( 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; return this.props.isLoggedIn && cancellableJobs.length > 0;
} }
@ -190,7 +215,9 @@ class PinBoard extends React.Component {
cancelAllPinnedJobs() { cancelAllPinnedJobs() {
const { getGeckoDecisionTaskId, notify, repoName } = this.props; 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); const jobIds = Object.keys(this.props.pinnedJobs);
JobModel.cancel(jobIds, repoName, getGeckoDecisionTaskId, notify); JobModel.cancel(jobIds, repoName, getGeckoDecisionTaskId, notify);
@ -200,24 +227,37 @@ class PinBoard extends React.Component {
canSaveClassifications() { canSaveClassifications() {
const { pinnedJobBugs, isLoggedIn, currentRepo } = this.props; 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 || (!!Object.keys(pinnedJobBugs).length ||
(failureClassificationId !== 4 && failureClassificationId !== 2) || (failureClassificationId !== 4 && failureClassificationId !== 2) ||
currentRepo.is_try_repo || currentRepo.is_try_repo ||
currentRepo.repository_group.name === 'project repositories' || currentRepo.repository_group.name === 'project repositories' ||
(failureClassificationId === 4 && failureClassificationComment.length > 0) || (failureClassificationId === 4 &&
(failureClassificationId === 2 && failureClassificationComment.length > 7)); failureClassificationComment.length > 0) ||
(failureClassificationId === 2 &&
failureClassificationComment.length > 7))
);
} }
// Facilitates Clear all if no jobs pinned to reset pinBoard UI // Facilitates Clear all if no jobs pinned to reset pinBoard UI
pinboardIsDirty() { pinboardIsDirty() {
const { failureClassificationId, failureClassificationComment } = this.state; const {
failureClassificationId,
failureClassificationComment,
} = this.state;
return failureClassificationComment !== '' || return (
failureClassificationComment !== '' ||
!!Object.keys(this.props.pinnedJobBugs).length || !!Object.keys(this.props.pinnedJobBugs).length ||
failureClassificationId !== 4; failureClassificationId !== 4
);
} }
// Dynamic btn/anchor title for classification save // Dynamic btn/anchor title for classification save
@ -274,23 +314,32 @@ class PinBoard extends React.Component {
} }
toggleEnterBugNumber(tf) { toggleEnterBugNumber(tf) {
this.setState({ this.setState(
enteringBugNumber: tf, {
}, () => { enteringBugNumber: tf,
if (tf) { },
document.getElementById('related-bug-input').focus(); () => {
// Bind escape to canceling the bug entry. if (tf) {
document.addEventListener('keydown', this.handleRelatedBugEscape); document.getElementById('related-bug-input').focus();
// Install a click handler on the document so that clicking // Bind escape to canceling the bug entry.
// outside of the input field will close it. A blur handler document.addEventListener('keydown', this.handleRelatedBugEscape);
// can't be used because it would have timing issues with the // Install a click handler on the document so that clicking
// click handler on the + icon. // outside of the input field will close it. A blur handler
document.addEventListener('click', this.handleRelatedBugDocumentClick); // can't be used because it would have timing issues with the
} else { // click handler on the + icon.
document.removeEventListener('keydown', this.handleRelatedBugEscape); document.addEventListener(
document.removeEventListener('click', this.handleRelatedBugDocumentClick); 'click',
} this.handleRelatedBugDocumentClick,
}); );
} else {
document.removeEventListener('keydown', this.handleRelatedBugEscape);
document.removeEventListener(
'click',
this.handleRelatedBugDocumentClick,
);
}
},
);
} }
isNumber(text) { isNumber(text) {
@ -335,39 +384,60 @@ class PinBoard extends React.Component {
render() { render() {
const { const {
selectedJob, revisionTips, isLoggedIn, isPinBoardVisible, classificationTypes, selectedJob,
pinnedJobs, pinnedJobBugs, removeBug, unPinJob, setSelectedJob, revisionTips,
isLoggedIn,
isPinBoardVisible,
classificationTypes,
pinnedJobs,
pinnedJobBugs,
removeBug,
unPinJob,
setSelectedJob,
} = this.props; } = this.props;
const { const {
failureClassificationId, failureClassificationComment, failureClassificationId,
enteringBugNumber, newBugNumber, failureClassificationComment,
enteringBugNumber,
newBugNumber,
} = this.state; } = this.state;
const selectedJobId = selectedJob ? selectedJob.id : null; const selectedJobId = selectedJob ? selectedJob.id : null;
return ( return (
<div <div id="pinboard-panel" className={isPinBoardVisible ? '' : 'hidden'}>
id="pinboard-panel"
className={isPinBoardVisible ? '' : 'hidden'}
>
<div id="pinboard-contents"> <div id="pinboard-contents">
<div id="pinned-job-list"> <div id="pinned-job-list">
<div className="content"> <div className="content">
{!this.hasPinnedJobs() && <span {!this.hasPinnedJobs() && (
className="pinboard-preload-txt" <span className="pinboard-preload-txt">
>press spacebar to pin a selected job</span>} press spacebar to pin a selected job
</span>
)}
{Object.values(pinnedJobs).map(job => ( {Object.values(pinnedJobs).map(job => (
<span className="btn-group" key={job.id}> <span className="btn-group" key={job.id}>
<span <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)} title={getHoverText(job)}
onClick={() => setSelectedJob(job)} onClick={() => setSelectedJob(job)}
data-job-id={job.job_id} data-job-id={job.job_id}
>{job.job_type_symbol}</span> >
{job.job_type_symbol}
</span>
<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)} onClick={() => unPinJob(job)}
title="un-pin this job" title="un-pin this job"
><i className="fa fa-times" /></span> >
<i className="fa fa-times" />
</span>
</span> </span>
))} ))}
</div> </div>
@ -381,45 +451,59 @@ class PinBoard extends React.Component {
onClick={() => this.toggleEnterBugNumber(!enteringBugNumber)} onClick={() => this.toggleEnterBugNumber(!enteringBugNumber)}
className="pointable" className="pointable"
title="Add a related bug" 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 <i className="fa fa-plus-square add-related-bugs-icon" />
id="related-bug-input" </span>
data-bug-input {!this.hasPinnedJobBugs() && (
type="text" <span
pattern="[0-9]*" className="pinboard-preload-txt pinboard-related-bug-preload-txt"
className="add-related-bugs-input" onClick={() => {
placeholder="enter bug number" this.toggleEnterBugNumber(!enteringBugNumber);
invalid={!this.isNumber(newBugNumber)} }}
onKeyPress={this.bugNumberKeyPress} >
onChange={ev => this.setState({ newBugNumber: ev.target.value })} click to add a related bug
/>
<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>
</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>
</div> </div>
@ -437,7 +521,9 @@ class PinBoard extends React.Component {
onChange={evt => this.setClassificationId(evt)} onChange={evt => this.setClassificationId(evt)}
> >
{classificationTypes.map(opt => ( {classificationTypes.map(opt => (
<option value={opt.id} key={opt.id}>{opt.name}</option> <option value={opt.id} key={opt.id}>
{opt.name}
</option>
))} ))}
</Input> </Input>
</FormGroup> </FormGroup>
@ -452,26 +538,32 @@ class PinBoard extends React.Component {
placeholder="click to add comment" placeholder="click to add comment"
value={failureClassificationComment} value={failureClassificationComment}
/> />
{failureClassificationId === 2 && <div> {failureClassificationId === 2 && (
<FormGroup> <div>
<Input <FormGroup>
id="pinboard-revision-select" <Input
className="classification-select" id="pinboard-revision-select"
type="select" className="classification-select"
defaultValue={0} type="select"
onChange={evt => this.setClassificationText(evt)} defaultValue={0}
> onChange={evt => this.setClassificationText(evt)}
<option value="0" disabled>Choose a recent >
commit <option value="0" disabled>
</option> Choose a recent commit
{revisionTips.slice(0, 20).map(tip => (<option </option>
title={tip.title} {revisionTips.slice(0, 20).map(tip => (
value={tip.revision} <option
key={tip.revision} title={tip.title}
>{tip.revision.slice(0, 12)} {tip.author}</option>))} value={tip.revision}
</Input> key={tip.revision}
</FormGroup> >
</div>} {tip.revision.slice(0, 12)} {tip.author}
</option>
))}
</Input>
</FormGroup>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -484,14 +576,27 @@ class PinBoard extends React.Component {
> >
<div className="btn-group save-btn-group dropdown"> <div className="btn-group save-btn-group dropdown">
<button <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')} title={this.saveUITitle('classification')}
onClick={this.save} onClick={this.save}
>save >
save
</button> </button>
<button <button
className={`btn btn-light-bordered btn-xs dropdown-toggle save-btn-dropdown ${!this.hasPinnedJobs() && !this.pinboardIsDirty() ? 'disabled' : ''}`} className={`btn btn-light-bordered btn-xs dropdown-toggle save-btn-dropdown ${
title={!this.hasPinnedJobs() && !this.pinboardIsDirty() ? 'No pinned jobs' : 'Additional pinboard functions'} !this.hasPinnedJobs() && !this.pinboardIsDirty()
? 'disabled'
: ''
}`}
title={
!this.hasPinnedJobs() && !this.pinboardIsDirty()
? 'No pinned jobs'
: 'Additional pinboard functions'
}
type="button" type="button"
data-toggle="dropdown" data-toggle="dropdown"
> >
@ -500,23 +605,36 @@ class PinBoard extends React.Component {
<ul className="dropdown-menu save-btn-dropdown-menu"> <ul className="dropdown-menu save-btn-dropdown-menu">
<li <li
className={!isLoggedIn ? 'disabled' : ''} className={!isLoggedIn ? 'disabled' : ''}
title={!isLoggedIn ? 'Not logged in' : 'Repeat the pinned jobs'} title={
!isLoggedIn ? 'Not logged in' : 'Repeat the pinned jobs'
}
> >
<a <a
className="dropdown-item" className="dropdown-item"
onClick={() => !isLoggedIn || this.retriggerAllPinnedJobs()} onClick={() => !isLoggedIn || this.retriggerAllPinnedJobs()}
>Retrigger all</a></li> >
Retrigger all
</a>
</li>
<li <li
className={this.canCancelAllPinnedJobs() ? '' : 'disabled'} className={this.canCancelAllPinnedJobs() ? '' : 'disabled'}
title={this.cancelAllPinnedJobsTitle()} title={this.cancelAllPinnedJobsTitle()}
> >
<a <a
className="dropdown-item" className="dropdown-item"
onClick={() => this.canCancelAllPinnedJobs() && this.cancelAllPinnedJobs()} onClick={() =>
>Cancel all</a> this.canCancelAllPinnedJobs() &&
this.cancelAllPinnedJobs()
}
>
Cancel all
</a>
</li>
<li>
<a className="dropdown-item" onClick={() => this.unPinAll()}>
Clear all
</a>
</li> </li>
<li><a className="dropdown-item" onClick={() => this.unPinAll()}>Clear
all</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -553,4 +671,6 @@ PinBoard.defaultProps = {
revisionTips: [], 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) { switch (logParseStatus) {
case 'pending': 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': 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': case 'unavailable':
notify('No logs available for this job', 'info'); break; notify('No logs available for this job', 'info');
break;
case 'parsed': case 'parsed':
$('.logviewer-btn')[0].click(); $('.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'); return notify('Must be logged in to create a gecko profile', 'danger');
} }
getGeckoDecisionTaskId( getGeckoDecisionTaskId(selectedJob.push_id).then(decisionTaskId =>
selectedJob.push_id).then(decisionTaskId => ( TaskclusterModel.load(decisionTaskId, selectedJob).then(results => {
TaskclusterModel.load(decisionTaskId, selectedJob).then((results) => { const geckoprofile = results.actions.find(
const geckoprofile = results.actions.find(result => result.name === 'geckoprofile'); result => result.name === 'geckoprofile',
);
if (geckoprofile === undefined || !Object.prototype.hasOwnProperty.call(geckoprofile, 'kind')) { if (
return notify('Job was scheduled without taskcluster support for GeckoProfiles'); geckoprofile === undefined ||
!Object.prototype.hasOwnProperty.call(geckoprofile, 'kind')
) {
return notify(
'Job was scheduled without taskcluster support for GeckoProfiles',
);
} }
TaskclusterModel.submit({ TaskclusterModel.submit({
@ -89,20 +98,21 @@ class ActionBar extends React.PureComponent {
task: results.originalTask, task: results.originalTask,
input: {}, input: {},
staticActionVariables: results.staticActionVariables, staticActionVariables: results.staticActionVariables,
}).then(() => { }).then(
notify( () => {
'Request sent to collect gecko profile job via actions.json', notify(
'success'); 'Request sent to collect gecko profile job via actions.json',
}, (e) => { 'success',
// The full message is too large to fit in a Treeherder );
// notification box. },
notify( e => {
formatTaskclusterError(e), // The full message is too large to fit in a Treeherder
'danger', // notification box.
{ sticky: true }); notify(formatTaskclusterError(e), 'danger', { sticky: true });
}); },
}) );
)); }),
);
} }
retriggerJob(jobs) { retriggerJob(jobs) {
@ -143,11 +153,15 @@ class ActionBar extends React.PureComponent {
return; return;
} }
if (selectedJob.build_system_type === 'taskcluster' || selectedJob.reason.startsWith('Created by BBB for task')) { if (
getGeckoDecisionTaskId( selectedJob.build_system_type === 'taskcluster' ||
selectedJob.push_id).then(decisionTaskId => ( selectedJob.reason.startsWith('Created by BBB for task')
TaskclusterModel.load(decisionTaskId, selectedJob).then((results) => { ) {
const backfilltask = results.actions.find(result => result.name === 'backfill'); getGeckoDecisionTaskId(selectedJob.push_id).then(decisionTaskId =>
TaskclusterModel.load(decisionTaskId, selectedJob).then(results => {
const backfilltask = results.actions.find(
result => result.name === 'backfill',
);
return TaskclusterModel.submit({ return TaskclusterModel.submit({
action: backfilltask, action: backfilltask,
@ -155,15 +169,21 @@ class ActionBar extends React.PureComponent {
taskId: results.originalTaskId, taskId: results.originalTaskId,
input: {}, input: {},
staticActionVariables: results.staticActionVariables, staticActionVariables: results.staticActionVariables,
}).then(() => { }).then(
notify('Request sent to backfill job via actions.json', 'success'); () => {
}, (e) => { notify(
// The full message is too large to fit in a Treeherder 'Request sent to backfill job via actions.json',
// notification box. 'success',
notify(formatTaskclusterError(e), 'danger', { sticky: true }); );
}); },
}) e => {
)); // The full message is too large to fit in a Treeherder
// notification box.
notify(formatTaskclusterError(e), 'danger', { sticky: true });
},
);
}),
);
} else { } else {
notify('Unable to backfill this job type!', 'danger', { sticky: true }); notify('Unable to backfill this job type!', 'danger', { sticky: true });
} }
@ -189,7 +209,8 @@ class ActionBar extends React.PureComponent {
} }
if (title === '') { 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'; 'to fill in gaps where the job was not run';
} else { } else {
// Cut off trailing '/ ' if one exists, capitalize first letter // Cut off trailing '/ ' if one exists, capitalize first letter
@ -200,17 +221,28 @@ class ActionBar extends React.PureComponent {
} }
async createInteractiveTask() { async createInteractiveTask() {
const { user, selectedJob, repoName, getGeckoDecisionTaskId, notify } = this.props; const {
user,
selectedJob,
repoName,
getGeckoDecisionTaskId,
notify,
} = this.props;
const jobId = selectedJob.id; const jobId = selectedJob.id;
if (!user.isLoggedIn) { 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 job = await JobModel.get(repoName, jobId);
const decisionTaskId = await getGeckoDecisionTaskId(job.push_id); const decisionTaskId = await getGeckoDecisionTaskId(job.push_id);
const results = await TaskclusterModel.load(decisionTaskId, job); 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 { try {
await TaskclusterModel.submit({ await TaskclusterModel.submit({
@ -226,7 +258,8 @@ class ActionBar extends React.PureComponent {
notify( notify(
`Request sent to create an interactive job via actions.json. `Request sent to create an interactive job via actions.json.
You will soon receive an email containing a link to interact with the task.`, You will soon receive an email containing a link to interact with the task.`,
'success'); 'success',
);
} catch (e) { } catch (e) {
// The full message is too large to fit in a Treeherder // The full message is too large to fit in a Treeherder
// notification box. // notification box.
@ -236,7 +269,9 @@ class ActionBar extends React.PureComponent {
cancelJobs(jobs) { cancelJobs(jobs) {
const { user, repoName, getGeckoDecisionTaskId, notify } = this.props; 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) { if (!user.isLoggedIn) {
return notify('Must be logged in to cancel a job', 'danger'); return notify('Must be logged in to cancel a job', 'danger');
@ -256,14 +291,20 @@ class ActionBar extends React.PureComponent {
} }
render() { render() {
const { selectedJob, logViewerUrl, logViewerFullUrl, jobLogUrls, user, pinJob } = this.props; const {
selectedJob,
logViewerUrl,
logViewerFullUrl,
jobLogUrls,
user,
pinJob,
} = this.props;
const { customJobActionsShowing } = this.state; const { customJobActionsShowing } = this.state;
return ( return (
<div id="job-details-actionbar"> <div id="job-details-actionbar">
<nav className="navbar navbar-dark details-panel-navbar"> <nav className="navbar navbar-dark details-panel-navbar">
<ul className="nav navbar-nav actionbar-nav"> <ul className="nav navbar-nav actionbar-nav">
<LogUrls <LogUrls
logUrls={jobLogUrls} logUrls={jobLogUrls}
logViewerUrl={logViewerUrl} logViewerUrl={logViewerUrl}
@ -275,32 +316,53 @@ class ActionBar extends React.PureComponent {
title="Add this job to the pinboard" title="Add this job to the pinboard"
className="btn icon-blue" className="btn icon-blue"
onClick={() => pinJob(selectedJob)} onClick={() => pinJob(selectedJob)}
><span className="fa fa-thumb-tack" /></span> >
<span className="fa fa-thumb-tack" />
</span>
</li> </li>
<li> <li>
<span <span
id="retrigger-btn" 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'}`} className={`btn ${user.isLoggedIn ? 'icon-green' : 'disabled'}`}
disabled={!user.isLoggedIn} disabled={!user.isLoggedIn}
onClick={() => this.retriggerJob([selectedJob])} onClick={() => this.retriggerJob([selectedJob])}
><span className="fa fa-repeat" /></span> >
<span className="fa fa-repeat" />
</span>
</li> </li>
{isReftest(selectedJob) && jobLogUrls.map(jobLogUrl => (<li key={`reftest-${jobLogUrl.id}`}> {isReftest(selectedJob) &&
<a jobLogUrls.map(jobLogUrl => (
title="Launch the Reftest Analyser in a new window" <li key={`reftest-${jobLogUrl.id}`}>
target="_blank" <a
rel="noopener noreferrer" title="Launch the Reftest Analyser in a new window"
href={getReftestUrl(jobLogUrl.url)} target="_blank"
><span className="fa fa-bar-chart-o" /></a> rel="noopener noreferrer"
</li>))} href={getReftestUrl(jobLogUrl.url)}
{this.canCancel() && <li> >
<a <span className="fa fa-bar-chart-o" />
title={user.isLoggedIn ? 'Cancel this job' : 'Must be logged in to cancel a job'} </a>
className={user.isLoggedIn ? 'hover-warning' : 'disabled'} </li>
onClick={() => this.cancelJob()} ))}
><span className="fa fa-times-circle cancel-job-icon" /></a> {this.canCancel() && (
</li>} <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>
<ul className="nav navbar-right"> <ul className="nav navbar-right">
<li className="dropdown"> <li className="dropdown">
@ -311,54 +373,76 @@ class ActionBar extends React.PureComponent {
aria-expanded="false" aria-expanded="false"
className="dropdown-toggle" className="dropdown-toggle"
data-toggle="dropdown" 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"> <ul className="dropdown-menu actionbar-menu" role="menu">
<li> <li>
<span <span
id="backfill-btn" id="backfill-btn"
className={`btn dropdown-item ${!user.isLoggedIn || !this.canBackfill() ? 'disabled' : ''}`} className={`btn dropdown-item ${
!user.isLoggedIn || !this.canBackfill() ? 'disabled' : ''
}`}
title={this.backfillButtonTitle()} title={this.backfillButtonTitle()}
onClick={() => !this.canBackfill() || this.backfillJob()} onClick={() => !this.canBackfill() || this.backfillJob()}
>Backfill</span> >
Backfill
</span>
</li> </li>
{selectedJob.taskcluster_metadata && <React.Fragment> {selectedJob.taskcluster_metadata && (
<li> <React.Fragment>
<a <li>
target="_blank" <a
rel="noopener noreferrer" target="_blank"
className="dropdown-item" rel="noopener noreferrer"
href={getInspectTaskUrl(selectedJob.taskcluster_metadata.task_id)} className="dropdown-item"
>Inspect Task</a> href={getInspectTaskUrl(
</li> selectedJob.taskcluster_metadata.task_id,
<li> )}
<a >
className="dropdown-item" Inspect Task
onClick={this.createInteractiveTask} </a>
>Create Interactive Task</a> </li>
</li> <li>
{isPerfTest(selectedJob) && <li> <a
<a className="dropdown-item"
className="dropdown-item" onClick={this.createInteractiveTask}
onClick={this.createGeckoProfile} >
>Create Gecko Profile</a> Create Interactive Task
</li>} </a>
<li> </li>
<a {isPerfTest(selectedJob) && (
onClick={this.toggleCustomJobActions} <li>
className="dropdown-item" <a
>Custom Action...</a> className="dropdown-item"
</li> onClick={this.createGeckoProfile}
</React.Fragment>} >
Create Gecko Profile
</a>
</li>
)}
<li>
<a
onClick={this.toggleCustomJobActions}
className="dropdown-item"
>
Custom Action...
</a>
</li>
</React.Fragment>
)}
</ul> </ul>
</li> </li>
</ul> </ul>
</nav> </nav>
{customJobActionsShowing && <CustomJobActions {customJobActionsShowing && (
job={selectedJob} <CustomJobActions
pushId={selectedJob.push_id} job={selectedJob}
isLoggedIn={user.isLoggedIn} pushId={selectedJob.push_id}
toggle={this.toggleCustomJobActions} isLoggedIn={user.isLoggedIn}
/>} toggle={this.toggleCustomJobActions}
/>
)}
</div> </div>
); );
} }
@ -385,4 +469,6 @@ ActionBar.defaultProps = {
jobLogUrls: [], 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'; import { longDateFormat } from '../../../helpers/display';
export default function ClassificationsPanel(props) { export default function ClassificationsPanel(props) {
const { const { classification, job, bugs, currentRepo, classificationMap } = props;
classification, job, bugs, currentRepo, classificationMap,
} = props;
const failureId = classification.failure_classification_id; 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]; const classificationName = classificationMap[failureId];
return ( return (
@ -21,23 +21,33 @@ export default function ClassificationsPanel(props) {
<i className={`fa ${iconClass}`} /> <i className={`fa ${iconClass}`} />
<span className="ml-1">{classificationName.name}</span> <span className="ml-1">{classificationName.name}</span>
</span> </span>
{!!bugs.length && {!!bugs.length && (
<a <a
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
href={getBugUrl(bugs[0].bug_id)} href={getBugUrl(bugs[0].bug_id)}
title={`View bug ${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> </li>
{classification.text.length > 0 && {classification.text.length > 0 && (
<li><em><RevisionLinkify repo={currentRepo}>{classification.text}</RevisionLinkify></em></li> <li>
} <em>
<RevisionLinkify repo={currentRepo}>
{classification.text}
</RevisionLinkify>
</em>
</li>
)}
<li className="revision-comment"> <li className="revision-comment">
{new Date(classification.created).toLocaleString('en-US', longDateFormat)} {new Date(classification.created).toLocaleString(
</li> 'en-US',
<li className="revision-comment"> longDateFormat,
{classification.who} )}
</li> </li>
<li className="revision-comment">{classification.who}</li>
</React.Fragment> </React.Fragment>
); );
} }

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

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

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

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

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

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

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

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

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

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

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

@ -51,15 +51,18 @@ class SimilarJobsTab extends React.Component {
offset: (page - 1) * this.pageSize, offset: (page - 1) * this.pageSize,
}; };
['filterBuildPlatformId', 'filterOptionCollectionHash'] ['filterBuildPlatformId', 'filterOptionCollectionHash'].forEach(key => {
.forEach((key) => { if (this.state[key]) {
if (this.state[key]) { const field = this.filterMap[key];
const field = this.filterMap[key]; options[field] = selectedJob[field];
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) { if (newSimilarJobs.length > 0) {
this.setState({ hasNextPage: newSimilarJobs.length > this.pageSize }); 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))]; const pushIds = [...new Set(newSimilarJobs.map(job => job.push_id))];
// get pushes and revisions for the given ids // get pushes and revisions for the given ids
let pushList = { results: [] }; 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) { if (resp.ok) {
pushList = await resp.json(); pushList = await resp.json();
// decorate the list of jobs with their result sets // decorate the list of jobs with their result sets
const pushes = pushList.results.reduce((acc, push) => ( const pushes = pushList.results.reduce(
{ ...acc, [push.id]: push } (acc, push) => ({ ...acc, [push.id]: push }),
), {}); {},
newSimilarJobs.forEach((simJob) => { );
newSimilarJobs.forEach(simJob => {
simJob.result_set = pushes[simJob.push_id]; simJob.result_set = pushes[simJob.push_id];
simJob.revisionResultsetFilterUrl = getJobsUrl({ repo: repoName, revision: simJob.result_set.revisions[0].revision }); simJob.revisionResultsetFilterUrl = getJobsUrl({
simJob.authorResultsetFilterUrl = getJobsUrl({ repo: repoName, author: simJob.result_set.author }); repo: repoName,
revision: simJob.result_set.revisions[0].revision,
});
simJob.authorResultsetFilterUrl = getJobsUrl({
repo: repoName,
author: simJob.result_set.author,
});
}); });
this.setState({ similarJobs: [...similarJobs, ...newSimilarJobs] }); this.setState({ similarJobs: [...similarJobs, ...newSimilarJobs] });
// on the first page show the first element info by default // on the first page show the first element info by default
@ -87,7 +100,11 @@ class SimilarJobsTab extends React.Component {
this.showJobInfo(newSimilarJobs[0]); this.showJobInfo(newSimilarJobs[0]);
} }
} else { } 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 }); this.setState({ isLoading: false });
@ -102,25 +119,30 @@ class SimilarJobsTab extends React.Component {
showJobInfo(job) { showJobInfo(job) {
const { repoName, classificationMap } = this.props; const { repoName, classificationMap } = this.props;
JobModel.get(repoName, job.id) JobModel.get(repoName, job.id).then(nextJob => {
.then((nextJob) => { nextJob.result_status = getStatus(nextJob);
nextJob.result_status = getStatus(nextJob); nextJob.duration = (nextJob.end_timestamp - nextJob.start_timestamp) / 60;
nextJob.duration = (nextJob.end_timestamp - nextJob.start_timestamp) / 60; nextJob.failure_classification =
nextJob.failure_classification = classificationMap[ classificationMap[nextJob.failure_classification_id];
nextJob.failure_classification_id];
// retrieve the list of error lines // retrieve the list of error lines
TextLogStepModel.get(nextJob.id).then((textLogSteps) => { TextLogStepModel.get(nextJob.id).then(textLogSteps => {
nextJob.error_lines = textLogSteps.reduce((acc, step) => ( nextJob.error_lines = textLogSteps.reduce(
[...acc, ...step.errors]), []); (acc, step) => [...acc, ...step.errors],
this.setState({ selectedSimilarJob: nextJob }); [],
}); );
this.setState({ selectedSimilarJob: nextJob });
}); });
});
} }
toggleFilter(filterField) { toggleFilter(filterField) {
this.setState( this.setState(
{ [filterField]: !this.state[filterField], similarJobs: [], isLoading: true }, {
[filterField]: !this.state[filterField],
similarJobs: [],
isLoading: true,
},
this.getSimilarJobs, this.getSimilarJobs,
); );
} }
@ -135,7 +157,9 @@ class SimilarJobsTab extends React.Component {
isLoading, isLoading,
} = this.state; } = this.state;
const button_class = job => getBtnClass(getStatus(job)); const button_class = job => getBtnClass(getStatus(job));
const selectedSimilarJobId = selectedSimilarJob ? selectedSimilarJob.id : null; const selectedSimilarJobId = selectedSimilarJob
? selectedSimilarJob.id
: null;
return ( return (
<div className="similar-jobs w-100"> <div className="similar-jobs w-100">
@ -154,19 +178,25 @@ class SimilarJobsTab extends React.Component {
<tr <tr
key={similarJob.id} key={similarJob.id}
onClick={() => this.showJobInfo(similarJob)} onClick={() => this.showJobInfo(similarJob)}
className={selectedSimilarJobId === similarJob.id ? 'table-active' : ''} className={
selectedSimilarJobId === similarJob.id ? 'table-active' : ''
}
> >
<td> <td>
<button <button
className={`btn btn-similar-jobs btn-xs ${button_class(similarJob)}`} className={`btn btn-similar-jobs btn-xs ${button_class(
>{similarJob.job_type_symbol} similarJob,
{similarJob.failure_classification_id > 1 && )}`}
<span>*</span>} >
{similarJob.job_type_symbol}
{similarJob.failure_classification_id > 1 && (
<span>*</span>
)}
</button> </button>
</td> </td>
<td <td title={toDateStr(similarJob.result_set.push_timestamp)}>
title={toDateStr(similarJob.result_set.push_timestamp)} {toShortDateStr(similarJob.result_set.push_timestamp)}
>{toShortDateStr(similarJob.result_set.push_timestamp)}</td> </td>
<td> <td>
<a href={similarJob.authorResultsetFilterUrl}> <a href={similarJob.authorResultsetFilterUrl}>
{similarJob.result_set.author} {similarJob.result_set.author}
@ -177,14 +207,18 @@ class SimilarJobsTab extends React.Component {
{similarJob.result_set.revisions[0].revision} {similarJob.result_set.revisions[0].revision}
</a> </a>
</td> </td>
</tr>))} </tr>
))}
</tbody> </tbody>
</table> </table>
{hasNextPage && {hasNextPage && (
<button <button
className="btn btn-light-bordered btn-sm link-style" className="btn btn-light-bordered btn-sm link-style"
onClick={this.showNext} onClick={this.showNext}
>Show previous jobs</button>} >
Show previous jobs
</button>
)}
</div> </div>
<div className="similar-job-detail-panel"> <div className="similar-job-detail-panel">
<form className="form form-inline"> <form className="form form-inline">
@ -195,7 +229,6 @@ class SimilarJobsTab extends React.Component {
checked={filterBuildPlatformId} checked={filterBuildPlatformId}
/> />
<small>Same platform</small> <small>Same platform</small>
</div> </div>
<div className="checkbox"> <div className="checkbox">
<input <input
@ -204,70 +237,83 @@ class SimilarJobsTab extends React.Component {
checked={filterOptionCollectionHash} checked={filterOptionCollectionHash}
/> />
<small>Same options</small> <small>Same options</small>
</div> </div>
</form> </form>
<div className="similar_job_detail"> <div className="similar_job_detail">
{selectedSimilarJob && <table className="table table-super-condensed"> {selectedSimilarJob && (
<tbody> <table className="table table-super-condensed">
<tr> <tbody>
<th>Result</th> <tr>
<td>{selectedSimilarJob.result_status}</td> <th>Result</th>
</tr> <td>{selectedSimilarJob.result_status}</td>
<tr> </tr>
<th>Build</th> <tr>
<td> <th>Build</th>
{selectedSimilarJob.build_architecture} {selectedSimilarJob.build_platform} {selectedSimilarJob.build_os} <td>
</td> {selectedSimilarJob.build_architecture}{' '}
</tr> {selectedSimilarJob.build_platform}{' '}
<tr> {selectedSimilarJob.build_os}
<th>Build option</th> </td>
<td> </tr>
{selectedSimilarJob.platform_option} <tr>
</td> <th>Build option</th>
</tr> <td>{selectedSimilarJob.platform_option}</td>
<tr> </tr>
<th>Job name</th> <tr>
<td>{selectedSimilarJob.job_type_name}</td> <th>Job name</th>
</tr> <td>{selectedSimilarJob.job_type_name}</td>
<tr> </tr>
<th>Started</th> <tr>
<td>{toDateStr(selectedSimilarJob.start_timestamp)}</td> <th>Started</th>
</tr> <td>{toDateStr(selectedSimilarJob.start_timestamp)}</td>
<tr> </tr>
<th>Duration</th> <tr>
<td> <th>Duration</th>
{selectedSimilarJob.duration >= 0 ? `${selectedSimilarJob.duration.toFixed(0)} minute(s)` : 'unknown'} <td>
</td> {selectedSimilarJob.duration >= 0
</tr> ? `${selectedSimilarJob.duration.toFixed(0)} minute(s)`
<tr> : 'unknown'}
<th>Classification</th> </td>
<td> </tr>
<label <tr>
className={`badge ${selectedSimilarJob.failure_classification.star}`} <th>Classification</th>
>{selectedSimilarJob.failure_classification.name}</label> <td>
</td> <label
</tr> className={`badge ${
{!!selectedSimilarJob.error_lines && <tr> selectedSimilarJob.failure_classification.star
<td colSpan={2}> }`}
<ul className="list-unstyled error_list"> >
{selectedSimilarJob.error_lines.map(error => (<li key={error.id}> {selectedSimilarJob.failure_classification.name}
<small title={error.line}>{error.line}</small> </label>
</li>))} </td>
</ul> </tr>
</td> {!!selectedSimilarJob.error_lines && (
</tr>} <tr>
</tbody> <td colSpan={2}>
</table>} <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>
</div> </div>
{isLoading && <div className="overlay"> {isLoading && (
<div> <div className="overlay">
<span className="fa fa-spinner fa-pulse th-spinner-lg" /> <div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
</div>
</div> </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 // 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 // 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. // 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( const tabIndex = TabsPanel.getDefaultTabIndex(
getStatus(selectedJob), getStatus(selectedJob),
!!perfJobDetail.length, showAutoclassifyTab, !!perfJobDetail.length,
showAutoclassifyTab,
); );
return { return {
@ -62,28 +66,45 @@ class TabsPanel extends React.Component {
const { tabIndex, showAutoclassifyTab } = this.state; const { tabIndex, showAutoclassifyTab } = this.state;
const { perfJobDetail } = this.props; const { perfJobDetail } = this.props;
const nextIndex = tabIndex + 1; 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 }); this.setState({ tabIndex: nextIndex < tabCount ? nextIndex : 0 });
} }
static getDefaultTabIndex(status, showPerf, showAutoclassify) { static getDefaultTabIndex(status, showPerf, showAutoclassify) {
let idx = 0; let idx = 0;
const tabNames = TabsPanel.getTabNames(showPerf, showAutoclassify); 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; let tabIndex = showPerf ? tabIndexes.perf : tabIndexes.details;
if (['busted', 'testfailed', 'exception'].includes(status)) { if (['busted', 'testfailed', 'exception'].includes(status)) {
tabIndex = showAutoclassify ? tabIndexes.autoclassify : tabIndexes.failure; tabIndex = showAutoclassify
? tabIndexes.autoclassify
: tabIndexes.failure;
} }
return tabIndex; return tabIndex;
} }
static getTabNames(showPerf, showAutoclassify) { static getTabNames(showPerf, showAutoclassify) {
return [ return [
'details', 'failure', 'autoclassify', 'annotations', 'similar', 'perf', 'details',
].filter(name => ( 'failure',
!((name === 'autoclassify' && !showAutoclassify) || (name === 'perf' && !showPerf)) 'autoclassify',
)); 'annotations',
'similar',
'perf',
].filter(
name =>
!(
(name === 'autoclassify' && !showAutoclassify) ||
(name === 'perf' && !showPerf)
),
);
} }
setTabIndex(tabIndex) { setTabIndex(tabIndex) {
@ -92,10 +113,25 @@ class TabsPanel extends React.Component {
render() { render() {
const { const {
jobDetails, jobLogUrls, logParseStatus, suggestions, errors, user, bugs, jobDetails,
bugSuggestionsLoading, perfJobDetail, repoName, jobRevision, jobLogUrls,
classifications, togglePinBoardVisibility, isPinBoardVisible, pinnedJobs, logParseStatus,
classificationMap, logViewerFullUrl, reftestUrl, clearSelectedJob, suggestions,
errors,
user,
bugs,
bugSuggestionsLoading,
perfJobDetail,
repoName,
jobRevision,
classifications,
togglePinBoardVisibility,
isPinBoardVisible,
pinnedJobs,
classificationMap,
logViewerFullUrl,
reftestUrl,
clearSelectedJob,
} = this.props; } = this.props;
const { showAutoclassifyTab, tabIndex } = this.state; const { showAutoclassifyTab, tabIndex } = this.state;
const countPinnedJobs = Object.keys(pinnedJobs).length; const countPinnedJobs = Object.keys(pinnedJobs).length;
@ -116,30 +152,50 @@ class TabsPanel extends React.Component {
<Tab>Similar Jobs</Tab> <Tab>Similar Jobs</Tab>
{!!perfJobDetail.length && <Tab>Performance</Tab>} {!!perfJobDetail.length && <Tab>Performance</Tab>}
</span> </span>
<span id="tab-header-buttons" className="details-panel-controls pull-right"> <span
id="tab-header-buttons"
className="details-panel-controls pull-right"
>
<span <span
id="pinboard-btn" id="pinboard-btn"
className="btn pinboard-btn-text" className="btn pinboard-btn-text"
onClick={togglePinBoardVisibility} onClick={togglePinBoardVisibility}
title={isPinBoardVisible ? 'Close the pinboard' : 'Open the pinboard'} title={
>PinBoard isPinBoardVisible ? 'Close the pinboard' : 'Open the pinboard'
{!!countPinnedJobs && <div }
id="pin-count-group" >
title={`You have ${countPinnedJobs} job${countPinnedJobs > 1 ? 's' : ''} pinned`} PinBoard
className={`${countPinnedJobs > 99 ? 'pin-count-group-3-digit' : ''}`} {!!countPinnedJobs && (
>
<div <div
className={`pin-count-text ${countPinnedJobs > 99 ? 'pin-count-group-3-digit' : ''}`} id="pin-count-group"
>{countPinnedJobs}</div> title={`You have ${countPinnedJobs} job${
</div>} 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 <span
className={`fa ${isPinBoardVisible ? 'fa-angle-down' : 'fa-angle-up'}`} className={`fa ${
isPinBoardVisible ? 'fa-angle-down' : 'fa-angle-up'
}`}
/> />
</span> </span>
<span <span
onClick={() => clearSelectedJob(countPinnedJobs)} onClick={() => clearSelectedJob(countPinnedJobs)}
className="btn details-panel-close-btn" className="btn details-panel-close-btn"
><span className="fa fa-times" /></span> >
<span className="fa fa-times" />
</span>
</span> </span>
</TabList> </TabList>
<TabPanel> <TabPanel>
@ -156,15 +212,17 @@ class TabsPanel extends React.Component {
reftestUrl={reftestUrl} reftestUrl={reftestUrl}
/> />
</TabPanel> </TabPanel>
{showAutoclassifyTab && <TabPanel> {showAutoclassifyTab && (
<AutoclassifyTab <TabPanel>
hasLogs={!!jobLogUrls.length} <AutoclassifyTab
logsParsed={logParseStatus !== 'pending'} hasLogs={!!jobLogUrls.length}
logParseStatus={logParseStatus} logsParsed={logParseStatus !== 'pending'}
user={user} logParseStatus={logParseStatus}
repoName={repoName} user={user}
/> repoName={repoName}
</TabPanel>} />
</TabPanel>
)}
<TabPanel> <TabPanel>
<AnnotationsTab <AnnotationsTab
classificationMap={classificationMap} classificationMap={classificationMap}
@ -178,13 +236,15 @@ class TabsPanel extends React.Component {
classificationMap={classificationMap} classificationMap={classificationMap}
/> />
</TabPanel> </TabPanel>
{!!perfJobDetail.length && <TabPanel> {!!perfJobDetail.length && (
<PerformanceTab <TabPanel>
repoName={repoName} <PerformanceTab
perfJobDetail={perfJobDetail} repoName={repoName}
revision={jobRevision} perfJobDetail={perfJobDetail}
/> revision={jobRevision}
</TabPanel>} />
</TabPanel>
)}
</Tabs> </Tabs>
</div> </div>
); );

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

@ -68,10 +68,9 @@ class AutoclassifyTab extends React.Component {
*/ */
onSaveAll(pendingLines) { onSaveAll(pendingLines) {
const pending = pendingLines || Array.from(this.state.inputByLine.values()); const pending = pendingLines || Array.from(this.state.inputByLine.values());
this.save(pending) this.save(pending).then(() => {
.then(() => { this.setState({ selectedLineIds: new Set() });
this.setState({ selectedLineIds: new Set() }); });
});
} }
/** /**
@ -121,21 +120,32 @@ class AutoclassifyTab extends React.Component {
getLoadStatusText() { getLoadStatusText() {
switch (this.state.loadStatus) { switch (this.state.loadStatus) {
case 'job_pending': return 'Job not complete, please wait'; case 'job_pending':
case 'pending': return 'Logs not fully parsed, please wait'; return 'Job not complete, please wait';
case 'failed': return 'Log parsing failed'; case 'pending':
case 'no_logs': return 'No errors logged'; return 'Logs not fully parsed, please wait';
case 'error': return 'Error showing autoclassification data'; case 'failed':
case 'loading': return null; return 'Log parsing failed';
case 'ready': return (!this.state.errorLines || this.state.errorLines.length === 0) ? 'No error lines reported' : null; case 'no_logs':
default: return `Unexpected status: ${this.state.loadStatus}`; 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) { setEditable(lineIds, editable) {
const { editableLineIds } = this.state; const { editableLineIds } = this.state;
const f = editable ? lineId => editableLineIds.add(lineId) : const f = editable
lineId => editableLineIds.delete(lineId); ? lineId => editableLineIds.add(lineId)
: lineId => editableLineIds.delete(lineId);
lineIds.forEach(f); lineIds.forEach(f);
this.setState({ editableLineIds }); this.setState({ editableLineIds });
@ -154,18 +164,23 @@ class AutoclassifyTab extends React.Component {
async fetchErrorData() { async fetchErrorData() {
const { selectedJob } = this.props; const { selectedJob } = this.props;
this.setState({ this.setState(
{
loadStatus: 'loading', loadStatus: 'loading',
errorLines: [], errorLines: [],
selectedLineIds: new Set(), selectedLineIds: new Set(),
editableLineIds: new Set(), editableLineIds: new Set(),
inputByLine: new Map(), inputByLine: new Map(),
autoclassifyStatusOnLoad: null, autoclassifyStatusOnLoad: null,
}, async () => { },
async () => {
if (selectedJob.id) { 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 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); .sort((a, b) => a.data.id - b.data.id);
if (errorLines.length) { if (errorLines.length) {
@ -180,10 +195,10 @@ class AutoclassifyTab extends React.Component {
loadStatus: 'ready', loadStatus: 'ready',
}); });
} }
}); },
);
} }
/** /**
* Test if it is possible to save a specific line. * Test if it is possible to save a specific line.
* @param {number} lineId - Line id to test. * @param {number} lineId - Line id to test.
@ -214,8 +229,11 @@ class AutoclassifyTab extends React.Component {
canSaveAll() { canSaveAll() {
const pendingLines = this.getPendingLines(); const pendingLines = this.getPendingLines();
return (this.state.canClassify && !!pendingLines.length && return (
pendingLines.every(line => this.canSave(line.id))); 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' }); this.setState({ loadStatus: 'loading' });
return TextLogErrorsModel return TextLogErrorsModel.verifyMany(data)
.verifyMany(data) .then(data => {
.then((data) => { const newErrorLines = data.reduce(
const newErrorLines = data.reduce((newLines, updatedLine) => { (newLines, updatedLine) => {
const idx = newLines.findIndex(line => line.id === updatedLine.id); const idx = newLines.findIndex(line => line.id === updatedLine.id);
newLines[idx] = new ErrorLineData(updatedLine); newLines[idx] = new ErrorLineData(updatedLine);
return newLines; return newLines;
}, [...errorLines]); },
[...errorLines],
);
this.setState({ errorLines: newErrorLines, loadStatus: 'ready' }); this.setState({ errorLines: newErrorLines, loadStatus: 'ready' });
}) })
.catch((err) => { .catch(err => {
const prefix = 'Error saving classifications: '; 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 }); notify(msg, 'danger', { sticky: true });
}); });
} }
@ -257,7 +279,13 @@ class AutoclassifyTab extends React.Component {
* Update the panel for a new job selection * Update the panel for a new job selection
*/ */
jobChanged() { jobChanged() {
const { autoclassifyStatus, hasLogs, logsParsed, logParseStatus, selectedJob } = this.props; const {
autoclassifyStatus,
hasLogs,
logsParsed,
logParseStatus,
selectedJob,
} = this.props;
const { loadStatus, autoclassifyStatusOnLoad } = this.state; const { loadStatus, autoclassifyStatusOnLoad } = this.state;
let newLoadStatus = 'loading'; let newLoadStatus = 'loading';
@ -269,7 +297,10 @@ class AutoclassifyTab extends React.Component {
newLoadStatus = 'failed'; newLoadStatus = 'failed';
} else if (!hasLogs) { } else if (!hasLogs) {
newLoadStatus = 'no_logs'; newLoadStatus = 'no_logs';
} else if (autoclassifyStatusOnLoad === null || autoclassifyStatusOnLoad === 'cross_referenced') { } else if (
autoclassifyStatusOnLoad === null ||
autoclassifyStatusOnLoad === 'cross_referenced'
) {
if (loadStatus !== 'ready') { if (loadStatus !== 'ready') {
newLoadStatus = 'loading'; newLoadStatus = 'loading';
} }
@ -331,46 +362,52 @@ class AutoclassifyTab extends React.Component {
return ( return (
<React.Fragment> <React.Fragment>
{canClassify && <AutoclassifyToolbar {canClassify && (
loadStatus={loadStatus} <AutoclassifyToolbar
autoclassifyStatus={autoclassifyStatus} loadStatus={loadStatus}
user={user} autoclassifyStatus={autoclassifyStatus}
hasSelection={!!selectedLineIds.size} user={user}
canSave={canSave} hasSelection={!!selectedLineIds.size}
canSaveAll={canSaveAll} canSave={canSave}
canClassify={canClassify} canSaveAll={canSaveAll}
onPin={this.onPin} canClassify={canClassify}
onIgnore={this.onIgnore} onPin={this.onPin}
onEdit={this.onToggleEditable} onIgnore={this.onIgnore}
onSave={this.onSave} onEdit={this.onToggleEditable}
onSaveAll={() => this.onSaveAll()} onSave={this.onSave}
/>} onSaveAll={() => this.onSaveAll()}
/>
)}
<div> <div>
{loadStatusText && <span>{loadStatusText}</span>} {loadStatusText && <span>{loadStatusText}</span>}
{loadStatus === 'loading' && <div className="overlay"> {loadStatus === 'loading' && (
<div> <div className="overlay">
<span className="fa fa-spinner fa-pulse th-spinner-lg" /> <div>
<span className="fa fa-spinner fa-pulse th-spinner-lg" />
</div>
</div> </div>
</div>} )}
</div> </div>
<span className="autoclassify-error-lines"> <span className="autoclassify-error-lines">
<ul className="list-unstyled"> <ul className="list-unstyled">
{errorLines.map((errorLine, idx) => (<li key={errorLine.id}> {errorLines.map((errorLine, idx) => (
<ErrorLine <li key={errorLine.id}>
errorMatchers={errorMatchers} <ErrorLine
errorLine={errorLine} errorMatchers={errorMatchers}
prevErrorLine={errorLines[idx - 1]} errorLine={errorLine}
canClassify={canClassify} prevErrorLine={errorLines[idx - 1]}
isSelected={selectedLineIds.has(errorLine.id)} canClassify={canClassify}
isEditable={editableLineIds.has(errorLine.id)} isSelected={selectedLineIds.has(errorLine.id)}
setEditable={() => this.setEditable([errorLine.id], true)} isEditable={editableLineIds.has(errorLine.id)}
setErrorLineInput={this.setErrorLineInput} setEditable={() => this.setEditable([errorLine.id], true)}
toggleSelect={this.toggleSelect} setErrorLineInput={this.setErrorLineInput}
repoName={repoName} toggleSelect={this.toggleSelect}
/> repoName={repoName}
</li>))} />
</li>
))}
</ul> </ul>
</span> </span>
</React.Fragment> </React.Fragment>
@ -396,4 +433,6 @@ AutoclassifyTab.defaultProps = {
logParseStatus: 'pending', 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'; import PropTypes from 'prop-types';
export default class AutoclassifyToolbar extends React.Component { export default class AutoclassifyToolbar extends React.Component {
getButtonTitle(condition, activeTitle, inactiveTitle) { getButtonTitle(condition, activeTitle, inactiveTitle) {
const { user } = this.props; const { user } = this.props;
@ -20,60 +19,88 @@ export default class AutoclassifyToolbar extends React.Component {
render() { render() {
const { const {
hasSelection, canSave, canSaveAll, canClassify, onPin, onIgnore, onSave, hasSelection,
onSaveAll, onEdit, autoclassifyStatus, canSave,
canSaveAll,
canClassify,
onPin,
onIgnore,
onSave,
onSaveAll,
onEdit,
autoclassifyStatus,
} = this.props; } = this.props;
return ( return (
<div className="autoclassify-toolbar th-context-navbar navbar-right"> <div className="autoclassify-toolbar th-context-navbar navbar-right">
{ {// TODO: This is broken (bug 1504711)
// TODO: This is broken (bug 1504711) // eslint-disable-next-line no-restricted-globals
// eslint-disable-next-line no-restricted-globals status === 'ready' && (
status === 'ready' && ( <div>
<div> {autoclassifyStatus === 'cross_referenced' && (
{autoclassifyStatus === 'cross_referenced' && ( <span>Autoclassification pending</span>
<span>Autoclassification pending</span> )}
)} {autoclassifyStatus === 'failed' && (
{autoclassifyStatus === 'failed' && ( <span>Autoclassification failed</span>
<span>Autoclassification failed</span> )}
)} </div>
</div>
)} )}
<button <button
className="btn btn-view-nav btn-sm nav-menu-btn" className="btn btn-view-nav btn-sm nav-menu-btn"
title="Pin job for bustage" title="Pin job for bustage"
onClick={onPin} onClick={onPin}
>Bustage >
Bustage
</button> </button>
<button <button
className="btn btn-view-nav btn-sm nav-menu-btn" 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} onClick={onEdit}
disabled={hasSelection && !canClassify} disabled={hasSelection && !canClassify}
>Edit</button> >
Edit
</button>
<button <button
className="btn btn-view-nav btn-sm nav-menu-btn" 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} onClick={onIgnore}
disabled={hasSelection && !canClassify} disabled={hasSelection && !canClassify}
>Ignore</button> >
Ignore
</button>
<button <button
className="btn btn-view-nav btn-sm nav-menu-btn" className="btn btn-view-nav btn-sm nav-menu-btn"
title={this.getButtonTitle(canSave, 'Save', 'Nothing selected')} title={this.getButtonTitle(canSave, 'Save', 'Nothing selected')}
onClick={onSave} onClick={onSave}
disabled={!canSave} disabled={!canSave}
>Save</button> >
Save
</button>
<button <button
className="btn btn-view-nav btn-sm nav-menu-btn" 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} onClick={onSaveAll}
disabled={!canSaveAll} disabled={!canSaveAll}
>Save All</button> >
Save All
</button>
</div> </div>
); );
} }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -17,7 +17,8 @@ const menuItems = [
text: 'API Reference', text: 'API Reference',
}, },
{ {
href: 'https://wiki.mozilla.org/EngineeringProductivity/Projects/Treeherder', href:
'https://wiki.mozilla.org/EngineeringProductivity/Projects/Treeherder',
icon: 'fa-file-word-o', icon: 'fa-file-word-o',
text: 'Project Wiki', text: 'Project Wiki',
}, },
@ -27,7 +28,8 @@ const menuItems = [
text: 'Mailing List', 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', icon: 'fa-bug',
text: 'Report a Bug', text: 'Report a Bug',
}, },
@ -37,9 +39,10 @@ const menuItems = [
text: 'Source', 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', icon: 'fa-question',
text: 'What\'s Deployed?', text: "What's Deployed?",
}, },
]; ];
@ -60,11 +63,19 @@ export default function HelpMenu() {
role="menu" role="menu"
aria-labelledby="helpLabel" aria-labelledby="helpLabel"
> >
{menuItems.map(item => (<li key={item.text}> {menuItems.map(item => (
<a href={item.href} target="_blank" rel="noopener noreferrer" className="dropdown-item"> <li key={item.text}>
<span className={`fa ${item.icon} midgray`} />{item.text} <a
</a> href={item.href}
</li>))} target="_blank"
rel="noopener noreferrer"
className="dropdown-item"
>
<span className={`fa ${item.icon} midgray`} />
{item.text}
</a>
</li>
))}
</ul> </ul>
</span> </span>
); );

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -8,12 +8,15 @@ export default function UpdateAvailable(props) {
return ( return (
<div className="alert alert-info update-alert-panel"> <div className="alert alert-info update-alert-panel">
<i className="fa fa-info-circle" aria-hidden="true" /> <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 <button
onClick={updateButtonClick} onClick={updateButtonClick}
className="btn btn-xs btn-danger" className="btn btn-xs btn-danger"
type="button" type="button"
>Reload</button> >
Reload
</button>
</div> </div>
); );
} }

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

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

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

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

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

@ -3,14 +3,15 @@ import PropTypes from 'prop-types';
export default function JobCount(props) { export default function JobCount(props) {
const { className, title, onClick, count } = 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 ( return (
<button <button className={classes.join(' ')} title={title} onClick={onClick}>
className={classes.join(' ')} {count}
title={title} </button>
onClick={onClick}
>{count}</button>
); );
} }

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

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

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

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

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

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

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

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

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

@ -56,19 +56,27 @@ class PushActionMenu extends React.PureComponent {
triggerMissingJobs() { triggerMissingJobs() {
const { getGeckoDecisionTaskId, notify } = this.props; 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; return;
} }
getGeckoDecisionTaskId(this.pushId) getGeckoDecisionTaskId(this.pushId)
.then((decisionTaskID) => { .then(decisionTaskID => {
PushModel.triggerMissingJobs(decisionTaskID) PushModel.triggerMissingJobs(decisionTaskID)
.then((msg) => { .then(msg => {
notify(msg, 'success'); notify(msg, 'success');
}).catch((e) => { })
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true }); notify(formatTaskclusterError(e), 'danger', { sticky: true });
}); });
}).catch((e) => { })
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true }); notify(formatTaskclusterError(e), 'danger', { sticky: true });
}); });
} }
@ -76,24 +84,38 @@ class PushActionMenu extends React.PureComponent {
triggerAllTalosJobs() { triggerAllTalosJobs() {
const { getGeckoDecisionTaskId, notify } = this.props; 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; 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)) { 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) getGeckoDecisionTaskId(this.pushId)
.then((decisionTaskID) => { .then(decisionTaskID => {
PushModel.triggerAllTalosJobs(times, decisionTaskID) PushModel.triggerAllTalosJobs(times, decisionTaskID)
.then((msg) => { .then(msg => {
notify(msg, 'success'); notify(msg, 'success');
}).catch((e) => { })
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true }); notify(formatTaskclusterError(e), 'danger', { sticky: true });
}); });
}).catch((e) => { })
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true }); notify(formatTaskclusterError(e), 'danger', { sticky: true });
}); });
} }
@ -105,9 +127,20 @@ class PushActionMenu extends React.PureComponent {
} }
render() { render() {
const { isLoggedIn, repoName, revision, runnableVisible, const {
hideRunnableJobs, showRunnableJobs, pushId } = this.props; isLoggedIn,
const { topOfRangeUrl, bottomOfRangeUrl, customJobActionsShowing } = this.state; repoName,
revision,
runnableVisible,
hideRunnableJobs,
showRunnableJobs,
pushId,
} = this.props;
const {
topOfRangeUrl,
bottomOfRangeUrl,
customJobActionsShowing,
} = this.state;
return ( return (
<span className="btn-group dropdown" dropdown="true"> <span className="btn-group dropdown" dropdown="true">
@ -124,57 +157,96 @@ class PushActionMenu extends React.PureComponent {
</button> </button>
<ul className="dropdown-menu pull-right"> <ul className="dropdown-menu pull-right">
{runnableVisible ? {runnableVisible ? (
<li <li
title="Hide Runnable Jobs" title="Hide Runnable Jobs"
className="dropdown-item" className="dropdown-item"
onClick={hideRunnableJobs} onClick={hideRunnableJobs}
>Hide Runnable Jobs</li> : >
Hide Runnable Jobs
</li>
) : (
<li <li
title={isLoggedIn ? 'Add new jobs to this push' : 'Must be logged in'} title={
className={isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'} isLoggedIn ? 'Add new jobs to this push' : 'Must be logged in'
}
className={
isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'
}
onClick={showRunnableJobs} onClick={showRunnableJobs}
>Add new jobs</li> >
} Add new jobs
{this.triggerMissingRepos.includes(repoName) && </li>
)}
{this.triggerMissingRepos.includes(repoName) && (
<li <li
title={isLoggedIn ? 'Trigger all jobs that were optimized away' : 'Must be logged in'} title={
className={isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'} isLoggedIn
? 'Trigger all jobs that were optimized away'
: 'Must be logged in'
}
className={
isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'
}
onClick={() => this.triggerMissingJobs(revision)} onClick={() => this.triggerMissingJobs(revision)}
>Trigger missing jobs</li> >
} Trigger missing jobs
</li>
)}
<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'} className={isLoggedIn ? 'dropdown-item' : 'dropdown-item disabled'}
onClick={() => this.triggerAllTalosJobs(revision)} onClick={() => this.triggerAllTalosJobs(revision)}
>Trigger all Talos jobs</li> >
<li><a Trigger all Talos jobs
target="_blank" </li>
rel="noopener noreferrer" <li>
className="dropdown-item" <a
href={`https://bugherder.mozilla.org/?cset=${revision}&tree=${repoName}`} target="_blank"
title="Use Bugherder to mark the bugs in this push" rel="noopener noreferrer"
>Mark with Bugherder</a></li> 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 <li
className="dropdown-item" className="dropdown-item"
onClick={this.toggleCustomJobActions} onClick={this.toggleCustomJobActions}
title="View/Edit/Submit Action tasks for this push" title="View/Edit/Submit Action tasks for this push"
>Custom Push Action...</li> >
<li><a Custom Push Action...
className="dropdown-item top-of-range-menu-item" </li>
href={topOfRangeUrl} <li>
>Set as top of range</a></li> <a
<li><a className="dropdown-item top-of-range-menu-item"
className="dropdown-item bottom-of-range-menu-item" href={topOfRangeUrl}
href={bottomOfRangeUrl} >
>Set as bottom of range</a></li> 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> </ul>
{customJobActionsShowing && <CustomJobActions {customJobActionsShowing && (
job={null} <CustomJobActions
pushId={pushId} job={null}
isLoggedIn={isLoggedIn} pushId={pushId}
toggle={this.toggleCustomJobActions} isLoggedIn={isLoggedIn}
/>} toggle={this.toggleCustomJobActions}
/>
)}
</span> </span>
); );
} }

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

@ -16,7 +16,13 @@ import PushActionMenu from './PushActionMenu';
// url params we don't want added from the current querystring to the revision // url params we don't want added from the current querystring to the revision
// and author links. // and author links.
const SKIPPED_LINK_PARAMS = [ const SKIPPED_LINK_PARAMS = [
'revision', 'fromchange', 'tochange', 'nojobs', 'startdate', 'enddate', 'author', 'revision',
'fromchange',
'tochange',
'nojobs',
'startdate',
'enddate',
'author',
]; ];
function Author(props) { function Author(props) {
@ -43,14 +49,12 @@ function PushCounts(props) {
return ( return (
<span className="push-progress"> <span className="push-progress">
{percentComplete === 100 && {percentComplete === 100 && <span>- Complete -</span>}
<span>- Complete -</span> {percentComplete < 100 && total > 0 && (
} <span title="Proportion of jobs that are complete">
{percentComplete < 100 && total > 0 && {percentComplete}% - {inProgress} in progress
<span </span>
title="Proportion of jobs that are complete" )}
>{percentComplete}% - {inProgress} in progress</span>
}
</span> </span>
); );
} }
@ -78,34 +82,45 @@ class PushHeader extends React.PureComponent {
getLinkParams() { getLinkParams() {
const { filterModel } = this.props; const { filterModel } = this.props;
return Object.entries(filterModel.getUrlParamsWithoutDefaults()) return Object.entries(filterModel.getUrlParamsWithoutDefaults()).reduce(
.reduce((acc, [field, values]) => ( (acc, [field, values]) =>
SKIPPED_LINK_PARAMS.includes(field) ? acc : { ...acc, [field]: values } SKIPPED_LINK_PARAMS.includes(field) ? acc : { ...acc, [field]: values },
), {}); {},
);
} }
triggerNewJobs() { triggerNewJobs() {
const { const {
isLoggedIn, pushId, getGeckoDecisionTaskId, selectedRunnableJobs, isLoggedIn,
hideRunnableJobs, notify, pushId,
getGeckoDecisionTaskId,
selectedRunnableJobs,
hideRunnableJobs,
notify,
} = this.props; } = this.props;
if (!window.confirm( if (
'This will trigger all selected jobs. Click "OK" if you want to proceed.')) { !window.confirm(
'This will trigger all selected jobs. Click "OK" if you want to proceed.',
)
) {
return; return;
} }
if (isLoggedIn) { if (isLoggedIn) {
const builderNames = selectedRunnableJobs; const builderNames = selectedRunnableJobs;
getGeckoDecisionTaskId(pushId) getGeckoDecisionTaskId(pushId)
.then((decisionTaskID) => { .then(decisionTaskID => {
PushModel.triggerNewJobs(builderNames, decisionTaskID).then((result) => { PushModel.triggerNewJobs(builderNames, decisionTaskID)
notify(result, 'success'); .then(result => {
hideRunnableJobs(pushId); notify(result, 'success');
this.props.hideRunnableJobs(); hideRunnableJobs(pushId);
}).catch((e) => { this.props.hideRunnableJobs();
notify(formatTaskclusterError(e), 'danger', { sticky: true }); })
}); .catch(e => {
}).catch((e) => { notify(formatTaskclusterError(e), 'danger', { sticky: true });
});
})
.catch(e => {
notify(formatTaskclusterError(e), 'danger', { sticky: true }); notify(formatTaskclusterError(e), 'danger', { sticky: true });
}); });
} else { } else {
@ -116,7 +131,11 @@ class PushHeader extends React.PureComponent {
cancelAllJobs() { cancelAllJobs() {
const { notify, repoName } = this.props; 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; const { push, isLoggedIn, getGeckoDecisionTaskId } = this.props;
if (!isLoggedIn) return; if (!isLoggedIn) return;
@ -127,8 +146,13 @@ class PushHeader extends React.PureComponent {
pinAllShownJobs() { pinAllShownJobs() {
const { const {
selectedJob, setSelectedJob, pinJobs, expandAllPushGroups, getAllShownJobs, selectedJob,
notify, pushId, setSelectedJob,
pinJobs,
expandAllPushGroups,
getAllShownJobs,
notify,
pushId,
} = this.props; } = this.props;
const shownJobs = getAllShownJobs(pushId); const shownJobs = getAllShownJobs(pushId);
@ -145,13 +169,24 @@ class PushHeader extends React.PureComponent {
} }
render() { render() {
const { repoName, isLoggedIn, pushId, jobCounts, author, const {
revision, runnableVisible, watchState, repoName,
showRunnableJobs, hideRunnableJobs, cycleWatchState, isLoggedIn,
notificationSupported, selectedRunnableJobs } = this.props; pushId,
const cancelJobsTitle = isLoggedIn ? jobCounts,
'Cancel all jobs' : author,
'Must be logged in to cancel jobs'; 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 linkParams = this.getLinkParams();
const revisionPushFilterUrl = getJobsUrl({ ...linkParams, revision }); const revisionPushFilterUrl = getJobsUrl({ ...linkParams, revision });
const authorPushFilterUrl = getJobsUrl({ ...linkParams, author }); const authorPushFilterUrl = getJobsUrl({ ...linkParams, author });
@ -168,11 +203,12 @@ class PushHeader extends React.PureComponent {
<span className="push-left"> <span className="push-left">
<span className="push-title-left"> <span className="push-title-left">
<span> <span>
<a <a href={revisionPushFilterUrl} title="View only this push">
href={revisionPushFilterUrl} {this.pushDateStr}{' '}
title="View only this push" <span className="fa fa-external-link icon-superscript" />
>{this.pushDateStr} <span className="fa fa-external-link icon-superscript" /> </a>{' '}
</a> - </span> -{' '}
</span>
<Author author={author} url={authorPushFilterUrl} /> <Author author={author} url={authorPushFilterUrl} />
</span> </span>
</span> </span>
@ -183,49 +219,56 @@ class PushHeader extends React.PureComponent {
completed={jobCounts.completed} completed={jobCounts.completed}
/> />
<span className="push-buttons"> <span className="push-buttons">
{jobCounts.pending + jobCounts.running > 0 && {jobCounts.pending + jobCounts.running > 0 && (
<button <button
className="btn btn-sm btn-push watch-commit-btn" className="btn btn-sm btn-push watch-commit-btn"
disabled={!notificationSupported} 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} data-watch-state={watchState}
onClick={() => cycleWatchState()} onClick={() => cycleWatchState()}
>{watchStateLabel}</button>} >
{watchStateLabel}
</button>
)}
<a <a
className="btn btn-sm btn-push test-view-btn" className="btn btn-sm btn-push test-view-btn"
href={`/testview.html?repo=${repoName}&revision=${revision}`} href={`/testview.html?repo=${repoName}&revision=${revision}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="View details on failed test results for this push" title="View details on failed test results for this push"
>View Tests</a> >
{isLoggedIn && View Tests
</a>
{isLoggedIn && (
<button <button
className="btn btn-sm btn-push cancel-all-jobs-btn" className="btn btn-sm btn-push cancel-all-jobs-btn"
title={cancelJobsTitle} title={cancelJobsTitle}
onClick={this.cancelAllJobs} onClick={this.cancelAllJobs}
> >
<span <span className="fa fa-times-circle cancel-job-icon dim-quarter" />
className="fa fa-times-circle cancel-job-icon dim-quarter"
/>
</button> </button>
} )}
<button <button
className="btn btn-sm btn-push pin-all-jobs-btn" className="btn btn-sm btn-push pin-all-jobs-btn"
title="Pin all available jobs in this push" title="Pin all available jobs in this push"
aria-label="Pin all available jobs in this push" aria-label="Pin all available jobs in this push"
onClick={this.pinAllShownJobs} onClick={this.pinAllShownJobs}
> >
<span <span className="fa fa-thumb-tack" />
className="fa fa-thumb-tack"
/>
</button> </button>
{!!selectedRunnableJobs.length && runnableVisible && {!!selectedRunnableJobs.length && runnableVisible && (
<button <button
className="btn btn-sm btn-push trigger-new-jobs-btn" className="btn btn-sm btn-push trigger-new-jobs-btn"
title="Trigger new jobs" title="Trigger new jobs"
onClick={this.triggerNewJobs} onClick={this.triggerNewJobs}
>Trigger New Jobs</button> >
} Trigger New Jobs
</button>
)}
<PushActionMenu <PushActionMenu
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
runnableVisible={runnableVisible} runnableVisible={runnableVisible}
@ -273,4 +316,6 @@ PushHeader.defaultProps = {
watchState: 'none', 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 selectedJobId = parseInt(getUrlParam('selectedJob'), 10);
const filteredPlatforms = platforms.reduce((acc, platform) => { const filteredPlatforms = platforms.reduce((acc, platform) => {
const thisPlatform = { ...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.title = `${thisPlatform.name}${suffix}`;
thisPlatform.visible = true; thisPlatform.visible = true;
return [...acc, PushJobs.filterPlatform(thisPlatform, selectedJobId, push, filterModel, runnableVisible)]; return [
...acc,
PushJobs.filterPlatform(
thisPlatform,
selectedJobId,
push,
filterModel,
runnableVisible,
),
];
}, []); }, []);
return { filteredPlatforms }; return { filteredPlatforms };
} }
static filterPlatform(platform, selectedJobId, push, filterModel, runnableVisible) { static filterPlatform(
platform,
selectedJobId,
push,
filterModel,
runnableVisible,
) {
platform.visible = false; platform.visible = false;
platform.groups.forEach((group) => { platform.groups.forEach(group => {
group.visible = false; group.visible = false;
group.jobs.forEach((job) => { group.jobs.forEach(job => {
job.visible = filterModel.showJob(job) || job.id === selectedJobId; job.visible = filterModel.showJob(job) || job.id === selectedJobId;
if (job.state === 'runnable') { if (job.state === 'runnable') {
job.visible = job.visible && runnableVisible; job.visible = job.visible && runnableVisible;
@ -52,11 +70,7 @@ class PushJobs extends React.Component {
const { push, repoName } = this.props; const { push, repoName } = this.props;
this.pushId = push.id; this.pushId = push.id;
this.aggregateId = getPushTableId( this.aggregateId = getPushTableId(repoName, this.pushId, push.revision);
repoName,
this.pushId,
push.revision,
);
this.state = { this.state = {
filteredPlatforms: [], filteredPlatforms: [],
@ -75,14 +89,17 @@ class PushJobs extends React.Component {
if (jobInstance && jobInstance.props.job) { if (jobInstance && jobInstance.props.job) {
const { job } = jobInstance.props; const { job } = jobInstance.props;
if (ev.button === 1) { // Middle click if (ev.button === 1) {
// Middle click
this.handleLogViewerClick(job.id); this.handleLogViewerClick(job.id);
} else if (ev.metaKey || ev.ctrlKey) { // Pin job } else if (ev.metaKey || ev.ctrlKey) {
// Pin job
if (!selectedJob) { if (!selectedJob) {
this.selectJob(job, ev.target); this.selectJob(job, ev.target);
} }
togglePinJob(job); togglePinJob(job);
} else if (job && job.state === 'runnable') { // Toggle runnable } else if (job && job.state === 'runnable') {
// Toggle runnable
this.handleRunnableClick(jobInstance); this.handleRunnableClick(jobInstance);
} else { } else {
this.selectJob(job, ev.target); // Left click this.selectJob(job, ev.target); // Left click
@ -106,13 +123,11 @@ class PushJobs extends React.Component {
handleLogViewerClick(jobId) { handleLogViewerClick(jobId) {
// Open logviewer in a new window // Open logviewer in a new window
const { repoName } = this.props; const { repoName } = this.props;
JobModel.get( JobModel.get(repoName, jobId).then(data => {
repoName,
jobId,
).then((data) => {
if (data.logs.length > 0) { if (data.logs.length > 0) {
window.open(`${window.location.origin}/${ window.open(
getLogViewerUrl(jobId, repoName)}`); `${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 // This actually filters the platform in-place. So we just need to
// trigger a re-render by giving it a new ``filteredPlatforms`` object instance. // 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) { if (filteredPlatforms.length) {
this.setState({ filteredPlatforms: [...filteredPlatforms] }); this.setState({ filteredPlatforms: [...filteredPlatforms] });
} }
@ -139,28 +160,39 @@ class PushJobs extends React.Component {
render() { render() {
const filteredPlatforms = this.state.filteredPlatforms || []; const filteredPlatforms = this.state.filteredPlatforms || [];
const { const {
repoName, filterModel, pushGroupState, duplicateJobsVisible, repoName,
filterModel,
pushGroupState,
duplicateJobsVisible,
groupCountsExpanded, groupCountsExpanded,
} = this.props; } = this.props;
return ( return (
<table id={this.aggregateId} className="table-hover"> <table id={this.aggregateId} className="table-hover">
<tbody onMouseDown={this.onMouseDown}> <tbody onMouseDown={this.onMouseDown}>
{filteredPlatforms ? filteredPlatforms.map(platform => ( {filteredPlatforms ? (
platform.visible && filteredPlatforms.map(
<Platform platform =>
platform={platform} platform.visible && (
repoName={repoName} <Platform
key={platform.title} platform={platform}
filterModel={filterModel} repoName={repoName}
pushGroupState={pushGroupState} key={platform.title}
filterPlatformCb={this.filterPlatformCallback} filterModel={filterModel}
duplicateJobsVisible={duplicateJobsVisible} pushGroupState={pushGroupState}
groupCountsExpanded={groupCountsExpanded} filterPlatformCb={this.filterPlatformCallback}
/> duplicateJobsVisible={duplicateJobsVisible}
)) : <tr> groupCountsExpanded={groupCountsExpanded}
<td><span className="fa fa-spinner fa-pulse th-spinner" /></td> />
</tr>} ),
)
) : (
<tr>
<td>
<span className="fa fa-spinner fa-pulse th-spinner" />
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
); );

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

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

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

@ -10,60 +10,79 @@ function PushLoadErrors(props) {
const urlParams = getAllUrlParams(); const urlParams = getAllUrlParams();
urlParams.delete('revision'); urlParams.delete('revision');
const isRevision = revision => ( const isRevision = revision =>
revision && (revision.length === 12 || revision.length === 40) revision && (revision.length === 12 || revision.length === 40);
);
return ( return (
<div className="push-load-errors"> <div className="push-load-errors">
{!loadingPushes && isRevision(revision) && currentRepo && currentRepo.url && {!loadingPushes &&
<div className="push-body unknown-message-body"> isRevision(revision) &&
<span> currentRepo &&
{revision && currentRepo.url && (
<div> <div className="push-body unknown-message-body">
<p> <span>
Waiting for push with revision&nbsp; {revision && (
<a <div>
href={currentRepo.getPushLogHref(revision)} <p>
target="_blank" Waiting for push with revision&nbsp;
rel="noopener noreferrer" <a
title={`Open revision ${revision} on ${currentRepo.url}`} href={currentRepo.getPushLogHref(revision)}
>{revision}</a> target="_blank"
&nbsp; rel="noopener noreferrer"
<span className="fa fa-spinner fa-pulse th-spinner" /> title={`Open revision ${revision} on ${currentRepo.url}`}
</p> >
<p>If the push exists, it will appear in a few minutes once it has been processed.</p> {revision}
</div> </a>
} &nbsp;
</span> <span className="fa fa-spinner fa-pulse th-spinner" />
</div> </p>
} <p>
{!loadingPushes && !isRevision(revision) && currentRepo.url && 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"> <div className="push-body unknown-message-body">
This is an invalid or unknown revision. Please change it, or click 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> </div>
} )}
{!loadingPushes && !revision && currentRepo && currentRepo.url && {!loadingPushes && !revision && currentRepo && currentRepo.url && (
<div className="push-body unknown-message-body"> <div className="push-body unknown-message-body">
<span> <span>
<div><b>No pushes found.</b></div> <div>
<span>No commit information could be loaded for this repository. <b>No pushes found.</b>
More information about this repository can be found <a href={currentRepo.url}>here</a>.</span> </div>
</span> <span>
</div> No commit information could be loaded for this repository. More
} information about this repository can be found{' '}
{!loadingPushes && !currentRepo.url && <a href={currentRepo.url}>here</a>.
<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>
</span> </span>
</div> </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> </div>
); );
} }

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

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

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

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

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

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

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

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

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

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

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

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

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