From 2476432dfd6130e7c96a4977a3ff3b6756f3bfe3 Mon Sep 17 00:00:00 2001 From: Wes Kocher Date: Wed, 12 Dec 2018 20:21:58 -0800 Subject: [PATCH] Bug 1517700 - Add a fuzzy job finder to Treeherder --- package.json | 1 + ui/css/treeherder-fuzzyfinder.css | 12 + ui/helpers/sort.js | 26 +++ ui/job-view/index.jsx | 1 + ui/job-view/pushes/FuzzyJobFinder.jsx | 324 ++++++++++++++++++++++++++ ui/job-view/pushes/Push.jsx | 71 ++++++ ui/job-view/pushes/PushActionMenu.jsx | 17 ++ ui/job-view/pushes/PushHeader.jsx | 3 + yarn.lock | 5 + 9 files changed, 460 insertions(+) create mode 100644 ui/css/treeherder-fuzzyfinder.css create mode 100644 ui/helpers/sort.js create mode 100644 ui/job-view/pushes/FuzzyJobFinder.jsx diff --git a/package.json b/package.json index 0e32a39faa9..6ce8d2b2443 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "auth0-js": "9.10.1", "bootstrap": "4.3.1", "d3": "5.9.2", + "fuse.js": "3.4.4", "history": "4.9.0", "jquery": "3.3.1", "jquery.flot": "0.8.3", diff --git a/ui/css/treeherder-fuzzyfinder.css b/ui/css/treeherder-fuzzyfinder.css new file mode 100644 index 00000000000..632656f5ad1 --- /dev/null +++ b/ui/css/treeherder-fuzzyfinder.css @@ -0,0 +1,12 @@ +/* + * Fuzzy runnable job finder + */ + +.fuzzy-modal .modal-body #addJobsGroup option.selected::before { + content: '• '; + color: red; +} + +div.fuzzybuttons { + margin-bottom: 3px; +} diff --git a/ui/helpers/sort.js b/ui/helpers/sort.js new file mode 100644 index 00000000000..45d17cfa87c --- /dev/null +++ b/ui/helpers/sort.js @@ -0,0 +1,26 @@ +// eslint-disable-next-line import/prefer-default-export +export const sortAlphaNum = (a, b) => { + // Implement a better alphanumeric sort so that mochitest-10 + // is sorted after mochitest 9, not mochitest-1 + const reA = /[^a-zA-Z]/g; + const reN = /[^0-9]/g; + if (a.name) { + a = a.name; + b = b.name; + } + const aA = a.replace(reA, ''); + const bA = b.replace(reA, ''); + if (aA === bA) { + const aN = parseInt(a.replace(reN, ''), 10); + const bN = parseInt(b.replace(reN, ''), 10); + let rv; + if (aN === bN) { + rv = 0; + } else if (aN > bN) { + rv = 1; + } else { + rv = -1; + } + return rv; + } +}; diff --git a/ui/job-view/index.jsx b/ui/job-view/index.jsx index db0f168c420..09b8675710b 100644 --- a/ui/job-view/index.jsx +++ b/ui/job-view/index.jsx @@ -19,6 +19,7 @@ import '../css/treeherder-job-buttons.css'; import '../css/treeherder-resultsets.css'; import '../css/treeherder-pinboard.css'; import '../css/treeherder-bugfiler.css'; +import '../css/treeherder-fuzzyfinder.css'; import '../css/treeherder-loading-overlay.css'; import App from './App'; diff --git a/ui/job-view/pushes/FuzzyJobFinder.jsx b/ui/job-view/pushes/FuzzyJobFinder.jsx new file mode 100644 index 00000000000..4edf9234469 --- /dev/null +++ b/ui/job-view/pushes/FuzzyJobFinder.jsx @@ -0,0 +1,324 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Col, + FormGroup, + Label, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + InputGroup, + Input, +} from 'reactstrap'; +import Fuse from 'fuse.js'; + +import PushModel from '../../models/push'; +import { withNotifications } from '../../shared/context/Notifications'; +import { formatTaskclusterError } from '../../helpers/errorMessage'; +import { sortAlphaNum } from '../../helpers/sort'; + +class FuzzyJobFinder extends React.Component { + constructor(props) { + super(props); + + this.state = { + fuzzySearch: '', + fuzzyList: [], + selectedList: [], + removeDisabled: true, + addDisabled: true, + submitDisabled: false, + }; + } + + /* + * Filter the list of runnable jobs based on the value of this input. + * Only actually do the filtering when `enter` is pressed, as filtering 13K DOM elements is slow... + * If this input is empty when `enter` is pressed, reset back to the full list of runnable jobs. + */ + filterJobs = ev => { + // By default we show a trimmed down list of runnable jobs, but there's an option to show the full list + let currentList; + if (this.state.useFullList) { + currentList = this.props.jobList; + } else { + currentList = this.props.filteredJobList; + } + + if (ev && ev.type === 'keydown') { + if (ev.key === 'Enter') { + this.setState({ fuzzySearch: ev.target.value }, () => { + const options = { + // http://fusejs.io/ describes the options available + keys: ['name'], + threshold: 0.3, // This seems like a good threshold to remove most false matches, lower is stricter + matchAllTokens: true, + tokenize: true, + }; + + // Always search from the full (or full filtered) list of jobs + const fuse = new Fuse(currentList, options); + + this.setState(prevState => ({ + fuzzyList: prevState.fuzzySearch + ? fuse.search(prevState.fuzzySearch) + : currentList, + })); + }); + } + } else { + this.setState({ + fuzzyList: currentList, + }); + } + }; + + resetForm = () => { + this.setState({ + selectedList: [], + removeDisabled: true, + }); + }; + + addAllJobs = () => { + const selectedOptions = Array.from( + this.state.fuzzyList, + option => option.name, + ); + let { selectedList } = this.state; + + // When adding jobs, add only new, unique job names to avoid duplicates + selectedList = [...new Set([].concat(selectedList, selectedOptions))]; + this.setState({ selectedList }); + }; + + removeAllJobs = () => { + this.setState({ + selectedList: [], + removeDisabled: true, + }); + }; + + addJobs = evt => { + const { selectedList } = this.state; + const { addJobsSelected } = this.state; + + // When adding jobs, add only new, unique job names to avoid duplicates + const newSelectedList = [ + ...new Set([].concat(selectedList, addJobsSelected)), + ]; + this.setState({ selectedList: newSelectedList }); + evt.target.parentNode.previousElementSibling.selectedIndex = -1; + }; + + removeJobs = () => { + const { selectedList } = this.state; + const { removeJobsSelected } = this.state; + + const newSelectedList = selectedList.filter( + value => !removeJobsSelected.includes(value), + ); + + this.setState({ selectedList: newSelectedList }, () => { + this.setState({ + removeDisabled: true, + }); + }); + }; + + submitJobs = () => { + const { notify } = this.props; + if (this.state.selectedList.length > 0) { + notify('Submitting selected jobs...'); + this.setState({ + submitDisabled: true, + }); + PushModel.triggerNewJobs( + this.state.selectedList, + this.props.decisionTaskId, + ) + .then(result => { + notify(result, 'success'); + this.props.toggle(); + }) + .catch(e => { + notify(formatTaskclusterError(e), 'danger', { sticky: true }); + this.setState({ + submitDisabled: false, + }); + }); + } else { + notify('Please select at least one job from the list', 'danger'); + } + }; + + toggleFullList = evt => { + this.setState( + { + useFullList: evt.target.checked, + }, + () => { + // Fake enough state to simulate the enter key being pressed in the search box + this.filterJobs({ + type: 'keydown', + key: 'Enter', + target: { value: this.state.fuzzySearch }, + }); + }, + ); + }; + + updateAddButton = evt => { + const selectedOptions = Array.from( + evt.target.selectedOptions, + option => option.textContent, + ); + + this.setState({ + addDisabled: selectedOptions.length === 0, + addJobsSelected: selectedOptions, + }); + }; + + updateRemoveButton = evt => { + const selectedOptions = Array.from( + evt.target.selectedOptions, + option => option.textContent, + ); + this.setState({ + removeDisabled: selectedOptions.length === 0, + removeJobsSelected: selectedOptions, + }); + }; + + render() { + return ( +
+ + Add New Jobs (Fuzzy) + + + + + + + + + +

