[ui] node eligibilty taken into consideration when clients list filtered to "ready" (#18607)

* node eligibilty taken into consideration when clients list filtered to 'ready'

* A working draft of complex positive querying

* tags and filter badge

* CompositeStatus -> Status

* Buttons within a Helios SegmentedGroup

* Convert the other dropdowns to helios on clients index

* A bunch of client index test fixes

* Remaining clients list acceptance tests for State facet modified
This commit is contained in:
Phil Renaud
2023-12-19 16:40:56 -05:00
committed by GitHub
parent e4e70b086a
commit e26c2e243c
8 changed files with 466 additions and 137 deletions

3
.changelog/18607.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: change the State filter on clients page to split out eligibility and drain status
```

View File

@@ -57,37 +57,31 @@ export default class ClientNodeRow extends Component.extend(
@watchRelationship('allocations') watch;
@computed('node.compositeStatus')
@computed('node.status')
get nodeStatusColor() {
let compositeStatus = this.get('node.compositeStatus');
if (compositeStatus === 'draining') {
return 'neutral';
} else if (compositeStatus === 'ineligible') {
let status = this.get('node.status');
if (status === 'disconnected') {
return 'warning';
} else if (compositeStatus === 'down') {
} else if (status === 'down') {
return 'critical';
} else if (compositeStatus === 'ready') {
} else if (status === 'ready') {
return 'success';
} else if (compositeStatus === 'initializing') {
} else if (status === 'initializing') {
return 'neutral';
} else {
return 'neutral';
}
}
@computed('node.compositeStatus')
@computed('node.status')
get nodeStatusIcon() {
let compositeStatus = this.get('node.compositeStatus');
if (compositeStatus === 'draining') {
return 'minus-circle';
} else if (compositeStatus === 'ineligible') {
let status = this.get('node.status');
if (status === 'disconnected') {
return 'skip';
} else if (compositeStatus === 'down') {
} else if (status === 'down') {
return 'x-circle';
} else if (compositeStatus === 'ready') {
} else if (status === 'ready') {
return 'check-circle';
} else if (compositeStatus === 'initializing') {
} else if (status === 'initializing') {
return 'entry-point';
} else {
return '';

View File

@@ -3,6 +3,8 @@
* SPDX-License-Identifier: BUSL-1.1
*/
// @ts-check
/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
import { alias, readOnly } from '@ember/object/computed';
import { inject as service } from '@ember/service';
@@ -45,9 +47,6 @@ export default class IndexController extends Controller.extend(
{
qpClass: 'class',
},
{
qpState: 'state',
},
{
qpDatacenter: 'dc',
},
@@ -62,6 +61,110 @@ export default class IndexController extends Controller.extend(
},
];
filterFunc = (node) => {
return node.isEligible;
};
clientFilterToggles = {
state: [
{
label: 'initializing',
qp: 'state_initializing',
default: true,
filter: (node) => node.status === 'initializing',
},
{
label: 'ready',
qp: 'state_ready',
default: true,
filter: (node) => node.status === 'ready',
},
{
label: 'down',
qp: 'state_down',
default: true,
filter: (node) => node.status === 'down',
},
{
label: 'disconnected',
qp: 'state_disconnected',
default: true,
filter: (node) => node.status === 'disconnected',
},
],
eligibility: [
{
label: 'eligible',
qp: 'eligibility_eligible',
default: true,
filter: (node) => node.isEligible,
},
{
label: 'ineligible',
qp: 'eligibility_ineligible',
default: true,
filter: (node) => !node.isEligible,
},
],
drainStatus: [
{
label: 'draining',
qp: 'drain_status_draining',
default: true,
filter: (node) => node.isDraining,
},
{
label: 'not draining',
qp: 'drain_status_not_draining',
default: true,
filter: (node) => !node.isDraining,
},
],
};
@computed(
'state_initializing',
'state_ready',
'state_down',
'state_disconnected',
'eligibility_eligible',
'eligibility_ineligible',
'drain_status_draining',
'drain_status_not_draining',
'allToggles.[]'
)
get activeToggles() {
return this.allToggles.filter((t) => this[t.qp]);
}
get allToggles() {
return Object.values(this.clientFilterToggles).reduce(
(acc, filters) => acc.concat(filters),
[]
);
}
// eslint-disable-next-line ember/classic-decorator-hooks
constructor() {
super(...arguments);
this.addDynamicQueryParams();
}
addDynamicQueryParams() {
this.clientFilterToggles.state.forEach((filter) => {
this.queryParams.push({ [filter.qp]: filter.qp });
this.set(filter.qp, filter.default);
});
this.clientFilterToggles.eligibility.forEach((filter) => {
this.queryParams.push({ [filter.qp]: filter.qp });
this.set(filter.qp, filter.default);
});
this.clientFilterToggles.drainStatus.forEach((filter) => {
this.queryParams.push({ [filter.qp]: filter.qp });
this.set(filter.qp, filter.default);
});
}
currentPage = 1;
@readOnly('userSettings.pageSize') pageSize;
@@ -74,14 +177,12 @@ export default class IndexController extends Controller.extend(
}
qpClass = '';
qpState = '';
qpDatacenter = '';
qpVersion = '';
qpVolume = '';
qpNodePool = '';
@selection('qpClass') selectionClass;
@selection('qpState') selectionState;
@selection('qpDatacenter') selectionDatacenter;
@selection('qpVersion') selectionVersion;
@selection('qpVolume') selectionVolume;
@@ -105,18 +206,6 @@ export default class IndexController extends Controller.extend(
return classes.sort().map((dc) => ({ key: dc, label: dc }));
}
@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('nodes.[]', 'selectionDatacenter')
get optionsDatacenter() {
const datacenters = Array.from(
@@ -195,35 +284,50 @@ export default class IndexController extends Controller.extend(
}
@computed(
'clientFilterToggles',
'drain_status_draining',
'drain_status_not_draining',
'eligibility_eligible',
'eligibility_ineligible',
'nodes.[]',
'selectionClass',
'selectionState',
'selectionDatacenter',
'selectionNodePool',
'selectionVersion',
'selectionVolume'
'selectionVolume',
'state_disconnected',
'state_down',
'state_initializing',
'state_ready'
)
get filteredNodes() {
const {
selectionClass: classes,
selectionState: states,
selectionDatacenter: datacenters,
selectionNodePool: nodePools,
selectionVersion: versions,
selectionVolume: volumes,
} = this;
const onlyIneligible = states.includes('ineligible');
const onlyDraining = states.includes('draining');
let nodes = this.nodes;
// states is a composite of node status and other node states
const statuses = states.without('ineligible').without('draining');
// new QP style filtering
for (let category in this.clientFilterToggles) {
nodes = nodes.filter((node) => {
let includeNode = false;
for (let filter of this.clientFilterToggles[category]) {
if (this[filter.qp] && filter.filter(node)) {
includeNode = true;
break;
}
}
return includeNode;
});
}
return this.nodes.filter((node) => {
return nodes.filter((node) => {
if (classes.length && !classes.includes(node.get('nodeClass')))
return false;
if (statuses.length && !statuses.includes(node.get('status')))
return false;
if (datacenters.length && !datacenters.includes(node.get('datacenter')))
return false;
if (versions.length && !versions.includes(node.get('version')))
@@ -237,9 +341,6 @@ export default class IndexController extends Controller.extend(
return false;
}
if (onlyIneligible && node.get('isEligible')) return false;
if (onlyDraining && !node.get('isDraining')) return false;
return true;
});
}
@@ -254,6 +355,16 @@ export default class IndexController extends Controller.extend(
this.set(queryParam, serialize(selection));
}
@action
handleFilterChange(queryParamValue, option, queryParamLabel) {
if (queryParamValue.includes(option)) {
queryParamValue.removeObject(option);
} else {
queryParamValue.addObject(option);
}
this.set(queryParamLabel, serialize(queryParamValue));
}
@action
gotoNode(node) {
this.transitionToRoute('clients.client', node);

View File

@@ -110,6 +110,12 @@
white-space: nowrap;
}
&.node-status-badges {
.hds-badge__text {
white-space: nowrap;
}
}
&.is-narrow {
padding: 1.25em 0 1.25em 0.5em;

View File

@@ -18,52 +18,190 @@
/>
{{/if}}
</div>
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
<MultiSelectDropdown
data-test-state-facet
@label="State"
@options={{this.optionsState}}
@selection={{this.selectionState}}
@onSelect={{action this.setFacetQueryParam "qpState"}}
<Hds::SegmentedGroup as |S|>
<S.Dropdown data-test-state-facet as |dd|>
<dd.ToggleButton
@text="State"
@color="secondary"
@badge={{if (eq this.activeToggles.length this.allToggles.length) false this.activeToggles.length}}
/>
<MultiSelectDropdown
data-test-node-pool-facet
@label="Node Pool"
@options={{this.optionsNodePool}}
@selection={{this.selectionNodePool}}
@onSelect={{action this.setFacetQueryParam "qpNodePool"}}
<dd.Title @text="Status" />
{{#each this.clientFilterToggles.state as |option|}}
<dd.Checkbox
{{on "change" (toggle option.qp this)}}
@value={{option.label}}
@count={{get (filter (action option.filter) this.nodes) "length"}}
checked={{get this option.qp}}
data-test-dropdown-option={{option.label}}
>
{{capitalize option.label}}
</dd.Checkbox>
{{/each}}
<dd.Separator />
<dd.Title @text="Eligibility" />
{{#each this.clientFilterToggles.eligibility as |option|}}
<dd.Checkbox
{{on "change" (toggle option.qp this)}}
@value={{option.label}}
@count={{get (filter (action option.filter) this.nodes) "length"}}
checked={{get this option.qp}}
data-test-dropdown-option={{option.label}}
>
{{capitalize option.label}}
</dd.Checkbox>
{{/each}}
<dd.Separator />
<dd.Title @text="Drain Status" />
{{#each this.clientFilterToggles.drainStatus as |option|}}
<dd.Checkbox
{{on "change" (toggle option.qp this)}}
@value={{option.label}}
@count={{get (filter (action option.filter) this.nodes) "length"}}
checked={{get this option.qp}}
data-test-dropdown-option={{option.label}}
>
{{capitalize option.label}}
</dd.Checkbox>
{{/each}}
</S.Dropdown>
<S.Dropdown data-test-node-pool-facet as |dd|>
<dd.ToggleButton
@text="Node Pool"
@color="secondary"
@badge={{or this.selectionNodePool.length false}}
/>
<MultiSelectDropdown
data-test-class-facet
@label="Class"
@options={{this.optionsClass}}
@selection={{this.selectionClass}}
@onSelect={{action this.setFacetQueryParam "qpClass"}}
{{#each this.optionsNodePool key="label" as |option|}}
<dd.Checkbox
{{on "change" (action this.handleFilterChange
this.selectionNodePool
option.label
"qpNodePool"
)}}
@value={{option.label}}
checked={{includes option.label this.selectionNodePool}}
@count={{get (filter-by 'nodePool' option.label this.nodes) "length"}}
data-test-dropdown-option={{option.label}}
>
{{option.label}}
</dd.Checkbox>
{{else}}
<dd.Generic data-test-dropdown-empty>
No Node Pool filters
</dd.Generic>
{{/each}}
</S.Dropdown>
<S.Dropdown data-test-class-facet as |dd|>
<dd.ToggleButton
@text="Class"
@color="secondary"
@badge={{or this.selectionClass.length false}}
/>
<MultiSelectDropdown
data-test-datacenter-facet
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenter}}
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}}
{{#each this.optionsClass key="label" as |option|}}
<dd.Checkbox
{{on "change" (action this.handleFilterChange
this.selectionClass
option.label
"qpClass"
)}}
@value={{option.label}}
checked={{includes option.label this.selectionClass}}
@count={{get (filter-by 'nodeClass' option.label this.nodes) "length"}}
data-test-dropdown-option={{option.label}}
>
{{option.label}}
</dd.Checkbox>
{{else}}
<dd.Generic data-test-dropdown-empty>
No Class filters
</dd.Generic>
{{/each}}
</S.Dropdown>
<S.Dropdown data-test-datacenter-facet as |dd|>
<dd.ToggleButton
@text="Datacenter"
@color="secondary"
@badge={{or this.selectionDatacenter.length false}}
/>
<MultiSelectDropdown
data-test-version-facet
@label="Version"
@options={{this.optionsVersion}}
@selection={{this.selectionVersion}}
@onSelect={{action this.setFacetQueryParam "qpVersion"}}
{{#each this.optionsDatacenter key="label" as |option|}}
<dd.Checkbox
{{on "change" (action this.handleFilterChange
this.selectionDatacenter
option.label
"qpDatacenter"
)}}
@value={{option.label}}
checked={{includes option.label this.selectionDatacenter}}
@count={{get (filter-by 'datacenter' option.label this.nodes) "length"}}
data-test-dropdown-option={{option.label}}
>
{{option.label}}
</dd.Checkbox>
{{else}}
<dd.Generic data-test-dropdown-empty>
No Datacenter filters
</dd.Generic>
{{/each}}
</S.Dropdown>
<S.Dropdown data-test-version-facet as |dd|>
<dd.ToggleButton
@text="Version"
@color="secondary"
@badge={{or this.selectionVersion.length false}}
/>
<MultiSelectDropdown
data-test-volume-facet
@label="Volume"
@options={{this.optionsVolume}}
@selection={{this.selectionVolume}}
@onSelect={{action this.setFacetQueryParam "qpVolume"}}
{{#each this.optionsVersion key="label" as |option|}}
<dd.Checkbox
{{on "change" (action this.handleFilterChange
this.selectionVersion
option.label
"qpVersion"
)}}
@value={{option.label}}
checked={{includes option.label this.selectionVersion}}
@count={{get (filter-by 'version' option.label this.nodes) "length"}}
data-test-dropdown-option={{option.label}}
>
{{option.label}}
</dd.Checkbox>
{{else}}
<dd.Generic data-test-dropdown-empty>
No Version filters
</dd.Generic>
{{/each}}
</S.Dropdown>
<S.Dropdown data-test-volume-facet as |dd|>
<dd.ToggleButton
@text="Volume"
@color="secondary"
@badge={{or this.selectionVolume.length false}}
/>
</div>
</div>
{{#each this.optionsVolume key="label" as |option|}}
<dd.Checkbox
{{on "change" (action this.handleFilterChange
this.selectionVolume
option.label
"qpVolume"
)}}
@value={{option.label}}
checked={{includes option.label this.selectionVolume}}
@count={{get (filter-by 'volume' option.label this.nodes) "length"}}
data-test-dropdown-option={{option.label}}
>
{{option.label}}
</dd.Checkbox>
{{else}}
<dd.Generic data-test-dropdown-empty>
No Volume filters
</dd.Generic>
{{/each}}
</S.Dropdown>
</Hds::SegmentedGroup>
</div>
{{#if this.sortedNodes}}
<ListPagination
@@ -86,7 +224,7 @@
@class="is-200px is-truncatable"
@prop="name"
>Name</t.sort-by>
<t.sort-by @prop="compositeStatus">State</t.sort-by>
<t.sort-by @prop="status">State</t.sort-by>
<th class="is-200px is-truncatable">Address</th>
<t.sort-by @prop="nodePool">Node Pool</t.sort-by>
<t.sort-by @prop="datacenter">Datacenter</t.sort-by>

View File

@@ -12,15 +12,41 @@
</td>
<td data-test-client-id><LinkTo @route="clients.client" @model={{this.node.id}} class="is-primary">{{this.node.shortId}}</LinkTo></td>
<td data-test-client-name class="is-200px is-truncatable" title="{{this.node.name}}">{{this.node.name}}</td>
<td data-test-client-composite-status>
<span class="tooltip" aria-label="{{this.node.status}} / {{if this.node.isDraining "draining" "not draining"}} / {{if this.node.isEligible "eligible" "not eligible"}}">
<td class="node-status-badges" data-test-client-composite-status>
<Hds::Badge
@text={{capitalize this.node.status}}
@icon={{this.nodeStatusIcon}}
@color={{this.nodeStatusColor}}
@size="small"
/>
{{#if this.node.isEligible}}
<Hds::Badge
@text={{capitalize this.node.compositeStatus}}
@icon={{this.nodeStatusIcon}}
@color={{this.nodeStatusColor}}
@size="large"
@text="Eligible"
@color="neutral"
@size="small"
/>
</span>
{{else}}
<Hds::Badge
@text="Ineligible"
@color="neutral"
@size="small"
/>
{{/if}}
{{#if this.node.isDraining}}
<Hds::Badge
@text="Draining"
@color="neutral"
@size="small"
/>
{{else}}
<Hds::Badge
@text="Not Draining"
@color="neutral"
@size="small"
/>
{{/if}}
</td>
<td data-test-client-address class="is-200px is-truncatable">{{this.node.httpAddr}}</td>
<td data-test-client-node-pool title="{{this.node.nodePool}}">

View File

@@ -77,7 +77,7 @@ module('Acceptance | clients list', function (hooks) {
assert.equal(nodeRow.nodePool, node.nodePool, 'Node Pool');
assert.equal(
nodeRow.compositeStatus.text,
'Draining',
'Ready Ineligible Draining',
'Combined status, draining, and eligbility'
);
assert.equal(nodeRow.address, node.httpAddr);
@@ -111,13 +111,13 @@ module('Acceptance | clients list', function (hooks) {
assert.equal(nodeRow.id, node.id.split('-')[0], 'ID');
assert.equal(
nodeRow.compositeStatus.text,
'Ready',
'Ready Eligible Not Draining',
'Combined status, draining, and eligbility'
);
assert.equal(nodeRow.allocations, running.length, '# Allocations');
});
test('client status, draining, and eligibility are collapsed into one column that stays sorted', async function (assert) {
test('client status, draining, and eligibility are combined into one column that stays sorted on status', async function (assert) {
server.createList('agent', 1);
server.create('node', {
@@ -151,47 +151,71 @@ module('Acceptance | clients list', function (hooks) {
drain: false,
});
server.create('node', 'draining', {
schedulingEligibility: 'eligible',
modifyIndex: 0,
status: 'ready',
});
await ClientsList.visit();
ClientsList.nodes[0].compositeStatus.as((readyClient) => {
assert.equal(readyClient.text, 'Ready');
console.log('readyClient', readyClient.text);
assert.equal(readyClient.tooltip, 'ready / not draining / eligible');
});
assert.equal(ClientsList.nodes[1].compositeStatus.text, 'Initializing');
assert.equal(ClientsList.nodes[2].compositeStatus.text, 'Down');
assert.equal(
ClientsList.nodes[0].compositeStatus.text,
'Ready Eligible Not Draining'
);
assert.equal(
ClientsList.nodes[1].compositeStatus.text,
'Initializing Eligible Not Draining'
);
assert.equal(
ClientsList.nodes[2].compositeStatus.text,
'Down',
'down takes priority over ineligible'
'Down Eligible Not Draining'
);
assert.equal(
ClientsList.nodes[3].compositeStatus.text,
'Down Ineligible Not Draining'
);
assert.equal(
ClientsList.nodes[4].compositeStatus.text,
'Ready Ineligible Not Draining'
);
assert.equal(
ClientsList.nodes[5].compositeStatus.text,
'Ready Eligible Draining'
);
assert.equal(ClientsList.nodes[4].compositeStatus.text, 'Ineligible');
assert.equal(ClientsList.nodes[5].compositeStatus.text, 'Draining');
await ClientsList.sortBy('compositeStatus');
await ClientsList.sortBy('status');
assert.deepEqual(
ClientsList.nodes.map((n) => n.compositeStatus.text),
['Ready', 'Initializing', 'Ineligible', 'Draining', 'Down', 'Down']
[
'Ready Eligible Draining',
'Ready Ineligible Not Draining',
'Ready Eligible Not Draining',
'Initializing Eligible Not Draining',
'Down Ineligible Not Draining',
'Down Eligible Not Draining',
],
'Nodes are sorted only by status, and otherwise default to modifyIndex'
);
// Simulate a client state change arriving through polling
let readyClient = this.owner
let discoClient = this.owner
.lookup('service:store')
.peekAll('node')
.findBy('modifyIndex', 5);
readyClient.set('schedulingEligibility', 'ineligible');
discoClient.set('status', 'disconnected');
await settled();
assert.deepEqual(
ClientsList.nodes.map((n) => n.compositeStatus.text),
['Initializing', 'Ineligible', 'Ineligible', 'Draining', 'Down', 'Down']
[
'Ready Eligible Draining',
'Ready Ineligible Not Draining',
'Disconnected Eligible Not Draining',
'Initializing Eligible Not Draining',
'Down Ineligible Not Draining',
'Down Eligible Not Draining',
]
);
});
@@ -275,12 +299,14 @@ module('Acceptance | clients list', function (hooks) {
facet: ClientsList.facets.state,
paramName: 'state',
expectedOptions: [
'Initializing',
'Ready',
'Down',
'Ineligible',
'Draining',
'Disconnected',
'initializing',
'ready',
'down',
'disconnected',
'eligible',
'ineligible',
'draining',
'not draining',
],
async beforeEach() {
server.create('agent');
@@ -312,7 +338,7 @@ module('Acceptance | clients list', function (hooks) {
)
return false;
return selection.includes(node.status);
return !selection.includes(node.status);
},
});
@@ -400,9 +426,8 @@ module('Acceptance | clients list', function (hooks) {
server.createList('node', 2, { status: 'ready' });
await ClientsList.visit();
await ClientsList.facets.state.toggle();
await ClientsList.facets.state.options.objectAt(0).toggle();
await ClientsList.facets.state.options.objectAt(1).toggle();
assert.ok(ClientsList.isEmpty, 'There is an empty message');
assert.equal(
ClientsList.empty.headline,
@@ -441,7 +466,9 @@ module('Acceptance | clients list', function (hooks) {
}
assert.deepEqual(
facet.options.map((option) => option.label.trim()),
facet.options.map((option) => {
return option.key.trim();
}),
expectation,
'Options for facet are as expected'
);
@@ -511,11 +538,20 @@ module('Acceptance | clients list', function (hooks) {
await option2.toggle();
selection.push(option2.key);
// State is different from the other facets, in that it is an "exclusive" filter, whete others are "inclusive".
// Because of this, it doesn't pass "state" as a stringified-array query param; rather, exclusion is indicated
// for each option with a "${optionName}=false" query param.
const stateString = `/clients?${selection
.map((option) => `state_${option}=false`)
.join('&')}`;
const nonStateString = `/clients?${paramName}=${encodeURIComponent(
JSON.stringify(selection)
)}`;
assert.equal(
currentURL(),
`/clients?${paramName}=${encodeURIComponent(
JSON.stringify(selection)
)}`,
paramName === 'state' ? stateString : nonStateString,
'URL has the correct query param key and value'
);
});

View File

@@ -16,9 +16,24 @@ import {
visitable,
} from 'ember-cli-page-object';
import { multiFacet } from 'nomad-ui/tests/pages/components/facet';
import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
const heliosFacet = (scope) => ({
scope,
toggle: clickable('button'),
options: collection(
'.hds-menu-primitive__content .hds-dropdown__content .hds-dropdown__list .hds-dropdown-list-item--variant-checkbox',
{
toggle: clickable('label'),
count: text('label .hds-dropdown-list-item__count'),
key: attribute(
'data-test-dropdown-option',
'[data-test-dropdown-option]'
),
}
),
});
export default create({
pageSize: 25,
@@ -77,11 +92,11 @@ export default create({
},
facets: {
nodePools: multiFacet('[data-test-node-pool-facet]'),
class: multiFacet('[data-test-class-facet]'),
state: multiFacet('[data-test-state-facet]'),
datacenter: multiFacet('[data-test-datacenter-facet]'),
version: multiFacet('[data-test-version-facet]'),
volume: multiFacet('[data-test-volume-facet]'),
nodePools: heliosFacet('[data-test-node-pool-facet]'),
class: heliosFacet('[data-test-class-facet]'),
state: heliosFacet('[data-test-state-facet]'),
datacenter: heliosFacet('[data-test-datacenter-facet]'),
version: heliosFacet('[data-test-version-facet]'),
volume: heliosFacet('[data-test-volume-facet]'),
},
});