From aa5b83bf170b2f4f549a0a1c6b2edc9bdfa45497 Mon Sep 17 00:00:00 2001
From: Phil Renaud
Date: Wed, 19 Oct 2022 15:00:35 -0400
Subject: [PATCH] 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
---
.changelog/14913.txt | 3 +
ui/app/controllers/topology.js | 145 +++++++++++++++++-
ui/app/templates/components/topo-viz.hbs | 1 +
ui/app/templates/components/topo-viz/node.hbs | 2 +
ui/app/templates/topology.hbs | 60 +++++++-
ui/tests/acceptance/topology-test.js | 26 +++-
ui/tests/pages/topology.js | 8 +
7 files changed, 233 insertions(+), 12 deletions(-)
create mode 100644 .changelog/14913.txt
diff --git a/.changelog/14913.txt b/.changelog/14913.txt
new file mode 100644
index 000000000..511b00a8c
--- /dev/null
+++ b/.changelog/14913.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: adds searching and filtering to the topology page
+```
diff --git a/ui/app/controllers/topology.js b/ui/app/controllers/topology.js
index c3866eb5e..901dcaa39 100644
--- a/ui/app/controllers/topology.js
+++ b/ui/app/controllers/topology.js
@@ -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;
}
}
}
diff --git a/ui/app/templates/components/topo-viz.hbs b/ui/app/templates/components/topo-viz.hbs
index baf84c9a7..f457afc1f 100644
--- a/ui/app/templates/components/topo-viz.hbs
+++ b/ui/app/templates/components/topo-viz.hbs
@@ -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}}>
{{@node.node.name}}
{{this.count}} Allocs
{{format-scheduled-bytes @node.memory start="MiB"}}, {{format-scheduled-hertz @node.cpu}}
+ {{@node.node.status}}
+ {{@node.node.version}}
{{/unless}}
{{else}}
- {{#if this.filteredNodes}}
+ {{#if this.pre09Nodes}}
@@ -13,10 +13,10 @@
Some Clients Were Filtered
- {{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
@@ -24,7 +24,7 @@
Okay
@@ -462,12 +462,60 @@
diff --git a/ui/tests/acceptance/topology-test.js b/ui/tests/acceptance/topology-test.js
index 2d664becc..87fbca010 100644
--- a/ui/tests/acceptance/topology-test.js
+++ b/ui/tests/acceptance/topology-test.js
@@ -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 });
+ });
});
diff --git a/ui/tests/pages/topology.js b/ui/tests/pages/topology.js
index c62dfeef0..6442a4d25 100644
--- a/ui/tests/pages/topology.js
+++ b/ui/tests/pages/topology.js
@@ -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]'),