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 a2f7809fc..a4caca681 100644 --- a/InfoLogger/lib/services/QueryService.js +++ b/InfoLogger/lib/services/QueryService.js @@ -229,20 +229,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 === '$emptyFor') { + // no parameterized value needed for $emptyFor, 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]); } @@ -257,55 +259,57 @@ class QueryService { case '$until': criteria.push(`\`${field}\`<=?`); break; + // $emptyFor is merged into the operator it refers to (match or exclude) when present, + // otherwise it emits its own clause 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].$emptyFor === 'match'; + 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].$emptyFor === 'exclude'; + if (excludeEmpty) { + criteria.push(`(\`${field}\` != '' AND \`${field}\` IS NOT NULL)`); + } break; } + case '$emptyFor': + if (filters[field].$emptyFor === 'match' && !filters[field].$match) { + criteria.push(`(\`${field}\` = '' OR \`${field}\` IS NULL)`); + } else if (filters[field].$emptyFor === 'exclude' && !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 9d2e934a9..b534d6e63 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -133,7 +133,51 @@ footer { border-top: 1px solid var(--color-gray); } .select-btn { background-color: var(--color-gray-button-background); color: var(--color-gray-button); border: 0; border-radius: .25rem; padding: 0em 0.5em; font-size: 1em; font-family: inherit; cursor: pointer; } .select-btn:hover { background-color: var(--color-gray-button-background-hover); color: var(--color-white); } -.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..c90e3ca73 100644 --- a/InfoLogger/public/constants/text-filter-operators.const.js +++ b/InfoLogger/public/constants/text-filter-operators.const.js @@ -15,4 +15,10 @@ /** * 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', + 'emptyFor', +]); diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 009f20bcf..3fbb7f02e 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -130,22 +130,43 @@ 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, emptyFor } = model.log.filter.criterias[field]; + const isClear = !match && !exclude && !emptyFor; + 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, 'emptyFor', null); 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 310007dfd..6ebe2194b 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -24,9 +24,22 @@ 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, + emptyFor: null, + $emptyFor: null, +}); + /** * 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 +73,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 +101,9 @@ export default class LogFilter extends Observable { case 'in': this.criterias[field]['$in'] = value ? value.split(' ') : null; break; + case 'emptyFor': + this.criterias[field]['$emptyFor'] = value === 'match' || value === 'exclude' ? value : null; + break; default: throw new Error('unknown operator'); } @@ -156,7 +175,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 +194,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 +214,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 +259,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 +279,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 +290,28 @@ export default class LogFilter extends Observable { break; } case '$match': { + if (isEmpty(logValue)) { + if (criteria.$emptyFor !== 'match') { + 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)) { + if (criteria.$emptyFor === 'exclude') { + return false; + } + break; + } const criteriaList = criteriaValue.split(separator); if (criteriaList.length > 1) { criteriaValue = criteriaValue.replace(new RegExp(separator, 'g'), '|'); @@ -281,6 +322,13 @@ export default class LogFilter extends Observable { } break; } + case '$emptyFor': + if (criteriaValue === 'match' && !criteria.$match && !isEmpty(logValue)) { + return false; + } else if (criteriaValue === 'exclude' && !criteria.$exclude && isEmpty(logValue)) { + return false; + } + break; case '$since': if (logValue === undefined || parseInfoLoggerDate(logValue) < parseInfoLoggerDate(criteriaValue)) { return false; @@ -339,6 +387,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 +410,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'], diff --git a/InfoLogger/public/logFilter/tableFilters.js b/InfoLogger/public/logFilter/tableFilters.js index bb571395c..9ac2a54c1 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,41 @@ 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 isActive = logModel.filter.criterias[field].emptyFor === command; + const verb = command === 'match' ? 'Match' : 'Exclude'; + const title = `${verb} logs where ${field} is empty`; + + return h('button.btn.empty-toggle', { + className: isActive ? 'active' : '', + title, + onclick: (e) => { + logModel.setCriteria(field, 'emptyFor', isActive ? null : command); + 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 124662017..b5a017b01 100644 --- a/InfoLogger/test/lib/services/mocha-query-service.test.js +++ b/InfoLogger/test/lib/services/mocha-query-service.test.js @@ -192,7 +192,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`>=?', @@ -204,6 +204,82 @@ describe('\'QueryService\' test suite', () => { }); }); + describe('Empty field filters', () => { + it('should skip emptyFor when value is null', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { $emptyFor: null }, + }); + assert.deepStrictEqual(result, { values: [], criteria: [] }); + }); + + it('should generate IS NULL/empty condition when emptyFor is "match" alone', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { $emptyFor: 'match' }, + }); + 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 when emptyFor is "exclude" alone', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { $emptyFor: 'exclude' }, + }); + assert.deepStrictEqual(result.values, []); + const expectedCriteria = '(`hostname` != \'\' AND `hostname` IS NOT NULL)'; + assert.deepStrictEqual(result.criteria, [expectedCriteria]); + }); + + it('should generate OR when $match is set and emptyFor is "match"', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { match: 'test', $match: 'test', $emptyFor: 'match' }, + }); + assert.deepStrictEqual(result.values, ['test']); + const expectedCriteria = '(`hostname` = ? OR `hostname` = \'\' OR `hostname` IS NULL)'; + assert.deepStrictEqual(result.criteria, [expectedCriteria]); + }); + + it('should generate AND when $exclude is set and emptyFor is "exclude"', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { exclude: 'test', $exclude: 'test', $emptyFor: 'exclude' }, + }); + 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 emptyFor criteria when $match is present (defers to $match case)', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { match: 'test', $match: 'test', $emptyFor: 'match' }, + }); + // Should have one combined criterion from $match, not a separate one from emptyFor + assert.strictEqual(result.criteria.length, 1); + assert.ok(result.criteria[0].includes('IS NULL')); + }); + + it('should handle $match with multiple values and emptyFor is "match"', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { match: 'foo bar', $match: 'foo bar', $emptyFor: 'match' }, + }); + 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 emptyFor is "exclude"', () => { + const result = emptySqlDataSource._filtersToSqlConditions({ + hostname: { exclude: 'foo bar', $exclude: 'foo bar', $emptyFor: 'exclude' }, + }); + 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 e7e7aac63..1a58ab32c 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 emptyFor is set to "match"', 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', 'emptyFor', 'match'); + }); + 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 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 emptyFor is set to "exclude"', 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', 'emptyFor', 'exclude'); + }); + 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 allNonEmpty = list.every((log) => !isFieldEmpty(log.rolename)); + assert.ok(list.length > 0); + assert.ok(allNonEmpty); + }); + + it('should receive matching OR empty logs when match is set and emptyFor is "match"', 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', 'emptyFor', 'match'); + }); + 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 is set and emptyFor is "exclude"', 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', 'emptyFor', 'exclude'); + }); + 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..8b98f6482 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', 'emptyFor', 'match'); }); 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, + emptyFor: window.model.log.filter.criterias.hostname.emptyFor, + $emptyFor: window.model.log.filter.criterias.hostname.$emptyFor, + 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.emptyFor, null); + assert.strictEqual(criteria.$emptyFor, null); 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 cf71d231e..4b53486fe 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -16,12 +16,12 @@ 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 @@ -58,6 +58,51 @@ describe('Filter actions test-suite', async () => { 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', + 'emptyFor', + '$emptyFor', + ]; + + 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', 'emptyFor', 'match'))); + 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 const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; @@ -188,21 +233,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 () => { @@ -252,8 +296,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 () => { @@ -265,8 +310,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 () => { @@ -278,8 +324,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 () => { @@ -305,6 +352,197 @@ 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 emptyFor to "match" for a field', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'emptyFor', 'match'); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.emptyFor, 'match'); + assert.strictEqual(result.$emptyFor, 'match'); + }); + + it('should set emptyFor to "exclude" for a field', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'emptyFor', 'exclude'); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.emptyFor, 'exclude'); + assert.strictEqual(result.$emptyFor, 'exclude'); + }); + + it('should reset emptyFor when criteria are reset', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'emptyFor', 'match'); + window.model.log.filter.resetCriteria(); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.emptyFor, null); + assert.strictEqual(result.$emptyFor, null); + }); + + it('should include emptyFor (but not $emptyFor) in toObject when set to match', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'emptyFor', 'match'); + return window.model.log.filter.toObject().hostname; + }); + + assert.strictEqual(result.emptyFor, 'match'); + assert.strictEqual(result.$emptyFor, undefined); + }); + + it('should include emptyFor (but not $emptyFor) in toObject when set to exclude', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'emptyFor', 'exclude'); + return window.model.log.filter.toObject().hostname; + }); + + assert.strictEqual(result.emptyFor, 'exclude'); + assert.strictEqual(result.$emptyFor, undefined); + }); + + it('should not include emptyFor in toObject when it was never set', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'match', 'test'); + return window.model.log.filter.toObject(); + }); + + assert.strictEqual(result.hostname.emptyFor, undefined); + }); + + it('should omit the field entirely from toObject when only emptyFor was set then cleared', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.setCriteria('hostname', 'emptyFor', 'match'); + window.model.log.filter.setCriteria('hostname', 'emptyFor', null); + 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', 'emptyFor', 'match'); + }); + + 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 emptyFor to match 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.emptyFor, 'match'); + assert.strictEqual(result.$emptyFor, 'match'); + }); + + it('should toggle emptyFor 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', 'emptyFor', 'match'); + }); + + 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.emptyFor, null); + assert.strictEqual(result.$emptyFor, null); + }); + + it('should restore emptyFor=match from fromObject', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.fromObject({ hostname: { emptyFor: 'match' } }); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.emptyFor, 'match'); + assert.strictEqual(result.$emptyFor, 'match'); + }); + + it('should restore emptyFor=exclude from fromObject', async () => { + const result = await page.evaluate(() => { + window.model.log.filter.fromObject({ hostname: { emptyFor: 'exclude' } }); + return window.model.log.filter.criterias.hostname; + }); + + assert.strictEqual(result.emptyFor, 'exclude'); + assert.strictEqual(result.$emptyFor, 'exclude'); + }); + + it('should include emptyFor 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', 'emptyFor', 'match'); + window.model.updateRouteOnModelChange(); + const url = window.location.search; + + const params = { q: decodeURIComponent(url.replace('?q=', '')) }; + window.model.parseLocation(params); + + return { + url, + emptyFor: window.model.log.filter.criterias.hostname.emptyFor, + $emptyFor: window.model.log.filter.criterias.hostname.$emptyFor, + }; + }); + + const decodedURI = decodeURIComponent(result.url); + assert.ok(decodedURI.includes('"emptyFor":"match"')); + assert.strictEqual(result.emptyFor, 'match'); + assert.strictEqual(result.$emptyFor, 'match'); + }); + }); }); describe('Level filter select', async () => { diff --git a/InfoLogger/test/public/query-mode-mocha.js b/InfoLogger/test/public/query-mode-mocha.js index 0c5797dff..aeba2d673 100644 --- a/InfoLogger/test/public/query-mode-mocha.js +++ b/InfoLogger/test/public/query-mode-mocha.js @@ -20,6 +20,7 @@ const TEXT_FILTER_VALUE_BY_OPERATOR = { until: '2026-01-01T00:00:00.000Z', match: 'some-message', exclude: 'some-message', + emptyFor: 'match', }; const TEXT_FILTER_FIELD_BY_OPERATOR = { @@ -27,6 +28,7 @@ const TEXT_FILTER_FIELD_BY_OPERATOR = { until: 'timestamp', match: 'message', exclude: 'message', + emptyFor: 'rolename', }; /**