From 911b6136931c2c19a6dee31a5863a8485661cf12 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 28 Apr 2021 13:31:05 -0500 Subject: [PATCH] ui: Change global search to use fuzzy search API (#10412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This updates the UI to use the new fuzzy search API. It’s a drop-in replacement so the / shortcut to jump to search is preserved, and results can be cycled through and chosen via arrow keys and the enter key. It doesn’t use everything returned by the API: * deployments and evaluations: these match by id, doesn’t seem like people would know those or benefit from quick navigation to them * namespaces: doesn’t seem useful as they currently function * scaling policies * tasks: the response doesn’t include an allocation id, which means they can’t be navigated to in the UI without an additional query * CSI volumes: aren’t actually returned by the API Since there’s no API to check the server configuration and know whether the feature has been disabled, this adds another query in route:application#beforeModel that acts as feature detection: if the attempt to query fails (500), the global search field is hidden. Upon having added another query on load, I realised that beforeModel was being triggered any time service:router#transitionTo was being called, which happens upon navigating to a search result, for instance, because of refreshModel being present on the region query parameter. This PR adds a check for transition.queryParamsOnly and skips rerunning the onload queries (token permissions check, license check, fuzzy search feature detection). Implementation notes: * there are changes to unrelated tests to ignore the on-load feature detection query * some lifecycle-related guards against undefined were required to address failures when navigating to an allocation * the minimum search length of 2 characters is hard-coded as there’s currently no way to determine min_term_length in the UI --- CHANGELOG.md | 1 + ui/app/components/global-header.js | 1 + ui/app/components/global-search/control.js | 210 +++++++++--------- ui/app/components/global-search/match.js | 54 ----- ui/app/components/lifecycle-chart-row.js | 4 + ui/app/components/lifecycle-chart.js | 5 +- .../allocations/allocation/index.js | 2 +- ui/app/routes/application.js | 62 ++++-- ui/app/services/data-caches.js | 33 --- ui/app/services/system.js | 18 ++ .../components/global-search-dropdown.scss | 4 - ui/app/templates/components/global-header.hbs | 8 +- .../components/global-search/control.hbs | 3 +- .../components/global-search/match.hbs | 5 - ui/mirage/config.js | 68 ++++++ ui/tests/acceptance/allocation-detail-test.js | 2 +- ui/tests/acceptance/client-detail-test.js | 30 +-- ui/tests/acceptance/regions-test.js | 2 + ui/tests/acceptance/search-test.js | 166 ++++++-------- ui/tests/pages/layout.js | 29 +-- 20 files changed, 336 insertions(+), 371 deletions(-) delete mode 100644 ui/app/components/global-search/match.js delete mode 100644 ui/app/services/data-caches.js delete mode 100644 ui/app/templates/components/global-search/match.hbs diff --git a/CHANGELOG.md b/CHANGELOG.md index d32a654c750f..707e57a7540f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ IMPROVEMENTS: * networking: Added support for interpolating host network names with node attributes. [[GH-10196](https://github.com/hashicorp/nomad/issues/10196)] * nomad/structs: Removed deprecated Node.Drain field, added API extensions to restore it [[GH-10202](https://github.com/hashicorp/nomad/issues/10202)] * ui: Added a job reversion button [[GH-10336](https://github.com/hashicorp/nomad/pull/10336)] + * ui: Updated global search to use fuzzy search API [[GH-10412](https://github.com/hashicorp/nomad/pull/10412)] BUG FIXES: * core (Enterprise): Update licensing library to v0.0.11 to include race condition fix. [[GH-10253](https://github.com/hashicorp/nomad/issues/10253)] diff --git a/ui/app/components/global-header.js b/ui/app/components/global-header.js index a1b13deb1a25..2e89fcb55181 100644 --- a/ui/app/components/global-header.js +++ b/ui/app/components/global-header.js @@ -5,6 +5,7 @@ import { inject as service } from '@ember/service'; @classic export default class GlobalHeader extends Component { @service config; + @service system; 'data-test-global-header' = true; onHamburgerClick() {} diff --git a/ui/app/components/global-search/control.js b/ui/app/components/global-search/control.js index a72f2ef1c31e..84bc9a2bfff3 100644 --- a/ui/app/components/global-search/control.js +++ b/ui/app/components/global-search/control.js @@ -1,39 +1,23 @@ import Component from '@ember/component'; import { classNames } from '@ember-decorators/component'; import { task } from 'ember-concurrency'; -import EmberObject, { action, computed, set } from '@ember/object'; -import { alias } from '@ember/object/computed'; +import { action, set } from '@ember/object'; import { inject as service } from '@ember/service'; import { debounce, run } from '@ember/runloop'; -import Searchable from 'nomad-ui/mixins/searchable'; -import classic from 'ember-classic-decorator'; const SLASH_KEY = 191; const MAXIMUM_RESULTS = 10; @classNames('global-search-container') export default class GlobalSearchControl extends Component { - @service dataCaches; @service router; - @service store; + @service token; searchString = null; constructor() { super(...arguments); this['data-test-search-parent'] = true; - - this.jobSearch = JobSearch.create({ - dataSource: this, - }); - - this.nodeNameSearch = NodeNameSearch.create({ - dataSource: this, - }); - - this.nodeIdSearch = NodeIdSearch.create({ - dataSource: this, - }); } keyDownHandler(e) { @@ -57,34 +41,85 @@ export default class GlobalSearchControl extends Component { } @task(function*(string) { - try { - set(this, 'searchString', string); - - const jobs = yield this.dataCaches.fetch('job'); - const nodes = yield this.dataCaches.fetch('node'); - - set(this, 'jobs', jobs.toArray()); - set(this, 'nodes', nodes.toArray()); - - const jobResults = this.jobSearch.listSearched.slice(0, MAXIMUM_RESULTS); - - const mergedNodeListSearched = this.nodeIdSearch.listSearched.concat(this.nodeNameSearch.listSearched).uniq(); - const nodeResults = mergedNodeListSearched.slice(0, MAXIMUM_RESULTS); - - return [ - { - groupName: resultsGroupLabel('Jobs', jobResults, this.jobSearch.listSearched), - options: jobResults, - }, - { - groupName: resultsGroupLabel('Clients', nodeResults, mergedNodeListSearched), - options: nodeResults, - }, - ]; - } catch (e) { - // eslint-disable-next-line - console.log('exception searching', e); - } + const searchResponse = yield this.token.authorizedRequest('/v1/search/fuzzy', { + method: 'POST', + body: JSON.stringify({ + Text: string, + Context: 'all', + }), + }); + + const results = yield searchResponse.json(); + + const allJobResults = results.Matches.jobs || []; + const allNodeResults = results.Matches.nodes || []; + const allAllocationResults = results.Matches.allocs || []; + const allTaskGroupResults = results.Matches.groups || []; + const allCSIPluginResults = results.Matches.plugins || []; + + const jobResults = allJobResults.slice(0, MAXIMUM_RESULTS).map(({ ID: name, Scope: [ namespace, id ]}) => ({ + type: 'job', + id, + namespace, + label: name, + })); + + const nodeResults = allNodeResults.slice(0, MAXIMUM_RESULTS).map(({ ID: name, Scope: [ id ]}) => ({ + type: 'node', + id, + label: name, + })); + + const allocationResults = allAllocationResults.slice(0, MAXIMUM_RESULTS).map(({ ID: name, Scope: [ , id ]}) => ({ + type: 'allocation', + id, + label: name, + })); + + const taskGroupResults = allTaskGroupResults.slice(0, MAXIMUM_RESULTS).map(({ ID: id, Scope: [ namespace, jobId ]}) => ({ + type: 'task-group', + id, + namespace, + jobId, + label: id, + })); + + const csiPluginResults = allCSIPluginResults.slice(0, MAXIMUM_RESULTS).map(({ ID: id }) => ({ + type: 'plugin', + id, + label: id, + })); + + const { + jobs: jobsTruncated, + nodes: nodesTruncated, + allocs: allocationsTruncated, + groups: taskGroupsTruncated, + plugins: csiPluginsTruncated, + } = results.Truncations; + + return [ + { + groupName: resultsGroupLabel('Jobs', jobResults, allJobResults, jobsTruncated), + options: jobResults, + }, + { + groupName: resultsGroupLabel('Clients', nodeResults, allNodeResults, nodesTruncated), + options: nodeResults, + }, + { + groupName: resultsGroupLabel('Allocations', allocationResults, allAllocationResults, allocationsTruncated), + options: allocationResults, + }, + { + groupName: resultsGroupLabel('Task Groups', taskGroupResults, allTaskGroupResults, taskGroupsTruncated), + options: taskGroupResults, + }, + { + groupName: resultsGroupLabel('CSI Plugins', csiPluginResults, allCSIPluginResults, csiPluginsTruncated), + options: csiPluginResults, + } + ]; }) search; @@ -96,15 +131,26 @@ export default class GlobalSearchControl extends Component { } @action - selectOption(model) { - const itemModelName = model.constructor.modelName; + ensureMinimumLength(string) { + return string.length > 1; + } - if (itemModelName === 'job') { - this.router.transitionTo('jobs.job', model.plainId, { - queryParams: { namespace: model.get('namespace.name') }, + @action + selectOption(model) { + if (model.type === 'job') { + this.router.transitionTo('jobs.job', model.id, { + queryParams: { namespace: model.namespace }, }); - } else if (itemModelName === 'node') { + } else if (model.type === 'node') { this.router.transitionTo('clients.client', model.id); + } else if (model.type === 'task-group') { + this.router.transitionTo('jobs.job.task-group', model.jobId, model.id, { + queryParams: { namespace: model.namespace }, + }); + } else if (model.type === 'plugin') { + this.router.transitionTo('csi.plugins.plugin', model.id); + } else if (model.type === 'allocation') { + this.router.transitionTo('allocations.allocation', model.id); } } @@ -150,61 +196,7 @@ export default class GlobalSearchControl extends Component { } } -@classic -class JobSearch extends EmberObject.extend(Searchable) { - @computed - get searchProps() { - return ['id', 'name']; - } - - @computed - get fuzzySearchProps() { - return ['name']; - } - - @alias('dataSource.jobs') listToSearch; - @alias('dataSource.searchString') searchTerm; - - fuzzySearchEnabled = true; - includeFuzzySearchMatches = true; -} -@classic -class NodeNameSearch extends EmberObject.extend(Searchable) { - @computed - get searchProps() { - return ['name']; - } - - @computed - get fuzzySearchProps() { - return ['name']; - } - - @alias('dataSource.nodes') listToSearch; - @alias('dataSource.searchString') searchTerm; - - fuzzySearchEnabled = true; - includeFuzzySearchMatches = true; -} - -@classic -class NodeIdSearch extends EmberObject.extend(Searchable) { - @computed - get regexSearchProps() { - return ['id']; - } - - @alias('dataSource.nodes') listToSearch; - @computed('dataSource.searchString') - get searchTerm() { - return `^${this.get('dataSource.searchString')}`; - } - - exactMatchEnabled = false; - regexEnabled = true; -} - -function resultsGroupLabel(type, renderedResults, allResults) { +function resultsGroupLabel(type, renderedResults, allResults, truncated) { let countString; if (renderedResults.length < allResults.length) { @@ -213,5 +205,7 @@ function resultsGroupLabel(type, renderedResults, allResults) { countString = renderedResults.length; } - return `${type} (${countString})`; + const truncationIndicator = truncated ? '+' : ''; + + return `${type} (${countString}${truncationIndicator})`; } diff --git a/ui/app/components/global-search/match.js b/ui/app/components/global-search/match.js deleted file mode 100644 index 4360adeeda39..000000000000 --- a/ui/app/components/global-search/match.js +++ /dev/null @@ -1,54 +0,0 @@ -import Component from '@ember/component'; -import { tagName } from '@ember-decorators/component'; -import { computed, get } from '@ember/object'; -import { alias } from '@ember/object/computed'; - -@tagName('') -export default class GlobalSearchMatch extends Component { - @alias('match.fuzzySearchMatches.firstObject') firstMatch; - - @computed('match.name') - get label() { - return get(this, 'match.name') || ''; - } - - @computed('firstMatch.indices.[]', 'label.length') - get substrings() { - const indices = get(this, 'firstMatch.indices'); - const labelLength = this.label.length; - - if (indices) { - return indices.reduce((substrings, [startIndex, endIndex], indicesIndex) => { - if (indicesIndex === 0 && startIndex > 0) { - substrings.push({ - isHighlighted: false, - string: this.label.substring(0, startIndex) - }); - } - - substrings.push({ - isHighlighted: true, - string: this.label.substring(startIndex, endIndex + 1) - }); - - let endIndexOfNextUnhighlightedSubstring; - - if (indicesIndex === indices.length - 1) { - endIndexOfNextUnhighlightedSubstring = labelLength; - } else { - const nextIndices = indices[indicesIndex + 1]; - endIndexOfNextUnhighlightedSubstring = nextIndices[0]; - } - - substrings.push({ - isHighlighted: false, - string: this.label.substring(endIndex + 1, endIndexOfNextUnhighlightedSubstring) - }); - - return substrings; - }, []); - } else { - return null; - } - } -} diff --git a/ui/app/components/lifecycle-chart-row.js b/ui/app/components/lifecycle-chart-row.js index 5a2422cb9d29..25203683b329 100644 --- a/ui/app/components/lifecycle-chart-row.js +++ b/ui/app/components/lifecycle-chart-row.js @@ -26,6 +26,10 @@ export default class LifecycleChartRow extends Component { @computed('task.lifecycleName') get lifecycleLabel() { + if (!this.task) { + return ''; + } + const name = this.task.lifecycleName; if (name.includes('sidecar')) { diff --git a/ui/app/components/lifecycle-chart.js b/ui/app/components/lifecycle-chart.js index a58e6b83999b..07a938109d90 100644 --- a/ui/app/components/lifecycle-chart.js +++ b/ui/app/components/lifecycle-chart.js @@ -24,7 +24,10 @@ export default class LifecycleChart extends Component { tasksOrStates.forEach(taskOrState => { const task = taskOrState.task || taskOrState; - lifecycles[`${task.lifecycleName}s`].push(taskOrState); + + if (task.lifecycleName) { + lifecycles[`${task.lifecycleName}s`].push(taskOrState); + } }); const phases = []; diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index 04771dbb1977..4068de300d5a 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -47,7 +47,7 @@ export default class IndexController extends Controller.extend(Sortable) { @computed('model.taskGroup.services.@each.name') get services() { - return this.get('model.taskGroup.services').sortBy('name'); + return (this.get('model.taskGroup.services') || []).sortBy('name'); } onDismiss() { diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index a52f185c7fcb..e0162369eff2 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -26,35 +26,49 @@ export default class ApplicationRoute extends Route { } async beforeModel(transition) { - let exchangeOneTimeToken; + let promises; - if (transition.to.queryParams.ott) { - exchangeOneTimeToken = this.get('token').exchangeOneTimeToken(transition.to.queryParams.ott); + // service:router#transitionTo can cause this to rerun because of refreshModel on + // the region query parameter, this skips rerunning the detection/loading queries. + if (transition.queryParamsOnly) { + promises = Promise.resolve(true); } else { - exchangeOneTimeToken = Promise.resolve(true); - } - try { - await exchangeOneTimeToken; - } catch (e) { - this.controllerFor('application').set('error', e); + let exchangeOneTimeToken; + + if (transition.to.queryParams.ott) { + exchangeOneTimeToken = this.get('token').exchangeOneTimeToken(transition.to.queryParams.ott); + } else { + exchangeOneTimeToken = Promise.resolve(true); + } + + try { + await exchangeOneTimeToken; + } catch (e) { + this.controllerFor('application').set('error', e); + } + + const fetchSelfTokenAndPolicies = this.get('token.fetchSelfTokenAndPolicies') + .perform() + .catch(); + + const fetchLicense = this.get('system.fetchLicense') + .perform() + .catch(); + + const checkFuzzySearchPresence = this.get('system.checkFuzzySearchPresence') + .perform() + .catch(); + + promises = await RSVP.all([ + this.get('system.regions'), + this.get('system.defaultRegion'), + fetchLicense, + fetchSelfTokenAndPolicies, + checkFuzzySearchPresence, + ]); } - const fetchSelfTokenAndPolicies = this.get('token.fetchSelfTokenAndPolicies') - .perform() - .catch(); - - const fetchLicense = this.get('system.fetchLicense') - .perform() - .catch(); - - const promises = await RSVP.all([ - this.get('system.regions'), - this.get('system.defaultRegion'), - fetchLicense, - fetchSelfTokenAndPolicies, - ]); - if (!this.get('system.shouldShowRegions')) return promises; const queryParam = transition.to.queryParams.region; diff --git a/ui/app/services/data-caches.js b/ui/app/services/data-caches.js deleted file mode 100644 index 7617c61c5830..000000000000 --- a/ui/app/services/data-caches.js +++ /dev/null @@ -1,33 +0,0 @@ -import Service, { inject as service } from '@ember/service'; - -export const COLLECTION_CACHE_DURATION = 60000; // one minute - -export default class DataCachesService extends Service { - @service router; - @service store; - @service system; - - collectionLastFetched = {}; - - async fetch(modelName) { - const modelNameToRoute = { - job: 'jobs', - node: 'clients', - }; - - const route = modelNameToRoute[modelName]; - const lastFetched = this.collectionLastFetched[modelName]; - const now = Date.now(); - - if (this.router.isActive(route)) { - // TODO Incorrect because it’s constantly being fetched by watchers, shouldn’t be marked as last fetched only on search - this.collectionLastFetched[modelName] = now; - return this.store.peekAll(modelName); - } else if (lastFetched && now - lastFetched < COLLECTION_CACHE_DURATION) { - return this.store.peekAll(modelName); - } else { - this.collectionLastFetched[modelName] = now; - return this.store.findAll(modelName); - } - } -} diff --git a/ui/app/services/system.js b/ui/app/services/system.js index 0f526b571754..106987c6a396 100644 --- a/ui/app/services/system.js +++ b/ui/app/services/system.js @@ -157,7 +157,25 @@ export default class SystemService extends Service { }) fetchLicense; + @task(function*() { + try { + const request = yield this.token.authorizedRequest('/v1/search/fuzzy', { + method: 'POST', + body: JSON.stringify({ + Text: 'feature-detection-query', + Context: 'namespaces', + }), + }); + + return request.ok; + } catch (e) { + return false; + } + }) + checkFuzzySearchPresence; + @alias('fetchLicense.lastSuccessful.value') license; + @alias('checkFuzzySearchPresence.last.value') fuzzySearchEnabled; @computed('license.License.Features.[]') get features() { diff --git a/ui/app/styles/components/global-search-dropdown.scss b/ui/app/styles/components/global-search-dropdown.scss index 27e5030020d1..0a4d52972e2e 100644 --- a/ui/app/styles/components/global-search-dropdown.scss +++ b/ui/app/styles/components/global-search-dropdown.scss @@ -42,10 +42,6 @@ background: transparentize($blue, 0.8); color: $blue; } - - .highlighted { - font-weight: $weight-semibold; - } } } diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs index 4beb56b39aa8..2fd688ee6c14 100644 --- a/ui/app/templates/components/global-header.hbs +++ b/ui/app/templates/components/global-header.hbs @@ -7,9 +7,11 @@ - {{#unless (media "isMobile")}} - - {{/unless}} + {{#if this.system.fuzzySearchEnabled}} + {{#unless (media "isMobile")}} + + {{/unless}} + {{/if}}