Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/database/seeders/20220412122043-environments.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
10 changes: 10 additions & 0 deletions lib/domain/dtos/GetAllEnvironmentsDto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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',
},
),
]),
]);
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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),
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +44,7 @@ export class EnvironmentOverviewModel extends FilterableOverviewPageModel {
})),
}),
ids: new RawTextFilterModel(),
rawConfiguration: new RawConfigurationFilterModel(),
},
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),
]),
]);
Expand Down
48 changes: 48 additions & 0 deletions lib/usecases/environment/GetAllEnvironmentsUseCase.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,6 +90,7 @@ class GetAllEnvironmentsUseCase {
statusHistory,
runNumbers: runNumbersExpression,
created,
rawConfiguration,
} = filter;

if (created) {
Expand Down Expand Up @@ -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' });
Expand Down
Loading
Loading