diff --git a/InfoLogger/lib/controller/ConfigController.js b/InfoLogger/lib/controller/ConfigController.js index 7daabfaa8..13d4784cb 100644 --- a/InfoLogger/lib/controller/ConfigController.js +++ b/InfoLogger/lib/controller/ConfigController.js @@ -32,7 +32,7 @@ class ConfigController { * Handler for providing configuration for the InfoLogger optional services * @param {ExpressJS.Request} _ - object for the HTTP request. * @param {ExpressJS.Response} res - response with the configuration object - * @returns {*} response returned. + * @returns {Promise} response returned. */ async getConfigurationHandler(_, res) { try { diff --git a/InfoLogger/lib/middleware/serviceAvailabilityCheck.middleware.js b/InfoLogger/lib/middleware/serviceAvailabilityCheck.middleware.js index 9754a9e52..e63ec8dff 100644 --- a/InfoLogger/lib/middleware/serviceAvailabilityCheck.middleware.js +++ b/InfoLogger/lib/middleware/serviceAvailabilityCheck.middleware.js @@ -15,7 +15,7 @@ /** * Check whether provided service was configured and if so whether is available or not * @param {object} service - Service object to check - * @returns {Function} Express middleware function + * @returns {(req: Request, res: Response, next: NextFunction) => void} Express middleware function */ const serviceAvailabilityCheck = (service) => @@ -23,7 +23,7 @@ const serviceAvailabilityCheck = (service) => * Express middleware function * @param {Request} req - HTTP request object * @param {Response} res - HTTP response object - * @param {Function} next - Next middleware function + * @param {NextFunction} next - Next middleware function * @returns {void} - calls next or res depending on service availability */ (req, res, next) => { diff --git a/InfoLogger/lib/services/QueryService.js b/InfoLogger/lib/services/QueryService.js index 929e6b30a..55303198d 100644 --- a/InfoLogger/lib/services/QueryService.js +++ b/InfoLogger/lib/services/QueryService.js @@ -211,20 +211,22 @@ class QueryService { if (!filters[field]) { continue; } + const separator = field === 'message' ? '\n' : ' '; for (const operator in filters[field]) { - if (filters[field][operator] === null || !operator.includes('$')) { + if (filters[field][operator] === null || filters[field][operator] === false || !operator.includes('$')) { continue; } - if (operator === '$since' || operator === '$until') { + if (operator === '$matchEmpty' || operator === '$excludeEmpty') { + // no parameterized value needed for $matchEmpty or $excludeEmpty, the SQL is static + } else if (operator === '$since' || operator === '$until') { // read date, both input and output are GMT, no timezone to consider here values.push(new Date(filters[field][operator]).getTime() / 1000); } else { - const separator = field === 'message' ? '\n' : ' '; if ((operator === '$match' || operator === '$exclude') && filters[field][operator].split(separator).length > 1 ) { const subValues = filters[field][operator].split(separator); - subValues.forEach((value) => values.push(value)); + values.push(...subValues); } else { values.push(filters[field][operator]); } @@ -240,54 +242,58 @@ class QueryService { criteria.push(`\`${field}\`<=?`); break; case '$match': { - const separator = field === 'message' ? '\n' : ' '; const criteriaArray = filters[field].match.split(separator); - if (criteriaArray.length <= 1) { - if (criteriaArray.toString().includes('%')) { - criteria.push(`\`${field}\` LIKE (?)`); - } else { - criteria.push(`\`${field}\` = ?`); - } + + // Either create a LIKE match or an exact match + const toMatchCondition = (crit) => + crit.includes('%') + ? `\`${field}\` LIKE (?)` + : `\`${field}\` = ?`; + + const matchStr = criteriaArray.map(toMatchCondition).join(' OR '); + + const matchEmpty = filters[field].$matchEmpty; + if (matchEmpty) { + criteria.push(`(${matchStr} OR \`${field}\` = '' OR \`${field}\` IS NULL)`); + } else if (criteriaArray.length > 1) { + // Wrap so the OR doesn't bind looser than the AND between criteria in the WHERE clause + criteria.push(`(${matchStr})`); } else { - let criteriaString = '('; - criteriaArray.forEach((crit) => { - if (crit.includes('%')) { - criteriaString += `\`${field}\` LIKE (?) OR `; - } else { - criteriaString += `\`${field}\` = ? OR `; - } - }); - criteriaString = criteriaString.substr(0, criteriaString.length - 4); - criteriaString += ')'; - criteria.push(criteriaString); + criteria.push(matchStr); } break; } case '$exclude': { - const separator = field === 'message' ? '\n' : ' '; const criteriaArray = filters[field].exclude.split(separator); - if (criteriaArray.length <= 1) { - if (criteriaArray.toString().includes('%')) { - criteria.push(`NOT(\`${field}\` LIKE (?) AND \`${field}\` IS NOT NULL)`); - } else { - criteria.push(`NOT(\`${field}\` = ? AND \`${field}\` IS NOT NULL)`); - } - } else { - let criteriaString = 'NOT('; - criteriaArray.forEach((crit) => { - if (crit.includes('%')) { - criteriaString += `\`${field}\` LIKE (?) AND \`${field}\` IS NOT NULL OR `; - } else { - criteriaString += `\`${field}\` = ? AND \`${field}\` IS NOT NULL OR `; - } - }); - criteriaString = criteriaString.substr(0, criteriaString.length - 4); - criteriaString += ')'; - criteria.push(criteriaString); - } + const toExcludeCondition = (crit) => + crit.includes('%') + ? `\`${field}\` LIKE (?) AND \`${field}\` IS NOT NULL` + : `\`${field}\` = ? AND \`${field}\` IS NOT NULL`; + + const excludeStr = criteriaArray.length > 1 + ? criteriaArray.map((c) => `(${toExcludeCondition(c)})`).join(' OR ') + : toExcludeCondition(criteriaArray[0]); + + criteria.push(`NOT(${excludeStr})`); + + const excludeEmpty = filters[field].$excludeEmpty; + if (excludeEmpty) { + criteria.push(`(\`${field}\` != '' AND \`${field}\` IS NOT NULL)`); + } break; } + case '$matchEmpty': + // If no match value but $matchEmpty is true, we want to match only empty values + if (!filters[field].$match) { + criteria.push(`(\`${field}\` = '' OR \`${field}\` IS NULL)`); + } + break; + case '$excludeEmpty': + if (!filters[field].$exclude) { + criteria.push(`(\`${field}\` != '' AND \`${field}\` IS NOT NULL)`); + } + break; case '$in': criteria.push(`\`${field}\` IN (?)`); break; diff --git a/InfoLogger/lib/utils/fromSqlToNativeError.js b/InfoLogger/lib/utils/fromSqlToNativeError.js index 9bea97342..62df10e02 100644 --- a/InfoLogger/lib/utils/fromSqlToNativeError.js +++ b/InfoLogger/lib/utils/fromSqlToNativeError.js @@ -19,7 +19,7 @@ const { NotFoundError, TimeoutError, UnauthorizedAccessError } = require('@alice * The purpose is to translate MySQL errors to native JS errors * Source: https://github.com/mariadb-corporation/mariadb-connector-nodejs/blob/c3a9e333243a1d92b22f4ca1e5a574ab0de77cea/lib/const/error-code.js#L1040 * @param {SqlError} error - the error from a catch or callback - * @throws throws a native JS error + * @throws {Error} throws a native JS error */ const fromSqlToNativeError = (error) => { const { code, errno, sqlMessage } = error; diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index 5a76d735c..666573210 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -124,7 +124,51 @@ footer { border-top: 1px solid var(--color-gray); } .btn:hover { background-color: #a0a0a0; color: var(--color-white); } .btn:active, .btn.active, .dropdown-open > .btn { background-color: #c0c0c0; } -.text-area-for-message:focus { width: 50%; height: 10rem !important; right: 0; position: absolute; } +.filter-input-group { display: flex; + position: relative; +} +.filter-input-group .form-control { + height: 2em; + border-radius: .25rem; + z-index: 1; +} +.filter-input-group:has(.empty-toggle.active) .form-control, +.filter-input-group:hover .form-control { + border-radius: .25rem 0 0 .25rem; +} + +.empty-toggle { + display: none; + padding: 0 0.15rem; + font-size: 0.7rem; + font-weight: bold; + border-radius: 0 .25rem .25rem 0; + opacity: 0.6; +} +.filter-input-group:hover .empty-toggle:not(.active) { + display: block; +} +.empty-toggle:hover { + opacity: 1; +} +.empty-toggle.active { + opacity: 1; + display: block; +} + +.text-area-for-message { + resize: none; +} +.text-area-for-message:focus { + width: calc(400% + 12px); + height: 10rem !important; + inset: 0 0 auto auto; + position: absolute; + z-index: 10; + border-radius: .25rem; +} +.filter-input-group:has(.text-area-for-message:focus) .empty-toggle { display: none; } + a.disabled { pointer-events: none; cursor: default; } .cell-context-menu-overlay { diff --git a/InfoLogger/public/common/utils.js b/InfoLogger/public/common/utils.js index 9c5d16168..cca66aa25 100644 --- a/InfoLogger/public/common/utils.js +++ b/InfoLogger/public/common/utils.js @@ -16,9 +16,9 @@ * Limit the number of calls to `fn` to 1 per `time` maximum. * First call is immediate if `time` have been waited already. * All other calls before end of `time` window will lead to 1 exececution at the end of window. - * @param {string} fn - function to be called - * @param {string} time - ms - * @returns {Function} lambda function to be called to call `fn` + * @param {(...args: unknown[]) => void} fn - function to be called + * @param {number} time - ms + * @returns {(...args: unknown[]) => void} lambda function to be called to call `fn` * @example * let f = callRateLimiter((arg) => console.log('called', arg), 1000); * 00:00:00 f(1);f(2);f(3);f(4); diff --git a/InfoLogger/public/constants/text-filter-operators.const.js b/InfoLogger/public/constants/text-filter-operators.const.js index 3aa6587f3..477f4feba 100644 --- a/InfoLogger/public/constants/text-filter-operators.const.js +++ b/InfoLogger/public/constants/text-filter-operators.const.js @@ -15,4 +15,11 @@ /** * Operators used with the text filters. */ -export const TEXT_FILTER_OPERATORS = Object.freeze(['since', 'until', 'match', 'exclude']); +export const TEXT_FILTER_OPERATORS = Object.freeze([ + 'since', + 'until', + 'match', + 'exclude', + 'matchEmpty', + 'excludeEmpty', +]); diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 009f20bcf..e3f2240c3 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -130,22 +130,44 @@ export const cellContextMenu = (model) => { }, model.log.filter.criterias.level.max === 1), ]; } + if (isTimestamp) { + const { since, until } = model.log.filter.criterias.timestamp; + return [ + createMenuItem(iconCheck(), 'success', 'From', () => { + model.log.setCriteria('timestamp', 'since', value); + hideMenu(); + }), + createMenuItem(iconBan(), 'danger', 'To', () => { + model.log.setCriteria('timestamp', 'until', value); + hideMenu(); + }), + createMenuItem(iconTrash(), 'danger', 'Clear Filter', () => { + model.log.setCriteria('timestamp', 'since', ''); + model.log.setCriteria('timestamp', 'until', ''); + hideMenu(); + }, !since && !until), + ]; + } + + const { match, exclude, matchEmpty, excludeEmpty } = model.log.filter.criterias[field]; + const isClear = !match && !exclude && !matchEmpty && !excludeEmpty; + return [ - createMenuItem(iconCheck(), 'success', isTimestamp ? 'From' : 'Match', () => { - model.log.setCriteria(field, isTimestamp ? 'since' : 'match', isTimestamp ? value : appendFilter('match')); + createMenuItem(iconCheck(), 'success', 'Match', () => { + model.log.setCriteria(field, 'match', appendFilter('match')); hideMenu(); }), - createMenuItem(iconBan(), 'danger', isTimestamp ? 'To' : 'Exclude', () => { - model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', isTimestamp ? value : appendFilter('exclude')); + createMenuItem(iconBan(), 'danger', 'Exclude', () => { + model.log.setCriteria(field, 'exclude', appendFilter('exclude')); hideMenu(); }), createMenuItem(iconTrash(), 'danger', 'Clear Filter', () => { - model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', ''); - model.log.setCriteria(field, isTimestamp ? 'since' : 'match', ''); + model.log.setCriteria(field, 'match', ''); + model.log.setCriteria(field, 'exclude', ''); + model.log.setCriteria(field, 'matchEmpty', false); + model.log.setCriteria(field, 'excludeEmpty', false); hideMenu(); - }, isTimestamp - ? !model.log.filter.criterias.timestamp.since && !model.log.filter.criterias.timestamp.until - : !model.log.filter.criterias[field].match && !model.log.filter.criterias[field].exclude), + }, isClear), ]; }; diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 7e70dd646..e4522d903 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -24,9 +24,24 @@ import { getDisabledSeverities } from '../constants/log-level-filters.const.js'; */ /** - * @typedef Criteria * @type {Array.} + * @typedef {Array.} Criteria */ +/** + * This makes a criteria object with all properties initialized to empty or minimal value + * @returns {object} criteria object with all properties initialized + */ +const makeDefaultMatchExcludeOperators = () => ({ + match: '', + $match: null, + exclude: '', + $exclude: null, + matchEmpty: false, + $matchEmpty: false, + excludeEmpty: false, + $excludeEmpty: false, +}); + /** * This class stores raw filters from user (strings) and parsed ones (like Date object). * It can generate a function to filter "messages" to be used @@ -60,6 +75,9 @@ export default class LogFilter extends Observable { * // */ setCriteria(field, operator, value) { + if (!(operator in this.criterias[field])) { + throw new Error(`unknown operator ${operator} for ${field}`); + } if (this.criterias[field][operator] !== value) { this.criterias[field][operator] = value; // auto-complete other properties / parse @@ -85,6 +103,14 @@ export default class LogFilter extends Observable { case 'in': this.criterias[field]['$in'] = value ? value.split(' ') : null; break; + case 'matchEmpty': + this.criterias[field]['$matchEmpty'] = Boolean(value); + this._clearOppositeEmpty(field, 'matchEmpty'); + break; + case 'excludeEmpty': + this.criterias[field]['$excludeEmpty'] = Boolean(value); + this._clearOppositeEmpty(field, 'excludeEmpty'); + break; default: throw new Error('unknown operator'); } @@ -156,7 +182,10 @@ export default class LogFilter extends Observable { */ hasActiveTextFilters() { return Object.values(this.criterias).some((criteria) => - TEXT_FILTER_OPERATORS.some((operator) => criteria[operator]?.trim())); + TEXT_FILTER_OPERATORS.some((operator) => { + const v = criteria[operator]; + return typeof v === 'string' ? v.trim() : Boolean(v); + })); } /** @@ -172,15 +201,14 @@ export default class LogFilter extends Observable { * Remove any active severity selections that are disallowed by the current level. */ enforceDisabledSeverities() { - const disabled = getDisabledSeverities(this.criterias.level.max); - if (disabled.length === 0 || !this.criterias.severity.$in) { - return; - } - const current = this.criterias.severity.$in; if (!current) { return; } + const disabled = getDisabledSeverities(this.criterias.level.max); + if (disabled.length === 0) { + return; + } const filteredSeverities = current.filter((s) => !disabled.includes(s)); // Only update if there is a change @@ -193,7 +221,7 @@ export default class LogFilter extends Observable { /** * Generates a function to filter a log passed as argument to it * Output of function is boolean. - * @returns {Function.} - function to filter logs + * @returns {(message: WebSocketMessage) => boolean} - function to filter logs */ toStringifyFunction() { /** @@ -238,6 +266,15 @@ export default class LogFilter extends Observable { return logValue.replace(/\r?\n|\r/g, ''); } + /** + * Whether a log field value is considered empty for matchEmpty/excludeEmpty purposes. + * @param {string|number|undefined|null} logValue - value of the log field + * @returns {boolean} - true if the value is undefined, null, or an empty string + */ + function isEmpty(logValue) { + return logValue === undefined || logValue === null || logValue === ''; + } + /** * Function that applies the criteria of one filter set by the user on each received logValue * @param {object} logValue - value of the log field that is to be checked (e.g. message, severity, etc.) @@ -249,7 +286,7 @@ export default class LogFilter extends Observable { for (const operator in criteria) { let criteriaValue = criteria[operator]; // don't apply criterias not set - if (criteriaValue === null) { + if (criteriaValue === null || criteriaValue === false) { continue; } switch (operator) { @@ -260,17 +297,25 @@ export default class LogFilter extends Observable { break; } case '$match': { + if (isEmpty(logValue)) { + if (!criteria.$matchEmpty) { + return false; + } + break; + } const criteriaList = criteriaValue.split(separator); if (criteriaList.length > 1) { criteriaValue = criteriaValue.replace(new RegExp(separator, 'g'), '|'); } - if (logValue === undefined || - !generateRegexCriteriaValue(criteriaValue).test(removeNewLinesFrom(logValue))) { + if (!generateRegexCriteriaValue(criteriaValue).test(removeNewLinesFrom(logValue))) { return false; } break; } case '$exclude': { + if (isEmpty(logValue) && criteria.$excludeEmpty) { + return false; + } const criteriaList = criteriaValue.split(separator); if (criteriaList.length > 1) { criteriaValue = criteriaValue.replace(new RegExp(separator, 'g'), '|'); @@ -281,6 +326,16 @@ export default class LogFilter extends Observable { } break; } + case '$matchEmpty': + if (!criteria.$match && !isEmpty(logValue)) { + return false; + } + break; + case '$excludeEmpty': + if (!criteria.$exclude && isEmpty(logValue)) { + return false; + } + break; case '$since': if (logValue === undefined || parseInfoLoggerDate(logValue) < parseInfoLoggerDate(criteriaValue)) { return false; @@ -339,6 +394,22 @@ export default class LogFilter extends Observable { * original state: empty or exclusive for other criterias. */ resetCriteria() { + const TEXT_FIELDS = [ + 'hostname', + 'rolename', + 'pid', + 'username', + 'system', + 'facility', + 'detector', + 'partition', + 'run', + 'errcode', + 'errline', + 'errsource', + 'message', + ]; + this.criterias = { timestamp: { since: '', @@ -346,84 +417,7 @@ export default class LogFilter extends Observable { $since: null, $until: null, }, - hostname: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - rolename: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - pid: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - username: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - system: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - facility: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - detector: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - partition: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - run: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - errcode: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - errline: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - errsource: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, - message: { - match: '', - exclude: '', - $match: null, - $exclude: null, - }, + ...Object.fromEntries(TEXT_FIELDS.map((field) => [field, makeDefaultMatchExcludeOperators()])), severity: { in: 'I W E F', $in: ['I', 'W', 'E', 'F'], @@ -435,4 +429,12 @@ export default class LogFilter extends Observable { }; this.notify(); } + + _clearOppositeEmpty(field, operator) { + const oppositeKey = operator === 'matchEmpty' ? 'excludeEmpty' : 'matchEmpty'; + if (this.criterias[field][oppositeKey]) { + this.criterias[field][oppositeKey] = false; + this.criterias[field][`$${oppositeKey}`] = false; + } + } } diff --git a/InfoLogger/public/logFilter/tableFilters.js b/InfoLogger/public/logFilter/tableFilters.js index bb571395c..2ece1067a 100644 --- a/InfoLogger/public/logFilter/tableFilters.js +++ b/InfoLogger/public/logFilter/tableFilters.js @@ -134,13 +134,20 @@ const createClickableLabel = (model, label) => h('td', h('button.btn.w-100', { * @param {number} tabIndex - value for order of the tab when using keyboard `tab` action * @returns {vnode} - input field within a td element */ -const createInputField = (logModel, field, command, tabIndex = 1) => h('td', h('input.form-control', { - type: 'text', - tabIndex, - oninput: (e) => logModel.setCriteria(field, command, e.target.value), - value: logModel.filter.criterias[field][command].slice(), - placeholder: field === 'hostname' ? command : '', -})); +const createInputField = (logModel, field, command, tabIndex = 1) => + h( + 'td', + h('.filter-input-group', [ + h('input.form-control', { + type: 'text', + tabIndex, + oninput: (e) => logModel.setCriteria(field, command, e.target.value), + value: logModel.filter.criterias[field][command].slice(), + placeholder: field === 'hostname' ? command : '', + }), + createEmptyToggle(logModel, field, command), + ]), + ); /** * Generate a text area which onfocus will expand, allowing the user to easily input multiple lines of text @@ -151,24 +158,45 @@ const createInputField = (logModel, field, command, tabIndex = 1) => h('td', h(' * @returns {vnode} - text area within a td element */ const createTextAreaField = (model, field, command, tabIndex) => - h('td', h('textarea.form-control.text-area-for-message', { - style: 'height:2em; resize: none;', - tabIndex, - placeholder: !model.messageFocused - ? '' - : 'Include/Exclude multiple error messages separated by new line. ' + - 'To partially match a message, use the SQL wildcard \'%\' \n\n' + - 'e.g \n\n%[FMQ] IDLE ---> INITIALIZING DEVICE%\n' + - 'TASK %QC% running out of memory\n' + - 'weird error with strict message', - onfocus: () => { - model.messageFocused = true; - model.notify(); - }, - onfocusout: () => { - model.messageFocused = false; - model.notify(); + h('td', h('.filter-input-group', [ + h('textarea.form-control.text-area-for-message', { + tabIndex, + placeholder: !model.messageFocused + ? '' + : 'Include/Exclude multiple error messages separated by new line. ' + + 'To partially match a message, use the SQL wildcard \'%\' \n\n' + + 'e.g \n\n%[FMQ] IDLE ---> INITIALIZING DEVICE%\n' + + 'TASK %QC% running out of memory\n' + + 'weird error with strict message', + onfocus: () => { + model.messageFocused = true; + model.notify(); + }, + onfocusout: () => { + model.messageFocused = false; + model.notify(); + }, + oninput: (e) => model.log.setCriteria(field, command, e.target.value.trim()), + value: model.log.filter.criterias[field][command].slice(), + }), + createEmptyToggle(model.log, field, command), + ])); + +const createEmptyToggle = (logModel, field, command) => { + const EMPTY_MODES = { + match: { key: 'matchEmpty', verb: 'Match' }, + exclude: { key: 'excludeEmpty', verb: 'Exclude' }, + }; + const { key, verb } = EMPTY_MODES[command]; + const isActive = logModel.filter.criterias[field][key]; + const title = `${verb} logs where ${field} is empty`; + + return h('button.btn.empty-toggle', { + className: isActive ? 'active' : '', + title, + onclick: (e) => { + logModel.setCriteria(field, key, !isActive); + e.target.blur(); }, - oninput: (e) => model.log.setCriteria(field, command, e.target.value.trim()), - value: model.log.filter.criterias[field][command].slice(), - })); + }, '∅'); +}; diff --git a/InfoLogger/test/lib/services/mocha-query-service.test.js b/InfoLogger/test/lib/services/mocha-query-service.test.js index ff3470ddd..7b9130edb 100644 --- a/InfoLogger/test/lib/services/mocha-query-service.test.js +++ b/InfoLogger/test/lib/services/mocha-query-service.test.js @@ -191,7 +191,7 @@ describe('\'QueryService\' test suite', () => { const expectedCriteria = [ '`timestamp`>=?', '`timestamp`<=?', - 'NOT(`hostname` = ? AND `hostname` IS NOT NULL OR `hostname` = ? AND `hostname` IS NOT NULL)', + 'NOT((`hostname` = ? AND `hostname` IS NOT NULL) OR (`hostname` = ? AND `hostname` IS NOT NULL))', '`severity` IN (?)', '`level`<=?', '`userId`>=?', @@ -203,6 +203,89 @@ describe('\'QueryService\' test suite', () => { }); }); + describe('Empty field filters', () => { + it('should skip $matchEmpty when value is false', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { $matchEmpty: false }, + }); + assert.deepStrictEqual(result, { values: [], criteria: [] }); + }); + + it('should skip $excludeEmpty when value is false', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { $excludeEmpty: false }, + }); + assert.deepStrictEqual(result, { values: [], criteria: [] }); + }); + + it('should generate IS NULL/empty condition for $matchEmpty alone', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { $matchEmpty: true }, + }); + assert.deepStrictEqual(result.values, []); + const expectedCriteria = '(`hostname` = \'\' OR `hostname` IS NULL)'; + assert.deepStrictEqual(result.criteria, [expectedCriteria]); + }); + + it('should generate IS NOT NULL/non-empty condition for $excludeEmpty alone', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { $excludeEmpty: true }, + }); + assert.deepStrictEqual(result.values, []); + const expectedCriteria = '(`hostname` != \'\' AND `hostname` IS NOT NULL)'; + assert.deepStrictEqual(result.criteria, [expectedCriteria]); + }); + + it('should OR matchEmpty with $match when both are set', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { match: 'test', $match: 'test', $matchEmpty: true }, + }); + assert.deepStrictEqual(result.values, ['test']); + const expectedCriteria = '(`hostname` = ? OR `hostname` = \'\' OR `hostname` IS NULL)'; + assert.deepStrictEqual(result.criteria, [expectedCriteria]); + }); + + it('should AND excludeEmpty with $exclude when both are set', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { exclude: 'test', $exclude: 'test', $excludeEmpty: true }, + }); + assert.deepStrictEqual(result.values, ['test']); + assert.deepStrictEqual(result.criteria, [ + 'NOT(`hostname` = ? AND `hostname` IS NOT NULL)', + '(`hostname` != \'\' AND `hostname` IS NOT NULL)', + ]); + }); + + it('should not push matchEmpty criteria when $match is present (defers to $match case)', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { match: 'test', $match: 'test', $matchEmpty: true }, + }); + // Should have one combined criterion from $match, not a separate one from $matchEmpty + assert.strictEqual(result.criteria.length, 1); + assert.ok(result.criteria[0].includes('IS NULL')); + }); + + it('should handle $match with multiple values and matchEmpty', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { match: 'foo bar', $match: 'foo bar', $matchEmpty: true }, + }); + assert.deepStrictEqual(result.values, ['foo', 'bar']); + const expectedCriteria = '(`hostname` = ? OR `hostname` = ? OR `hostname` = \'\' OR `hostname` IS NULL)'; + assert.deepStrictEqual(result.criteria, [expectedCriteria]); + }); + + it('should handle $exclude with multiple values and excludeEmpty', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { exclude: 'foo bar', $exclude: 'foo bar', $excludeEmpty: true }, + }); + assert.deepStrictEqual(result.values, ['foo', 'bar']); + assert.deepStrictEqual(result.criteria, [ + 'NOT((`hostname` = ? AND `hostname` IS NOT NULL) OR (`hostname` = ? AND `hostname` IS NOT NULL))', + '(`hostname` != \'\' AND `hostname` IS NOT NULL)', + ]); + }); + }); + describe('Parse criteria as SQL Query', () => { it('should successfully return empty string for criteria if array is empty', () => { assert.deepStrictEqual(emptySqlDataSource._getCriteriaAsString([]), ''); diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 0b7cef97e..174df7ce6 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -15,9 +15,11 @@ const assert = require('assert'); const test = require('../mocha-index'); +const isFieldEmpty = (value) => value === undefined || value === null || value === ''; + describe('Live Mode test-suite', async () => { - let baseUrl; - let page; + let baseUrl = null; + let page = null; before(async () => { ({ helpers: { baseUrl }, page } = test); }); @@ -64,7 +66,7 @@ describe('Live Mode test-suite', async () => { window.model.log.filter.setCriteria('hostname', 'match', 'aldaqecs01-v1'); }); await page.evaluate(() => window.model.log.liveStart()); - await page.waitForFunction('window.model.log.list.length > 5', { timeout: 5000 }); + await page.waitForFunction('window.model.log.list.length > 5', { timeout: 10000 }); const list = await page.evaluate(() => window.model.log.list); const isHostNameMatching = list .map((element) => element.hostname) @@ -116,6 +118,74 @@ describe('Live Mode test-suite', async () => { assert.ok(isUserNameMatching); }); + describe('Empty field filters in live mode', async () => { + it('should only receive logs with empty rolename when matchEmpty is set', async () => { + await page.evaluate(() => window.model.log.liveStop('Paused')); + await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + window.model.log.filter.setCriteria('level', 'max', null); + window.model.log.filter.setCriteria('rolename', 'matchEmpty', true); + }); + await page.evaluate(() => window.model.log.liveStart()); + await page.waitForFunction('window.model.log.list.length > 5', { timeout: 5000 }); + + const list = await page.evaluate(() => window.model.log.list); + const allEmpty = list.every((log) => isFieldEmpty(log.rolename)); + assert.ok(list.length > 0); + assert.ok(allEmpty); + }); + + it('should only receive logs with non-empty rolename when excludeEmpty is set', async () => { + await page.evaluate(() => window.model.log.liveStop('Paused')); + await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + window.model.log.filter.setCriteria('level', 'max', null); + window.model.log.filter.setCriteria('rolename', 'excludeEmpty', true); + }); + await page.evaluate(() => window.model.log.liveStart()); + await page.waitForFunction('window.model.log.list.length > 5', { timeout: 5000 }); + + const list = await page.evaluate(() => window.model.log.list); + const allNonEmpty = list.every((log) => !isFieldEmpty(log.rolename)); + assert.ok(list.length > 0); + assert.ok(allNonEmpty); + }); + + it('should receive matching OR empty logs when match and matchEmpty are both set', async () => { + await page.evaluate(() => window.model.log.liveStop('Paused')); + await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + window.model.log.filter.setCriteria('level', 'max', null); + window.model.log.filter.setCriteria('rolename', 'match', 'mon-DA-PHS-0'); + window.model.log.filter.setCriteria('rolename', 'matchEmpty', true); + }); + await page.evaluate(() => window.model.log.liveStart()); + await page.waitForFunction('window.model.log.list.length > 5', { timeout: 5000 }); + + const list = await page.evaluate(() => window.model.log.list); + const allValid = list.every((log) => isFieldEmpty(log.rolename) || log.rolename === 'mon-DA-PHS-0'); + assert.ok(list.length > 0); + assert.ok(allValid); + }); + + it('should exclude matching AND empty logs when exclude and excludeEmpty are both set', async () => { + await page.evaluate(() => window.model.log.liveStop('Paused')); + await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + window.model.log.filter.setCriteria('level', 'max', null); + window.model.log.filter.setCriteria('rolename', 'exclude', 'mon-DA-PHS-0'); + window.model.log.filter.setCriteria('rolename', 'excludeEmpty', true); + }); + await page.evaluate(() => window.model.log.liveStart()); + await page.waitForFunction('window.model.log.list.length > 5', { timeout: 10000 }); + + const list = await page.evaluate(() => window.model.log.list); + const allValid = list.every((log) => !isFieldEmpty(log.rolename) && log.rolename !== 'mon-DA-PHS-0'); + assert.ok(list.length > 0); + assert.ok(allValid); + }); + }); + it('should successfully go to mode LIVE in paused state', async () => { const activeMode = await page.evaluate(() => { window.model.log.liveStop('Paused'); diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index 5a5b37b25..3a197897d 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -318,6 +318,7 @@ describe('Cell Context Menu', async () => { await page.evaluate(() => { window.model.log.filter.setCriteria('hostname', 'match', 'ctx-host-01'); window.model.log.filter.setCriteria('hostname', 'exclude', 'ctx-host-01'); + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); }); await openContextMenu(page, 'hostname', 'ctx-host-01', 100, 120); @@ -329,6 +330,9 @@ describe('Cell Context Menu', async () => { $match: window.model.log.filter.criterias.hostname.$match, exclude: window.model.log.filter.criterias.hostname.exclude, $exclude: window.model.log.filter.criterias.hostname.$exclude, + matchEmpty: window.model.log.filter.criterias.hostname.matchEmpty, + $matchEmpty: window.model.log.filter.criterias.hostname.$matchEmpty, + isOpen: window.model.log.contextMenu.isOpen, })); @@ -336,6 +340,8 @@ describe('Cell Context Menu', async () => { assert.strictEqual(criteria.$match, null); assert.strictEqual(criteria.exclude, ''); assert.strictEqual(criteria.$exclude, null); + assert.strictEqual(criteria.matchEmpty, false); + assert.strictEqual(criteria.$matchEmpty, false); assert.strictEqual(criteria.isOpen, false); }); diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 21eb8a098..c203daa5e 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -10,24 +10,23 @@ * 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. -*/ + */ -/* eslint-disable max-len */ const assert = require('assert'); const test = require('../mocha-index'); describe('Filter actions test-suite', async () => { - let baseUrl; - let page; + let baseUrl = null; + let page = null; before(async () => { - baseUrl = test.helpers.baseUrl; - page = test.page; + ({ page } = test); + ({ baseUrl } = test.helpers); }); // "physicist" is not a distinct stored profile; the server returns defaultCriterias for any name - it('should succesfully load a page with profile in the URI', async function() { - await page.goto(baseUrl + "?profile=physicist", {waitUntil: 'networkidle0'}); + it('should succesfully load a page with profile in the URI', async () => { + await page.goto(`${baseUrl}?profile=physicist`, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); const search = decodeURIComponent(location.search); @@ -37,60 +36,105 @@ describe('Filter actions test-suite', async () => { it('should update column headers based on profile when passed in the URI', async () => { const expectedColumns = { - date: {size: 'cell-m', visible: false}, - time: {size: 'cell-m', visible: true}, - hostname: {size: 'cell-m', visible: true}, - rolename: {size: 'cell-m', visible: false}, - pid: {size: 'cell-s', visible: false}, - username: {size: 'cell-m', visible: false}, - system: {size: 'cell-s', visible: true}, - facility: {size: 'cell-m', visible: true}, - detector: {size: 'cell-s', visible: true}, - partition: {size: 'cell-m', visible: true}, - run: {size: 'cell-s', visible: true}, - errcode: {size: 'cell-s', visible: false}, - errline: {size: 'cell-s', visible: false}, - errsource: {size: 'cell-m', visible: false}, - message: {size: 'cell-xl', visible: true} + date: { size: 'cell-m', visible: false }, + time: { size: 'cell-m', visible: true }, + hostname: { size: 'cell-m', visible: true }, + rolename: { size: 'cell-m', visible: false }, + pid: { size: 'cell-s', visible: false }, + username: { size: 'cell-m', visible: false }, + system: { size: 'cell-s', visible: true }, + facility: { size: 'cell-m', visible: true }, + detector: { size: 'cell-s', visible: true }, + partition: { size: 'cell-m', visible: true }, + run: { size: 'cell-s', visible: true }, + errcode: { size: 'cell-s', visible: false }, + errline: { size: 'cell-s', visible: false }, + errsource: { size: 'cell-m', visible: false }, + message: { size: 'cell-xl', visible: true }, }; - const columns = await page.evaluate(() => { - return window.model.table.colsHeader; - }); + const columns = await page.evaluate(() => window.model.table.colsHeader); assert.deepStrictEqual(columns, expectedColumns); }); + it('should initialize each criteria field with the expected operators', async () => { + const operators = await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + const result = {}; + for (const [field, ops] of Object.entries(window.model.log.filter.criterias)) { + result[field] = Object.keys(ops); + } + return result; + }); + + const TEXT_OPS = [ + 'match', + '$match', + 'exclude', + '$exclude', + 'matchEmpty', + '$matchEmpty', + 'excludeEmpty', + '$excludeEmpty', + ]; + + assert.deepStrictEqual(operators, { + timestamp: ['since', 'until', '$since', '$until'], + hostname: TEXT_OPS, + rolename: TEXT_OPS, + pid: TEXT_OPS, + username: TEXT_OPS, + system: TEXT_OPS, + facility: TEXT_OPS, + detector: TEXT_OPS, + partition: TEXT_OPS, + run: TEXT_OPS, + errcode: TEXT_OPS, + errline: TEXT_OPS, + errsource: TEXT_OPS, + message: TEXT_OPS, + severity: ['in', '$in'], + level: ['max', '$max'], + }); + }); + + it('should throw when setting non-existent operator on a field', async () => { + await assert.rejects(page.evaluate(() => window.model.log.filter.setCriteria('timestamp', 'matchEmpty', false))); + await assert.rejects(page.evaluate(() => window.model.log.filter.setCriteria('pid', 'in', false))); + await assert.rejects(page.evaluate(() => window.model.log.filter.setCriteria('rolename', 'since', false))); + }); + it('should update filters based on profile when passed in the URI', async () => { - // for now check if the filters are reset once the profile is passed + // for now check if the filters are reset once the profile is passed const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; const searchParams = await page.evaluate(() => { - const params = {profile: 'physicist'}; + const params = { profile: 'physicist' }; window.model.parseLocation(params); return window.location.search; }); - await page.waitForFunction(`window.model.notification.state === 'shown'`); - await page.waitForFunction(`window.model.notification.type === 'success'`); - await page.waitForFunction(`window.model.notification.message === "The profile PHYSICIST was loaded successfully"`); + await page.waitForFunction('window.model.notification.state === \'shown\''); + await page.waitForFunction('window.model.notification.type === \'success\''); + await page.waitForFunction('window.model.notification.message === "The profile PHYSICIST was loaded successfully"'); assert.strictEqual(searchParams, expectedParams); }); it('should reset filters and show warning message when profile and filters are passed', async () => { // wait until the previous notification is hidden - await page.waitForFunction(`window.model.notification.state === 'hidden'`); + await page.waitForFunction('window.model.notification.state === \'hidden\''); const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; const searchParams = await page.evaluate(() => { - const params = {profile: "physicist", q: '"severity":{"in":"I W E F"}}'}; + const params = { profile: 'physicist', q: '"severity":{"in":"I W E F"}}' }; window.model.parseLocation(params); return window.location.search; }); - await page.waitForFunction(`window.model.notification.state === 'shown'`); - await page.waitForFunction(`window.model.notification.type === 'warning'`); - await page.waitForFunction(`window.model.notification.message === "URL can contain only filters or profile, not both"`); + await page.waitForFunction('window.model.notification.state === \'shown\''); + await page.waitForFunction('window.model.notification.type === \'warning\''); + await page.waitForFunction('window.model.notification.message === "URL can contain only filters or profile, not both"'); assert.strictEqual(searchParams, expectedParams); }); @@ -111,11 +155,11 @@ describe('Filter actions test-suite', async () => { // CI/CD runs on Chromium so this assertion is based on Chromium's JSON engine's error message assert.strictEqual( locationAndNotification.notification.message, - 'Invalid URL filter format: Expected \',\' or \'}\' after property value in JSON at position 27 (line 1 column 28)'); + 'Invalid URL filter format: Expected \',\' or \'}\' after property value in JSON at position 27 (line 1 column 28)', + ); }); it('should update URI with new encoded "match" criteria', async () => { - /* eslint-disable max-len */ const decodedParams = '?q={"hostname":{"match":"\\"%ald_qdip01%"},"severity":{"in":"I W E F"},"level":{"max":1}}'; const expectedParams = '?q={%22hostname%22:{%22match%22:%22%5C%22%25ald_qdip01%25%22},%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; const searchParams = await page.evaluate(() => { @@ -129,7 +173,6 @@ describe('Filter actions test-suite', async () => { }); it('should update URI with new encoded "exclude" criteria', async () => { - /* eslint-disable max-len */ const decodedParams = '?q={"hostname":{"exclude":"\\"%ald_qdip01%"},"severity":{"in":"I W E F"},"level":{"max":1}}'; const expectedParams = '?q={%22hostname%22:{%22exclude%22:%22%5C%22%25ald_qdip01%25%22},%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; const searchParams = await page.evaluate(() => { @@ -192,21 +235,20 @@ describe('Filter actions test-suite', async () => { }); it('should parse no keywords to null', async () => { - const $in = await page.evaluate(() => { - window.model.log.filter.setCriteria('pid', 'in', ''); - return window.model.log.filter.criterias.pid.$in; + const $match = await page.evaluate(() => { + window.model.log.filter.setCriteria('pid', 'match', ''); + return window.model.log.filter.criterias.pid.$match; }); - assert.strictEqual($in, null); + assert.strictEqual($match, null); }); - it('should parse keywords to array', async () => { + it('should parse keywords to array when using "in" operator', async () => { const $in = await page.evaluate(() => { - window.model.log.filter.setCriteria('pid', 'in', '123 456'); - return window.model.log.filter.criterias.pid.$in; + window.model.log.filter.setCriteria('severity', 'in', 'I W E F'); + return window.model.log.filter.criterias.severity.$in; }); - - assert.strictEqual($in.length, 2); - assert.deepStrictEqual($in, ['123', '456']); + assert.strictEqual($in.length, 4); + assert.deepStrictEqual($in, ['I', 'W', 'E', 'F']); }); it('should reset filters and set them again', async () => { @@ -256,8 +298,9 @@ describe('Filter actions test-suite', async () => { }; }); - assert.ok(!severity.$in.includes('D')); - assert.ok(!severity.in.includes('D')); + assert.ok(Array.isArray(severity.$in)); + assert.deepStrictEqual(severity.$in, ['I', 'W', 'E', 'F']); + assert.strictEqual(severity.in, 'I W E F'); }); it('should strip DEBUG from URL when severity is set before level', async () => { @@ -269,8 +312,9 @@ describe('Filter actions test-suite', async () => { }; }); - assert.ok(!severity.$in.includes('D')); - assert.ok(!severity.in.includes('D')); + assert.ok(Array.isArray(severity.$in)); + assert.deepStrictEqual(severity.$in, ['I', 'W', 'E', 'F']); + assert.strictEqual(severity.in, 'I W E F'); }); it('should strip DEBUG from URL when level is set before severity', async () => { @@ -282,8 +326,9 @@ describe('Filter actions test-suite', async () => { }; }); - assert.ok(!severity.$in.includes('D')); - assert.ok(!severity.in.includes('D')); + assert.ok(Array.isArray(severity.$in)); + assert.deepStrictEqual(severity.$in, ['I', 'W', 'E', 'F']); + assert.strictEqual(severity.in, 'I W E F'); }); it('should disable DEBUG button at OPS level', async () => { @@ -309,5 +354,204 @@ describe('Filter actions test-suite', async () => { return !debugBtn?.classList.contains('disabled'); }); }); + + describe('Empty field toggle', async () => { + afterEach(async () => { + await page.evaluate(() => window.model.log.filter.resetCriteria()); + }); + + it('should set matchEmpty to true for a field', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.matchEmpty, true); + assert.strictEqual(result.$matchEmpty, true); + }); + + it('should set excludeEmpty to true for a field', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'excludeEmpty', true); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.excludeEmpty, true); + assert.strictEqual(result.$excludeEmpty, true); + }); + + it('should deactivate excludeEmpty when matchEmpty is toggled on', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'excludeEmpty', true); + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.excludeEmpty, false); + assert.strictEqual(result.$excludeEmpty, false); + assert.strictEqual(result.matchEmpty, true); + assert.strictEqual(result.$matchEmpty, true); + }); + + it('should deactivate matchEmpty when excludeEmpty is toggled on', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + window.model.log.filter.setCriteria('hostname', 'excludeEmpty', true); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.excludeEmpty, true); + assert.strictEqual(result.$excludeEmpty, true); + assert.strictEqual(result.matchEmpty, false); + assert.strictEqual(result.$matchEmpty, false); + }); + + it('should reset matchEmpty and excludeEmpty when criteria are reset', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + window.model.log.filter.setCriteria('hostname', 'excludeEmpty', true); + window.model.log.filter.resetCriteria(); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.matchEmpty, false); + assert.strictEqual(result.$matchEmpty, false); + assert.strictEqual(result.excludeEmpty, false); + assert.strictEqual(result.$excludeEmpty, false); + }); + + it('should include matchEmpty in toObject when set to true', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + return window.model.log.filter.toObject().hostname; + }); + + assert.strictEqual(result.matchEmpty, true); + }); + + it('should not include matchEmpty in toObject when false', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + window.model.log.filter.setCriteria('hostname', 'matchEmpty', false); + return window.model.log.filter.toObject(); + }); + + assert.strictEqual(result.hostname, undefined); + }); + + it('should have active class on toggle button when active', async () => { + const btnSelector = '.table-filters tbody tr:nth-child(2) td:nth-child(2) button.empty-toggle'; + + await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + }); + + await page.waitForFunction((sel) => { + const toggleBtn = document.querySelector(sel); + return toggleBtn?.classList.contains('active'); + }, {}, btnSelector); + }); + + it('should appear on hover and disappear when not hovered', async () => { + const selector = '.table-filters tbody tr:nth-child(2) td:nth-child(2) .filter-input-group'; + const btnSelector = '.table-filters tbody tr:nth-child(2) td:nth-child(2) button.empty-toggle'; + + await page.waitForFunction((sel) => { + const btn = document.querySelector(sel); + return btn && !btn.classList.contains('active'); + }, {}, btnSelector); + + const hiddenByDefault = await page.evaluate( + (sel) => getComputedStyle(document.querySelector(sel)).display === 'none', + btnSelector, + ); + assert.strictEqual(hiddenByDefault, true); + + await page.hover(selector); + + const visibleOnHover = await page.evaluate( + (sel) => getComputedStyle(document.querySelector(sel)).display !== 'none', + btnSelector, + ); + assert.strictEqual(visibleOnHover, true); + + await page.hover('.table-filters tbody tr:nth-child(1)'); + + const hiddenAfterLeave = await page.evaluate( + (sel) => getComputedStyle(document.querySelector(sel)).display === 'none', + btnSelector, + ); + assert.strictEqual(hiddenAfterLeave, true); + }); + + it('should toggle matchEmpty on when match toggle button is clicked', async () => { + const btnSelector = '.table-filters tbody tr:nth-child(2) td:nth-child(2) button.empty-toggle'; + + await page.hover('.table-filters tbody tr:nth-child(2) td:nth-child(2) .filter-input-group'); + await page.click(btnSelector); + + const result = await page.evaluate(() => window.model.log.filter.criterias.hostname); + assert.strictEqual(result.matchEmpty, true); + assert.strictEqual(result.$matchEmpty, true); + }); + + it('should toggle matchEmpty off when match toggle button is clicked again', async () => { + const btnSelector = '.table-filters tbody tr:nth-child(2) td:nth-child(2) button.empty-toggle'; + + await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + }); + + await page.waitForFunction((sel) => document.querySelector(sel)?.classList.contains('active'), {}, btnSelector); + + await page.click(btnSelector); + + const result = await page.evaluate(() => window.model.log.filter.criterias.hostname); + assert.strictEqual(result.matchEmpty, false); + assert.strictEqual(result.$matchEmpty, false); + }); + + it('should restore matchEmpty from fromObject', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.fromObject({ hostname: { matchEmpty: true } }); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.matchEmpty, true); + assert.strictEqual(result.$matchEmpty, true); + }); + + it('should restore excludeEmpty from fromObject', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.fromObject({ hostname: { excludeEmpty: true } }); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.excludeEmpty, true); + assert.strictEqual(result.$excludeEmpty, true); + }); + + it('should include matchEmpty in the URL and restore it on parse', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); + window.model.updateRouteOnModelChange(); + const url = window.location.search; + + const params = { q: decodeURIComponent(url.replace('?q=', '')) }; + window.model.parseLocation(params); + + return { + url, + matchEmpty: window.model.log.filter.criterias.hostname.matchEmpty, + $matchEmpty: window.model.log.filter.criterias.hostname.$matchEmpty, + }; + }); + + assert.ok(result.url.includes('matchEmpty')); + assert.strictEqual(result.matchEmpty, true); + assert.strictEqual(result.$matchEmpty, true); + }); + }); }); }); diff --git a/InfoLogger/test/public/query-mode-mocha.js b/InfoLogger/test/public/query-mode-mocha.js index 0c5797dff..12f796a7c 100644 --- a/InfoLogger/test/public/query-mode-mocha.js +++ b/InfoLogger/test/public/query-mode-mocha.js @@ -20,6 +20,8 @@ const TEXT_FILTER_VALUE_BY_OPERATOR = { until: '2026-01-01T00:00:00.000Z', match: 'some-message', exclude: 'some-message', + matchEmpty: true, + excludeEmpty: true, }; const TEXT_FILTER_FIELD_BY_OPERATOR = { @@ -27,6 +29,8 @@ const TEXT_FILTER_FIELD_BY_OPERATOR = { until: 'timestamp', match: 'message', exclude: 'message', + matchEmpty: 'rolename', + excludeEmpty: 'rolename', }; /**