Adds searching and filtering for nodes on topology view (#14913)
* Adds searching and filtering for nodes on topology view * Lintfix and changelog * Acceptance tests for topology search and filter * Search terms also apply to class and dc on topo page * Initialize queryparam values so as to not break history state
This commit is contained in:
parent
bb00f3d713
commit
54eeb6ebe8
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui: adds searching and filtering to the topology page
|
||||
```
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
|
||||
import Controller from '@ember/controller';
|
||||
import { computed, action } from '@ember/object';
|
||||
import { alias } from '@ember/object/computed';
|
||||
|
@ -5,6 +6,13 @@ import { inject as service } from '@ember/service';
|
|||
import { tracked } from '@glimmer/tracking';
|
||||
import classic from 'ember-classic-decorator';
|
||||
import { reduceBytes, reduceHertz } from 'nomad-ui/utils/units';
|
||||
import {
|
||||
serialize,
|
||||
deserializedQueryParam as selection,
|
||||
} from 'nomad-ui/utils/qp-serialize';
|
||||
import { scheduleOnce } from '@ember/runloop';
|
||||
import intersection from 'lodash.intersection';
|
||||
import Searchable from 'nomad-ui/mixins/searchable';
|
||||
|
||||
const sumAggregator = (sum, value) => sum + (value || 0);
|
||||
const formatter = new Intl.NumberFormat(window.navigator.locale || 'en', {
|
||||
|
@ -12,12 +20,139 @@ const formatter = new Intl.NumberFormat(window.navigator.locale || 'en', {
|
|||
});
|
||||
|
||||
@classic
|
||||
export default class TopologyControllers extends Controller {
|
||||
export default class TopologyControllers extends Controller.extend(Searchable) {
|
||||
@service userSettings;
|
||||
|
||||
queryParams = [
|
||||
{
|
||||
searchTerm: 'search',
|
||||
},
|
||||
{
|
||||
qpState: 'status',
|
||||
},
|
||||
{
|
||||
qpVersion: 'version',
|
||||
},
|
||||
{
|
||||
qpClass: 'class',
|
||||
},
|
||||
{
|
||||
qpDatacenter: 'dc',
|
||||
},
|
||||
];
|
||||
|
||||
@tracked searchTerm = '';
|
||||
qpState = '';
|
||||
qpVersion = '';
|
||||
qpClass = '';
|
||||
qpDatacenter = '';
|
||||
|
||||
setFacetQueryParam(queryParam, selection) {
|
||||
this.set(queryParam, serialize(selection));
|
||||
}
|
||||
|
||||
@selection('qpState') selectionState;
|
||||
@selection('qpClass') selectionClass;
|
||||
@selection('qpDatacenter') selectionDatacenter;
|
||||
@selection('qpVersion') selectionVersion;
|
||||
|
||||
@computed
|
||||
get optionsState() {
|
||||
return [
|
||||
{ key: 'initializing', label: 'Initializing' },
|
||||
{ key: 'ready', label: 'Ready' },
|
||||
{ key: 'down', label: 'Down' },
|
||||
{ key: 'ineligible', label: 'Ineligible' },
|
||||
{ key: 'draining', label: 'Draining' },
|
||||
{ key: 'disconnected', label: 'Disconnected' },
|
||||
];
|
||||
}
|
||||
|
||||
@computed('model.nodes', 'nodes.[]', 'selectionClass')
|
||||
get optionsClass() {
|
||||
const classes = Array.from(new Set(this.model.nodes.mapBy('nodeClass')))
|
||||
.compact()
|
||||
.without('');
|
||||
|
||||
// Remove any invalid node classes from the query param/selection
|
||||
scheduleOnce('actions', () => {
|
||||
// eslint-disable-next-line ember/no-side-effects
|
||||
this.set(
|
||||
'qpClass',
|
||||
serialize(intersection(classes, this.selectionClass))
|
||||
);
|
||||
});
|
||||
|
||||
return classes.sort().map((dc) => ({ key: dc, label: dc }));
|
||||
}
|
||||
|
||||
@computed('model.nodes', 'nodes.[]', 'selectionDatacenter')
|
||||
get optionsDatacenter() {
|
||||
const datacenters = Array.from(
|
||||
new Set(this.model.nodes.mapBy('datacenter'))
|
||||
).compact();
|
||||
|
||||
// Remove any invalid datacenters from the query param/selection
|
||||
scheduleOnce('actions', () => {
|
||||
// eslint-disable-next-line ember/no-side-effects
|
||||
this.set(
|
||||
'qpDatacenter',
|
||||
serialize(intersection(datacenters, this.selectionDatacenter))
|
||||
);
|
||||
});
|
||||
|
||||
return datacenters.sort().map((dc) => ({ key: dc, label: dc }));
|
||||
}
|
||||
|
||||
@computed('model.nodes', 'nodes.[]', 'selectionVersion')
|
||||
get optionsVersion() {
|
||||
const versions = Array.from(
|
||||
new Set(this.model.nodes.mapBy('version'))
|
||||
).compact();
|
||||
|
||||
// Remove any invalid versions from the query param/selection
|
||||
scheduleOnce('actions', () => {
|
||||
// eslint-disable-next-line ember/no-side-effects
|
||||
this.set(
|
||||
'qpVersion',
|
||||
serialize(intersection(versions, this.selectionVersion))
|
||||
);
|
||||
});
|
||||
|
||||
return versions.sort().map((v) => ({ key: v, label: v }));
|
||||
}
|
||||
|
||||
@alias('userSettings.showTopoVizPollingNotice') showPollingNotice;
|
||||
|
||||
@tracked filteredNodes = null;
|
||||
@tracked pre09Nodes = null;
|
||||
|
||||
get filteredNodes() {
|
||||
const { nodes } = this.model;
|
||||
return nodes.filter((node) => {
|
||||
const {
|
||||
searchTerm,
|
||||
selectionState,
|
||||
selectionVersion,
|
||||
selectionDatacenter,
|
||||
selectionClass,
|
||||
} = this;
|
||||
return (
|
||||
(selectionState.length ? selectionState.includes(node.status) : true) &&
|
||||
(selectionVersion.length
|
||||
? selectionVersion.includes(node.version)
|
||||
: true) &&
|
||||
(selectionDatacenter.length
|
||||
? selectionDatacenter.includes(node.datacenter)
|
||||
: true) &&
|
||||
(selectionClass.length
|
||||
? selectionClass.includes(node.nodeClass)
|
||||
: true) &&
|
||||
(node.name.includes(searchTerm) ||
|
||||
node.datacenter.includes(searchTerm) ||
|
||||
node.nodeClass.includes(searchTerm))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@computed('model.nodes.@each.datacenter')
|
||||
get datacenters() {
|
||||
|
@ -156,9 +291,9 @@ export default class TopologyControllers extends Controller {
|
|||
|
||||
@action
|
||||
handleTopoVizDataError(errors) {
|
||||
const filteredNodesError = errors.findBy('type', 'filtered-nodes');
|
||||
if (filteredNodesError) {
|
||||
this.filteredNodes = filteredNodesError.context;
|
||||
const pre09NodesError = errors.findBy('type', 'filtered-nodes');
|
||||
if (pre09NodesError) {
|
||||
this.pre09Nodes = pre09NodesError.context;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
data-test-topo-viz
|
||||
class="topo-viz {{if this.isSingleColumn "is-single-column"}}"
|
||||
{{did-insert this.buildTopology}}
|
||||
{{did-update this.buildTopology @nodes}}
|
||||
{{did-insert this.captureElement}}
|
||||
{{window-resize this.determineViewportColumns}}>
|
||||
<FlexMasonry
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
<strong>{{@node.node.name}}</strong>
|
||||
<span class="bumper-left">{{this.count}} Allocs</span>
|
||||
<span class="bumper-left is-faded">{{format-scheduled-bytes @node.memory start="MiB"}}, {{format-scheduled-hertz @node.cpu}}</span>
|
||||
<span class="bumper-left is-faded">{{@node.node.status}}</span>
|
||||
<span class="bumper-left is-faded">{{@node.node.version}}</span>
|
||||
</p>
|
||||
{{/unless}}
|
||||
<svg
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{{#if this.isForbidden}}
|
||||
<ForbiddenMessage />
|
||||
{{else}}
|
||||
{{#if this.filteredNodes}}
|
||||
{{#if this.pre09Nodes}}
|
||||
<div class="notification is-warning">
|
||||
<div data-test-filtered-nodes-warning class="columns">
|
||||
<div class="column">
|
||||
|
@ -13,10 +13,10 @@
|
|||
Some Clients Were Filtered
|
||||
</h3>
|
||||
<p data-test-message>
|
||||
{{this.filteredNodes.length}}
|
||||
{{if (eq this.filteredNodes.length 1) "client was" "clients were"}}
|
||||
{{this.pre09Nodes.length}}
|
||||
{{if (eq this.pre09Nodes.length 1) "client was" "clients were"}}
|
||||
filtered from the topology visualization. This is most likely due to the
|
||||
{{pluralize "client" this.filteredNodes.length}}
|
||||
{{pluralize "client" this.pre09Nodes.length}}
|
||||
running a version of Nomad
|
||||
</p>
|
||||
</div>
|
||||
|
@ -24,7 +24,7 @@
|
|||
<button
|
||||
data-test-dismiss
|
||||
class="button is-warning"
|
||||
onclick={{action (mut this.filteredNodes) null}}
|
||||
onclick={{action (mut this.pre09Nodes) null}}
|
||||
type="button"
|
||||
>
|
||||
Okay
|
||||
|
@ -462,12 +462,60 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
{{#if this.model.nodes.length}}
|
||||
<SearchBox
|
||||
@inputClass="node-search"
|
||||
@searchTerm={{mut this.searchTerm}}
|
||||
@placeholder="Search clients..."
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="toolbar-item is-right-aligned is-mobile-full-width">
|
||||
<div class="button-bar">
|
||||
<MultiSelectDropdown
|
||||
data-test-datacenter-facet
|
||||
@label="Datacenter"
|
||||
@options={{this.optionsDatacenter}}
|
||||
@selection={{this.selectionDatacenter}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
data-test-class-facet
|
||||
@label="Class"
|
||||
@options={{this.optionsClass}}
|
||||
@selection={{this.selectionClass}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpClass"}}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
data-test-state-facet
|
||||
@label="State"
|
||||
@options={{this.optionsState}}
|
||||
@selection={{this.selectionState}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpState"}}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
data-test-version-facet
|
||||
@label="Version"
|
||||
@options={{this.optionsVersion}}
|
||||
@selection={{this.selectionVersion}}
|
||||
@onSelect={{action this.setFacetQueryParam "qpVersion"}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TopoViz
|
||||
@nodes={{this.model.nodes}}
|
||||
@nodes={{this.filteredNodes}}
|
||||
@allocations={{this.model.allocations}}
|
||||
@onAllocationSelect={{action this.setAllocation}}
|
||||
@onNodeSelect={{action this.setNode}}
|
||||
@onDataError={{action this.handleTopoVizDataError}}
|
||||
@filters={{hash
|
||||
search=this.searchTerm
|
||||
clientState=this.selectionState
|
||||
clientVersion=this.selectionVersion
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable qunit/require-expect */
|
||||
import { get } from '@ember/object';
|
||||
import { currentURL } from '@ember/test-helpers';
|
||||
import { currentURL, typeIn, click } from '@ember/test-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
|
@ -311,4 +311,28 @@ module('Acceptance | topology', function (hooks) {
|
|||
assert.ok(Topology.filteredNodesWarning.isPresent);
|
||||
assert.ok(Topology.filteredNodesWarning.message.startsWith('1'));
|
||||
});
|
||||
|
||||
test('Filtering and Querying reduces the number of nodes shown', async function (assert) {
|
||||
server.createList('node', 10);
|
||||
server.createList('node', 2, {
|
||||
nodeClass: 'foo-bar-baz',
|
||||
});
|
||||
server.createList('allocation', 5);
|
||||
|
||||
await Topology.visit();
|
||||
assert.dom('[data-test-topo-viz-node]').exists({ count: 12 });
|
||||
|
||||
await typeIn('input.node-search', server.schema.nodes.first().name);
|
||||
assert.dom('[data-test-topo-viz-node]').exists({ count: 1 });
|
||||
await typeIn('input.node-search', server.schema.nodes.first().name);
|
||||
assert.dom('[data-test-topo-viz-node]').doesNotExist();
|
||||
await click('[title="Clear search"]');
|
||||
assert.dom('[data-test-topo-viz-node]').exists({ count: 12 });
|
||||
|
||||
await Topology.facets.class.toggle();
|
||||
await Topology.facets.class.options
|
||||
.findOneBy('label', 'foo-bar-baz')
|
||||
.toggle();
|
||||
assert.dom('[data-test-topo-viz-node]').exists({ count: 2 });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
visitable,
|
||||
} from 'ember-cli-page-object';
|
||||
|
||||
import { multiFacet } from 'nomad-ui/tests/pages/components/facet';
|
||||
import TopoViz from 'nomad-ui/tests/pages/components/topo-viz';
|
||||
import notification from 'nomad-ui/tests/pages/components/notification';
|
||||
|
||||
|
@ -19,6 +20,13 @@ export default create({
|
|||
|
||||
viz: TopoViz('[data-test-topo-viz]'),
|
||||
|
||||
facets: {
|
||||
datacenter: multiFacet('[data-test-datacenter-facet]'),
|
||||
class: multiFacet('[data-test-class-facet]'),
|
||||
state: multiFacet('[data-test-state-facet]'),
|
||||
version: multiFacet('[data-test-version-facet]'),
|
||||
},
|
||||
|
||||
clusterInfoPanel: {
|
||||
scope: '[data-test-info-panel]',
|
||||
nodeCount: text('[data-test-node-count]'),
|
||||
|
|
Loading…
Reference in New Issue