Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Change global search to use fuzzy search API #10412

Merged
merged 26 commits into from
Apr 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
885a592
Remove running client-side search tests for now
backspace Apr 20, 2021
601d1a6
Add preliminary fuzzy search
backspace Apr 20, 2021
555ba2d
Add placeholder for ignoring one-character search
backspace Apr 20, 2021
4501e44
Add navigation to jobs
backspace Apr 20, 2021
7d5fdf2
Add handling for node results
backspace Apr 20, 2021
5beff76
Add minimum search string length filter
backspace Apr 20, 2021
8ce3cb3
Add handling for task group results
backspace Apr 20, 2021
3dadcad
Add client-side truncation
backspace Apr 20, 2021
cf488fa
Add indicator for server-side truncation
backspace Apr 20, 2021
2865773
Add fuzzy search feature detection
backspace Apr 21, 2021
4a6b1a1
Remove superseded search code
backspace Apr 21, 2021
c79952e
Add handling for CSI plugins
backspace Apr 21, 2021
85725f9
Add filtering of detection POST from tests
backspace Apr 21, 2021
fd9d049
Remove unused variable
backspace Apr 21, 2021
9f92a22
Add test ignoring for feature detection request
backspace Apr 21, 2021
e3198a6
Add handling for allocation search results
backspace Apr 22, 2021
abe2794
Add guards against unrealistic random data
backspace Apr 22, 2021
a39c3b9
Fix wasteful reruns of application beforeModel
backspace Apr 22, 2021
3c5fe93
Change text in feature detection query
backspace Apr 22, 2021
d39f5a2
Restore region-setting in application route
backspace Apr 22, 2021
c1a7b48
Add intermediate representation
backspace Apr 22, 2021
d6fc44b
Update various variable names
backspace Apr 22, 2021
72d6200
Change context for feature detection query
backspace Apr 22, 2021
b370b94
Merge branch 'main' into f-ui/fuzzy-search
backspace Apr 28, 2021
54346bc
Add changelog entry
backspace Apr 28, 2021
d2aa291
Add missing import
backspace Apr 28, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really clean way to handle the API response shape.


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 '';
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unrelated. How'd you end up here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t totally understand but I was experiencing test failures when navigating to some search results and had to add a couple of null-handlings, I suspect it has something to do with the way the Mirage models are being constructed. With the time constraint I sadly just added the guards without digging further to grasp the underlying issue 😞

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's reasonable!

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('model.taskGroup.services.@each.name')
get services() {
return this.get('model.taskGroup.services').sortBy('name');
return (this.get('model.taskGroup.services') || []).sortBy('name');
}

onDismiss() {
Expand Down
Loading