Runnable Jobs [{this.state.fuzzyList.length}]

+
+ +   + +
+ + + {this.state.fuzzyList.sort(sortAlphaNum).map(e => ( + + ))} + + +
+

Selected Jobs [{this.state.selectedList.length}]

+
+ +   + +
+ + + {this.state.selectedList.sort(sortAlphaNum).map(e => ( + + ))} + + +
+ + {' '} + + +
+
+ ); + } +} + +FuzzyJobFinder.propTypes = { + className: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + notify: PropTypes.func.isRequired, + toggle: PropTypes.func.isRequired, + decisionTaskId: PropTypes.string, + jobList: PropTypes.array, + filteredJobList: PropTypes.array, +}; + +FuzzyJobFinder.defaultProps = { + jobList: [], + filteredJobList: [], + decisionTaskId: '', +}; + +export default withNotifications(FuzzyJobFinder); diff --git a/ui/job-view/pushes/Push.jsx b/ui/job-view/pushes/Push.jsx index f68a3486ab4..9588d378942 100644 --- a/ui/job-view/pushes/Push.jsx +++ b/ui/job-view/pushes/Push.jsx @@ -16,6 +16,7 @@ import { withNotifications } from '../../shared/context/Notifications'; import { getRevisionTitle } from '../../helpers/revision'; import { getPercentComplete } from '../../helpers/display'; +import FuzzyJobFinder from './FuzzyJobFinder'; import { Revision } from './Revision'; import PushHeader from './PushHeader'; import PushJobs from './PushJobs'; @@ -32,6 +33,7 @@ class Push extends React.Component { const collapsedPushes = getUrlParam('collapsedPushes') || ''; this.state = { + fuzzyModal: false, platforms: [], jobList: [], runnableVisible: false, @@ -337,6 +339,54 @@ class Push extends React.Component { ); }; + showFuzzyJobs = async () => { + const { push, repoName, getGeckoDecisionTaskId, notify } = this.props; + const createRegExp = (str, opts) => + new RegExp(str.raw[0].replace(/\s/gm, ''), opts || ''); + const excludedJobNames = createRegExp` + (balrog|beetmover|bouncer-locations-firefox|build-docker-image|build-(.+)-nightly| + build-(.+)-upload-symbols|checksums|cron-bouncer|dmd|fetch|google-play-strings| + push-to-release|mar-signing|nightly|packages|release-bouncer|release-early| + release-final|release-secondary|release-snap|release-source|release-update| + repackage-l10n|repo-update|searchfox|sign-and-push|test-(.+)-devedition| + test-linux(32|64)(-asan|-pgo|-qr)?\/(opt|debug)-jittest|test-macosx64-ccov| + test-verify|test-windows10-64-ux|toolchain|upload-generated-sources)`; + + try { + const decisionTaskId = await getGeckoDecisionTaskId(push.id, repoName); + + notify('Fetching runnable jobs... This could take a while...'); + let fuzzyJobList = await RunnableJobModel.getList(repoName, { + decision_task_id: decisionTaskId, + }); + fuzzyJobList = [ + ...new Set( + fuzzyJobList.map(job => { + const obj = {}; + obj.name = job.job_type_name; + obj.symbol = job.job_type_symbol; + obj.groupsymbol = job.job_group_symbol; + return obj; + }), + ), + ].sort((a, b) => (a.name > b.name ? 1 : -1)); + const filteredFuzzyList = fuzzyJobList.filter( + job => job.name.search(excludedJobNames) < 0, + ); + this.setState({ + fuzzyJobList, + filteredFuzzyList, + decisionTaskId, + }); + this.toggleFuzzyModal(); + } catch (error) { + notify( + `Error fetching runnable jobs: Failed to fetch task ID (${error})`, + 'danger', + ); + } + }; + cycleWatchState = async () => { const { notify } = this.props; const { watched } = this.state; @@ -359,6 +409,13 @@ class Push extends React.Component { this.setState({ watched: next }); }; + toggleFuzzyModal = async () => { + this.setState(prevState => ({ + fuzzyModal: !prevState.fuzzyModal, + jobList: prevState.jobList, + })); + }; + render() { const { push, @@ -373,6 +430,10 @@ class Push extends React.Component { isOnlyRevision, } = this.props; const { + fuzzyJobList, + fuzzyModal, + filteredFuzzyList, + decisionTaskId, watched, runnableVisible, pushGroupState, @@ -395,6 +456,15 @@ class Push extends React.Component { this.container = ref; }} > + )} + {true && ( +
  • + Add new jobs (fuzzy) +
  • + )} {triggerMissingRepos.includes(repoName) && (
  • @@ -360,6 +362,7 @@ PushHeader.propTypes = { runnableVisible: PropTypes.bool.isRequired, showRunnableJobs: PropTypes.func.isRequired, hideRunnableJobs: PropTypes.func.isRequired, + showFuzzyJobs: PropTypes.func.isRequired, cycleWatchState: PropTypes.func.isRequired, isLoggedIn: PropTypes.bool.isRequired, setSelectedJob: PropTypes.func.isRequired, diff --git a/yarn.lock b/yarn.lock index e749820a938..e7c00203499 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4403,6 +4403,11 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +fuse.js@3.4.4: + version "3.4.4" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.4.tgz#f98f55fcb3b595cf6a3e629c5ffaf10982103e95" + integrity sha512-pyLQo/1oR5Ywf+a/tY8z4JygnIglmRxVUOiyFAbd11o9keUDpUJSMGRWJngcnkURj30kDHPmhoKY8ChJiz3EpQ== + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"