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 @@
+
+
+ {{#if this.model.nodes.length}} + + {{/if}} +
+
+
+ + + + +
+
+
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]'),