Skip to content

Commit

Permalink
ui: Change global search to use fuzzy search API (#10412)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
backspace authored Apr 28, 2021
1 parent 647d5b2 commit 911b613
Show file tree
Hide file tree
Showing 20 changed files with 336 additions and 371 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions ui/app/components/global-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
Expand Down
210 changes: 102 additions & 108 deletions ui/app/components/global-search/control.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;

Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand All @@ -213,5 +205,7 @@ function resultsGroupLabel(type, renderedResults, allResults) {
countString = renderedResults.length;
}

return `${type} (${countString})`;
const truncationIndicator = truncated ? '+' : '';

return `${type} (${countString}${truncationIndicator})`;
}
54 changes: 0 additions & 54 deletions ui/app/components/global-search/match.js

This file was deleted.

4 changes: 4 additions & 0 deletions ui/app/components/lifecycle-chart-row.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
5 changes: 4 additions & 1 deletion ui/app/components/lifecycle-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
2 changes: 1 addition & 1 deletion ui/app/controllers/allocations/allocation/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default class IndexController extends Controller.extend(Sortable) {

@computed('[email protected]')
get services() {
return this.get('model.taskGroup.services').sortBy('name');
return (this.get('model.taskGroup.services') || []).sortBy('name');
}

onDismiss() {
Expand Down
Loading

0 comments on commit 911b613

Please sign in to comment.