From eb10217bb5fdb5a2f1495f89f1f91a01ad4954f2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:05:52 +0100 Subject: [PATCH 1/3] Add api support for filtering environments by raw configuration --- .../seeders/20220412122043-environments.js | 3 + lib/domain/dtos/GetAllEnvironmentsDto.js | 10 +++ .../environment/GetAllEnvironmentsUseCase.js | 48 +++++++++++ test/api/environments.test.js | 78 ++++++++++++++++++ .../GetAllEnvironmentsUseCase.test.js | 81 +++++++++++++++++++ 5 files changed, 220 insertions(+) diff --git a/lib/database/seeders/20220412122043-environments.js b/lib/database/seeders/20220412122043-environments.js index aa0a46419b..d1d1147ff5 100644 --- a/lib/database/seeders/20220412122043-environments.js +++ b/lib/database/seeders/20220412122043-environments.js @@ -51,6 +51,9 @@ module.exports = { id: 'TDI59So3d', created_at: new Date('2019-08-09 16:05:00'), updated_at: new Date('2019-08-09 19:00:00'), + raw_configuration: JSON.stringify({ + hosts: '["fake-flp3"]', + }), }, // Error { diff --git a/lib/domain/dtos/GetAllEnvironmentsDto.js b/lib/domain/dtos/GetAllEnvironmentsDto.js index e1ebbd60f2..3dc49c46e4 100644 --- a/lib/domain/dtos/GetAllEnvironmentsDto.js +++ b/lib/domain/dtos/GetAllEnvironmentsDto.js @@ -29,6 +29,16 @@ const FilterDto = Joi.object({ 'string.base': 'Run numbers must be comma-separated numbers or ranges (e.g. 12,15-18)', }).optional(), created: FromToFilterDto, + rawConfiguration: Joi.object({ + // Surrounding quotes are allowed for better UX, but are not part of the key + key: Joi.string().trim().max(256).replace(/^"(.*)"$/, '$1').optional(), + + /* + * Strip surrounding quotes and unescape JSON escapes (e.g. [\"flp1\"] copied from the raw JSON becomes ["flp1"]), the use case + * then compares it to the unescaped value of the key, or to both forms when searching the whole configuration + */ + value: Joi.string().trim().max(1000).replace(/^"(.*)"$/, '$1').replace(/\\(["\\])/g, '$1').optional(), + }).or('key', 'value').optional(), }); const QueryDto = Joi.object({ diff --git a/lib/usecases/environment/GetAllEnvironmentsUseCase.js b/lib/usecases/environment/GetAllEnvironmentsUseCase.js index 4f7133c753..16fe8a4cdb 100644 --- a/lib/usecases/environment/GetAllEnvironmentsUseCase.js +++ b/lib/usecases/environment/GetAllEnvironmentsUseCase.js @@ -25,6 +25,14 @@ const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js'); const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); +/** + * Escape the LIKE wildcards of a value, so that it is matched literally + * + * @param {string} value the value to escape + * @return {string} the escaped value + */ +const escapeLikeWildcards = (value) => value.replace(/[\\%_]/g, '\\$&'); + /** * Subquery to select the latest history item for each environment. * It orders the history items by updatedAt DESC and selects to have the latests @@ -82,6 +90,7 @@ class GetAllEnvironmentsUseCase { statusHistory, runNumbers: runNumbersExpression, created, + rawConfiguration, } = filter; if (created) { @@ -161,6 +170,45 @@ class GetAllEnvironmentsUseCase { }); } } + + if (rawConfiguration) { + const { key: jsonKey, value: searchValue } = rawConfiguration; + + if (jsonKey) { + // Quote the key so that any character (e.g. "-", spaces, "*") is treated literally in the JSON path + const jsonPath = `$."${jsonKey.replace(/[\\"]/g, '\\$&')}"`; + + if (searchValue) { + // JSON_UNQUOTE gives the stored string without its JSON escaping + queryBuilder.literalWhere( + 'JSON_UNQUOTE(JSON_EXTRACT(raw_configuration, :jsonPath)) LIKE :searchPattern', + { jsonPath, searchPattern: `%${escapeLikeWildcards(searchValue)}%` }, + ); + } else { + queryBuilder.literalWhere( + 'JSON_EXTRACT(raw_configuration, :jsonPath) IS NOT NULL', + { jsonPath }, + ); + } + } else if (searchValue) { + /* + * Without key, search anywhere in the raw configuration text. Values are stored JSON-escaped (e.g. [\"flp1\"]), so + * match both the value as given and its JSON-escaped form, to find text typed naturally as well as copied from + * the stored configuration + */ + const plainPattern = `%${escapeLikeWildcards(searchValue)}%`; + const jsonEscapedPattern = `%${escapeLikeWildcards(JSON.stringify(searchValue).slice(1, -1))}%`; + + if (plainPattern === jsonEscapedPattern) { + queryBuilder.literalWhere('raw_configuration LIKE :plainPattern', { plainPattern }); + } else { + queryBuilder.literalWhere( + '(raw_configuration LIKE :plainPattern OR raw_configuration LIKE :jsonEscapedPattern)', + { plainPattern, jsonEscapedPattern }, + ); + } + } + } } queryBuilder.include({ association: 'runs' }); diff --git a/test/api/environments.test.js b/test/api/environments.test.js index bd6bfd3535..05d4535342 100644 --- a/test/api/environments.test.js +++ b/test/api/environments.test.js @@ -272,6 +272,84 @@ module.exports = () => { const rangeError = errors.find((err) => err.source.pointer === '/data/attributes/query/filter/runNumbers'); expect(rangeError.detail).to.equal('Invalid range: -105'); }); + + it('should successfully filter environments by a key in the raw configuration', async () => { + const response = await request(server).get('/api/environments?filter[rawConfiguration][key]=hosts'); + + expect(response.status).to.equal(200); + const environments = response.body.data; + expect(environments.length).to.be.equal(2); + expect(environments.map(({ id }) => id)).to.include.members(['CmCvjNbg', 'TDI59So3d']); + }); + + it('should return 400 if length of rawConfiguration key exceeds 256 characters', async () => { + const longKey = 'a'.repeat(257); + const response = await request(server).get(`/api/environments?filter[rawConfiguration][key]=${longKey}`); + + expect(response.status).to.equal(400); + }); + + it('should return 400 if length of rawConfiguration value exceeds 1000 characters', async () => { + const longValue = 'a'.repeat(1001); + const response = await request(server) + .get(`/api/environments?filter[rawConfiguration][key]=hosts&filter[rawConfiguration][value]=${longValue}`); + + expect(response.status).to.equal(400); + }); + + it('should return the same results when filtering raw configuration value with escaped and plain quotes', async () => { + // Search with plain quotes: ["fake-flp3"] + const plainResponse = await request(server) + .get(`/api/environments?filter[rawConfiguration][key]=hosts&filter[rawConfiguration][value]=${encodeURIComponent('["fake-flp3"]')}`); + + // Search with escaped quotes: [\"fake-flp3\"] + const escapedResponse = await request(server) + .get(`/api/environments?filter[rawConfiguration][key]=hosts&filter[rawConfiguration][value]=${encodeURIComponent('[\\"fake-flp3\\"]')}`); + + expect(plainResponse.status).to.equal(200); + expect(escapedResponse.status).to.equal(200); + + const plainEnvs = plainResponse.body.data; + const escapedEnvs = escapedResponse.body.data; + + expect(plainEnvs.length).to.be.equal(1); + expect(plainEnvs[0].id).to.equal('TDI59So3d'); + + expect(escapedEnvs.length).to.be.equal(1); + expect(escapedEnvs[0].id).to.equal('TDI59So3d'); + }); + + it('should filter raw configuration value with array brackets and quotes', async () => { + const response = await request(server) + .get('/api/environments?filter[rawConfiguration][key]=hosts&filter[rawConfiguration][value]=["fake-flp1", "fake-flp2"]'); + + expect(response.status).to.equal(200); + const environments = response.body.data; + expect(environments.length).to.be.equal(1); + expect(environments[0].id).to.equal('CmCvjNbg'); + }); + + it('should search a raw configuration value without key anywhere in the configuration', async () => { + const response = await request(server).get('/api/environments?filter[rawConfiguration][value]=fake-flp3'); + + expect(response.status).to.equal(200); + expect(response.body.data.map(({ id }) => id)).to.have.members(['TDI59So3d']); + }); + + it('should find a raw configuration fragment copied from the environment details page', async () => { + // The details page displays values JSON-serialized, e.g. "[\"fake-flp1\", \"fake-flp2\"]" + const response = await request(server) + .get(`/api/environments?filter[rawConfiguration][value]=${encodeURIComponent('\\"fake-flp1\\", \\"fake-flp2')}`); + + expect(response.status).to.equal(200); + expect(response.body.data.map(({ id }) => id)).to.have.members(['CmCvjNbg']); + }); + + it('should return 400 if the raw configuration filter has an empty key and no value', async () => { + const response = await request(server).get('/api/environments?filter[rawConfiguration][key]='); + + expect(response.status).to.equal(400); + }); }); describe('POST /api/environments', () => { it('should return 201 if valid data is provided', async () => { diff --git a/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js b/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js index 5f1e816571..571c26ac70 100644 --- a/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js +++ b/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js @@ -261,4 +261,85 @@ module.exports = () => { expect(new Set(allIds).size).to.be.equal(totalMatchingFilter); expect(allIds).to.have.members(['SomeId', 'newId', 'CmCvjNbg', 'EIDO13i3D', '8E4aZTjY', 'Dxi029djX']); }); + + it('should successfully filter to multiple environments on raw configuration', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { key: 'hosts', value: 'fake-flp' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments).to.be.an('array'); + expect(environments.length).to.be.equal(2); + expect(environments.map(({ id }) => id)).to.have.members(['TDI59So3d', 'CmCvjNbg']); + }); + + it('should successfully filter to a single environment on raw configuration', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { key: 'hosts', value: 'fake-flp3' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments).to.be.an('array'); + expect(environments.length).to.be.equal(1); + expect(environments.map(({ id }) => id)).to.have.members(['TDI59So3d']); + }); + + it('should successfully return empty environment array if filter does not match on raw configuration', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { key: 'nonExistingKey', value: 'nonExistingValue' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments).to.be.an('array'); + expect(environments.length).to.be.equal(0); + }); + + it('should filter raw configuration value containing plain brackets and quotes', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { key: 'hosts', value: '["fake-flp3"]' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments).to.be.an('array'); + expect(environments.length).to.be.equal(1); + expect(environments[0].id).to.equal('TDI59So3d'); + }); + + it('should not treat LIKE wildcards in the raw configuration value as wildcards', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { key: 'hosts', value: 'fake_flp3' } } }; + const { environments: underscoreMatches } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(underscoreMatches.length).to.be.equal(0); + + getAllEnvsDto.query = { filter: { rawConfiguration: { key: 'hosts', value: '%' } } }; + const { environments: percentMatches } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(percentMatches.length).to.be.equal(0); + }); + + it('should treat the raw configuration key literally and not as a JSON path expression', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { key: '*' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments.length).to.be.equal(0); + }); + + it('should filter raw configuration value with plain value containing array brackets', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { key: 'hosts', value: '["fake-flp1", "fake-flp2"]' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments).to.be.an('array'); + expect(environments.length).to.be.equal(1); + expect(environments[0].id).to.equal('CmCvjNbg'); + }); + + it('should search a value without key anywhere in the raw configuration', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { value: 'fake-flp3' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments.map(({ id }) => id)).to.have.members(['TDI59So3d']); + }); + + it('should search a value without key in its JSON-escaped form, as values are stored', async () => { + // Stored as "hosts":"[\"fake-flp1\", \"fake-flp2\"]" + getAllEnvsDto.query = { filter: { rawConfiguration: { value: '["fake-flp1", "fake-flp2"]' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments.map(({ id }) => id)).to.have.members(['CmCvjNbg']); + }); + + it('should search a value without key as given, including keys and JSON syntax', async () => { + // Stored as {"ccdb_enabled":true,"dcs_enabled":false} + getAllEnvsDto.query = { filter: { rawConfiguration: { value: '"ccdb_enabled":true' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments.map(({ id }) => id)).to.have.members(['Dxi029djX']); + }); + + it('should not treat LIKE wildcards as wildcards when searching a value without key', async () => { + getAllEnvsDto.query = { filter: { rawConfiguration: { value: 'fake_flp' } } }; + const { environments } = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + expect(environments.length).to.be.equal(0); + }); }; From 01977480b2809190c3bb319683959374978eb6b2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:13:19 +0100 Subject: [PATCH 2/3] Add raw configuration filter to the envs overview page --- .../RawConfigurationFilterModel.js | 126 ++++++++++++++++++ .../rawConfigurationFilter.js | 46 +++++++ .../environmentsActiveColumns.js | 62 ++++++++- .../Overview/EnvironmentOverviewModel.js | 2 + .../Overview/environmentOverviewComponent.js | 8 +- test/public/envs/overview.test.js | 42 ++++++ 6 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 lib/public/components/Filters/EnvironmentsFilter/RawConfigurationFilterModel.js create mode 100644 lib/public/components/Filters/EnvironmentsFilter/rawConfigurationFilter.js diff --git a/lib/public/components/Filters/EnvironmentsFilter/RawConfigurationFilterModel.js b/lib/public/components/Filters/EnvironmentsFilter/RawConfigurationFilterModel.js new file mode 100644 index 0000000000..8cd36bb156 --- /dev/null +++ b/lib/public/components/Filters/EnvironmentsFilter/RawConfigurationFilterModel.js @@ -0,0 +1,126 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { FilterModel } from '../common/FilterModel.js'; +import { RawTextFilterModel } from '../common/filters/RawTextFilterModel.js'; + +/** + * Model for filtering environments by raw configuration key-value pairs + */ +export class RawConfigurationFilterModel extends FilterModel { + /** + * Constructor + */ + constructor() { + super(); + + this._keyModel = new RawTextFilterModel(); + this._valueModel = new RawTextFilterModel(); + + // The key input is updated on each keystroke but only applied on change, keep track of the applied one + this._appliedKey = ''; + this._keyModel.observe(() => { + this._appliedKey = this.key; + }); + + this._addSubmodel(this._keyModel); + this._addSubmodel(this._valueModel); + } + + /** + * @inheritDoc + */ + reset() { + this._keyModel.reset(); + this._valueModel.reset(); + this._appliedKey = ''; + } + + /** + * @inheritDoc + */ + get isEmpty() { + return this.key === '' && this.value === ''; + } + + /** + * Returns the normalized value as an object with key and value properties, each one only if filled (without key, the value is + * searched anywhere in the configuration) + * + * @return {object} the normalized filter value + */ + get normalized() { + const result = {}; + if (this.key) { + result.key = this.key; + } + if (this.value) { + result.value = this.value; + } + return result; + } + + /** + * @inheritDoc + */ + set normalized({ key = '', value = '' } = {}) { + this._keyModel.normalized = key; + this._valueModel.normalized = value; + this._appliedKey = this.key; + } + + /** + * Returns the key currently typed in the key input, trimmed and without surrounding quotes + * + * @return {string} the key + */ + get key() { + return this._keyModel.value.trim().replace(/^"(.*)"$/, '$1'); + } + + /** + * Returns the value currently typed in the value input, trimmed + * + * @return {string} the value + */ + get value() { + return this._valueModel.value.trim(); + } + + /** + * Returns the key of the filter that is currently applied, empty if none + * + * @return {string} the applied key + */ + get appliedKey() { + return this._appliedKey; + } + + /** + * Returns the key filter model + * + * @return {RawTextFilterModel} the key filter model + */ + get keyModel() { + return this._keyModel; + } + + /** + * Returns the value filter model + * + * @return {RawTextFilterModel} the value filter model + */ + get valueModel() { + return this._valueModel; + } +} diff --git a/lib/public/components/Filters/EnvironmentsFilter/rawConfigurationFilter.js b/lib/public/components/Filters/EnvironmentsFilter/rawConfigurationFilter.js new file mode 100644 index 0000000000..bab8aa59e7 --- /dev/null +++ b/lib/public/components/Filters/EnvironmentsFilter/rawConfigurationFilter.js @@ -0,0 +1,46 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { h } from '/js/src/index.js'; +import { rawTextFilter } from '../common/filters/rawTextFilter.js'; + +/** + * Renders a filter for the raw configuration of environments, allowing users to filter environments on a key, optionally with a value, + * or on a text found anywhere in the raw configuration + * + * @param {RawConfigurationFilterModel} rawConfigurationFilterModel the raw configuration filter model + * @return {Component} the filter + */ +export const rawConfigurationFilter = (rawConfigurationFilterModel) => + h('.flex-row.g2', [ + h( + '.flex-column.flex-grow', + // bubble to update the value placeholder when a key is typed + { oninput: () => rawConfigurationFilterModel.visualChange$.notify() }, + [ + rawTextFilter( + rawConfigurationFilterModel.keyModel, + { id: 'rawConfiguration-key', placeholder: 'e.g. hosts' }, + ), + ], + ), + h('.flex-column.flex-grow', [ + rawTextFilter( + rawConfigurationFilterModel.valueModel, + { + id: 'rawConfiguration-value', + placeholder: rawConfigurationFilterModel.key ? 'e.g. some-host' : 'Search anywhere in the configuration', + }, + ), + ]), + ]); diff --git a/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js b/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js index 96f963f6b4..ed62540f2d 100644 --- a/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js +++ b/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js @@ -29,13 +29,14 @@ import { parseEnvironmentConfiguration } from '../format/parseEnvironmentConfigu import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js'; import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; +import { rawConfigurationFilter } from '../../../components/Filters/EnvironmentsFilter/rawConfigurationFilter.js'; /** * List of active columns for the environments overview * * The current status is displayed with its message and the status history with the time spent running by the environment */ -export const environmentsActiveColumns = { +const environmentsActiveColumns = { id: { name: 'Environment', size: 'w-environments-id-column', @@ -206,3 +207,62 @@ export const environmentsActiveColumns = { }, }, }; + +/** + * Extract the value of a given key from the raw configuration of an environment, formatted for display + * + * @param {string|null} rawConfiguration the raw configuration of the environment (JSON serialized) + * @param {string} key the configuration key + * @return {string} the value, or a dash if the configuration has no such key + */ +const formatRawConfigurationValue = (rawConfiguration, key) => { + let value; + try { + value = JSON.parse(rawConfiguration)?.[key]; + } catch { + return '-'; + } + + if (value === undefined || value === null) { + return '-'; + } + return typeof value === 'string' ? value : JSON.stringify(value); +}; + +/** + * Returns the active columns for the environments overview, with a column displaying the value of the raw configuration key being + * filtered on (hidden when there is no such filter) + * + * @param {RawConfigurationFilterModel} rawConfigurationFilterModel the raw configuration filter model + * @return {object} the active columns + */ +export const createEnvironmentsActiveColumns = (rawConfigurationFilterModel) => { + const { appliedKey } = rawConfigurationFilterModel; + + return { + ...environmentsActiveColumns, + rawConfiguration: { + name: 'Raw Configuration', + information: appliedKey ? `Value of "${appliedKey}" in the environment configuration` : null, + visible: Boolean(appliedKey), + sortable: false, + classes: 'f6', + noEllipsis: true, + + /** + * Display the value of the filtered key in the raw configuration + * + * @param {string|null} rawConfiguration the raw configuration of the environment + * @param {Environment} environment the environment + * @return {Component} the value + */ + format: (rawConfiguration, { id }) => h( + `#row${id}-rawConfiguration`, + overflowBalloon(formatRawConfigurationValue(rawConfiguration, appliedKey)), + ), + filterTooltip: 'With a key: environments having this key, optionally with a value containing the given text. ' + + 'Without a key: environments whose configuration contains the given text anywhere', + filter: () => rawConfigurationFilter(rawConfigurationFilterModel), + }, + }; +}; diff --git a/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js b/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js index 9621e4df33..31b291b699 100644 --- a/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js +++ b/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js @@ -13,6 +13,7 @@ import { TimeRangeInputModel } from '../../../components/Filters/common/filters/TimeRangeInputModel.js'; import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; +import { RawConfigurationFilterModel } from '../../../components/Filters/EnvironmentsFilter/RawConfigurationFilterModel.js'; import { coloredEnvironmentStatusComponent } from '../ColoredEnvironmentStatusComponent.js'; import { StatusAcronym } from '../../../domain/enums/statusAcronym.mjs'; import { SelectionModel } from '../../../components/common/selection/SelectionModel.js'; @@ -43,6 +44,7 @@ export class EnvironmentOverviewModel extends FilterableOverviewPageModel { })), }), ids: new RawTextFilterModel(), + rawConfiguration: new RawConfigurationFilterModel(), }, ); } diff --git a/lib/public/views/Environments/Overview/environmentOverviewComponent.js b/lib/public/views/Environments/Overview/environmentOverviewComponent.js index 20ff5eac6e..ac17243c1f 100644 --- a/lib/public/views/Environments/Overview/environmentOverviewComponent.js +++ b/lib/public/views/Environments/Overview/environmentOverviewComponent.js @@ -13,7 +13,7 @@ import { h } from '/js/src/index.js'; import { table } from '../../../components/common/table/table.js'; -import { environmentsActiveColumns } from '../ActiveColumns/environmentsActiveColumns.js'; +import { createEnvironmentsActiveColumns } from '../ActiveColumns/environmentsActiveColumns.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; @@ -33,15 +33,17 @@ export const environmentOverviewComponent = (envsOverviewModel) => { pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + const activeColumns = createEnvironmentsActiveColumns(envsOverviewModel.filteringModel.get('rawConfiguration')); + return h('', [ h( '.flex-row.header-container.g2.pv2', - filtersPanelPopover(envsOverviewModel, environmentsActiveColumns), + filtersPanelPopover(envsOverviewModel, activeColumns), ), warningComponent(envsOverviewModel), h('.w-100.flex-column', [ h('.header-container.pv2'), - table(environments, environmentsActiveColumns, { classes: 'table-sm' }), + table(environments, activeColumns, { classes: 'table-sm' }), paginationComponent(pagination), ]), ]); diff --git a/test/public/envs/overview.test.js b/test/public/envs/overview.test.js index 38756dee26..85c0d9e683 100644 --- a/test/public/envs/overview.test.js +++ b/test/public/envs/overview.test.js @@ -22,12 +22,14 @@ const { expectUrlParams, waitForNavigation, waitForTableLength, + waitForEmptyTable, getPopoverSelector, goToPage, openFilteringPanel, fillInput, getPeriodInputsSelectors, expectAttributeValue, + expectInputValue, resetFilters, } = require('../defaults.js'); const dateAndTime = require('date-and-time'); @@ -301,6 +303,10 @@ module.exports = () => { await expectAttributeValue(page, '.id-filter input', 'placeholder', 'e.g. CmCvjNbg, TDI59So3d...'); await expectAttributeValue(page, '.runs-filter input', 'placeholder', 'e.g. 553203, 553221, ...'); await expectAttributeValue(page, '.historyItems-filter input', 'placeholder', 'e.g. D-R-X'); + await expectAttributeValue(page, '#rawConfiguration-key', 'placeholder', 'e.g. hosts'); + await expectAttributeValue(page, '#rawConfiguration-value', 'placeholder', 'Search anywhere in the configuration'); + // The raw configuration column is only displayed when filtering on a key + expect(await page.$('#rowTDI59So3d-rawConfiguration')).to.be.null; // range of runNumbers await fillInput(page, '.runs-filter input', '103-104', ['change']); @@ -318,9 +324,45 @@ module.exports = () => { await fillInput(page, fromDateTimeSelector, '2019-08-09T00:00', ['change']); await fillInput(page, toDateTimeSelector, '2019-08-10T23:59', ['change']); + // Typing a key switches the value placeholder from the free-text search, without applying the filter + await fillInput(page, '#rawConfiguration-key', 'hosts', ['input']); + await page.waitForSelector('#rawConfiguration-value[placeholder="e.g. some-host"]'); + + await fillInput(page, '#rawConfiguration-key', 'hosts', ['change']); + await expectInnerText(page, '#rowTDI59So3d-rawConfiguration', '["fake-flp3"]'); + + // Go through an empty result first, otherwise the expected single row is already displayed before the filter applies + await fillInput(page, '#rawConfiguration-value', 'fake-flp1', ['change']); + await waitForEmptyTable(page, 5000); + await fillInput(page, '#rawConfiguration-value', 'fake-flp3', ['change']); await waitForTableLength(page, 1); expect(await page.$$eval('tbody tr', (rows) => rows.map((row) => row.id))).to.eql(['rowTDI59So3d']); await resetFilters(page); }); + + it('should keep the raw configuration value and search it anywhere when the key is cleared', async () => { + await goToPage(page, 'env-overview'); + await openFilteringPanel(page); + + // No host contains ITS, but the detectors of CmCvjNbg do + await fillInput(page, '#rawConfiguration-key', 'hosts', ['change']); + await fillInput(page, '#rawConfiguration-value', 'ITS', ['change']); + await waitForEmptyTable(page, 5000); + + await fillInput(page, '#rawConfiguration-key', '', ['change']); + await page.waitForSelector('tbody tr#rowCmCvjNbg'); + await expectInputValue(page, '#rawConfiguration-value', 'ITS'); + expect(await page.$('#rowCmCvjNbg-rawConfiguration')).to.be.null; + }); + + it('should search a raw configuration value without key from the URL anywhere in the configuration', async () => { + await goToPage(page, 'env-overview', { queryParameters: { filter: { rawConfiguration: { value: 'fake-flp3' } } } }); + await openFilteringPanel(page); + + await expectInputValue(page, '#rawConfiguration-key', ''); + await expectInputValue(page, '#rawConfiguration-value', 'fake-flp3'); + await page.waitForSelector('tbody tr#rowTDI59So3d'); + await waitForTableLength(page, 1); + }); }; From 25d6593120e468fb9c13d5cca3f707c592c2dd5e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:01:27 +0100 Subject: [PATCH 3/3] Fix eslint --- .../Filters/EnvironmentsFilter/rawConfigurationFilter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/public/components/Filters/EnvironmentsFilter/rawConfigurationFilter.js b/lib/public/components/Filters/EnvironmentsFilter/rawConfigurationFilter.js index bab8aa59e7..04193d8209 100644 --- a/lib/public/components/Filters/EnvironmentsFilter/rawConfigurationFilter.js +++ b/lib/public/components/Filters/EnvironmentsFilter/rawConfigurationFilter.js @@ -25,7 +25,7 @@ export const rawConfigurationFilter = (rawConfigurationFilterModel) => h('.flex-row.g2', [ h( '.flex-column.flex-grow', - // bubble to update the value placeholder when a key is typed + // Bubble to update the value placeholder when a key is typed { oninput: () => rawConfigurationFilterModel.visualChange$.notify() }, [ rawTextFilter(