From 82179b27f55fd01ecde04cafdcc72dbcb958c730 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:26:10 +0200 Subject: [PATCH 01/26] [OGUI-1193] Add empty field filter support to SQL query builder Allow filtering logs by empty/null fields via new $matchEmpty and $excludeEmpty operators. Using $matchEmpty on its own matches rows where the field is blank or null; combined with $match it ORs with the empty condition. Using $excludeEmpty on its own filters out empty rows; combined with $exclude it ANDs with the empty condition. --- InfoLogger/lib/services/QueryService.js | 84 ++++++++++++------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/InfoLogger/lib/services/QueryService.js b/InfoLogger/lib/services/QueryService.js index 929e6b30a..462c1ddde 100644 --- a/InfoLogger/lib/services/QueryService.js +++ b/InfoLogger/lib/services/QueryService.js @@ -212,19 +212,22 @@ class QueryService { continue; } 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') { + const separator = field === 'message' ? '\n' : ' '; + + 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 +243,51 @@ 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 { - 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`; + + criteria.push(`NOT(${criteriaArray.map(toExcludeCondition).join(' OR ')})`); + + 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; From aa87f9cdfd3168dab8804e633bc3b32d0302f898 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:13:35 +0200 Subject: [PATCH 02/26] [OGUI-1193] Add matchEmpty/excludeEmpty criteria to LogFilter model Wire up matchEmpty & excludeEmpty operators in setCriteria and the live mode matching logic. --- InfoLogger/public/logFilter/LogFilter.js | 106 ++++++++++++----------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 7e70dd646..c2bdcdfc1 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -27,6 +27,21 @@ import { getDisabledSeverities } from '../constants/log-level-filters.const.js'; * @typedef Criteria * @type {Array.} */ +/** + * 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: '', + exclude: '', + $match: null, + $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 @@ -85,6 +100,12 @@ export default class LogFilter extends Observable { case 'in': this.criterias[field]['$in'] = value ? value.split(' ') : null; break; + case 'matchEmpty': + this.criterias[field]['$matchEmpty'] = value; + break; + case 'excludeEmpty': + this.criterias[field]['$excludeEmpty'] = value; + break; default: throw new Error('unknown operator'); } @@ -249,7 +270,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 +281,22 @@ export default class LogFilter extends Observable { break; } case '$match': { + if ((logValue === undefined || logValue === '') && !criteria.$matchEmpty) { + return false; + } 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 ((logValue === undefined || logValue === '') && criteria.$excludeEmpty) { + return false; + } const criteriaList = criteriaValue.split(separator); if (criteriaList.length > 1) { criteriaValue = criteriaValue.replace(new RegExp(separator, 'g'), '|'); @@ -281,6 +307,16 @@ export default class LogFilter extends Observable { } break; } + case '$matchEmpty': + if (!criteria.$match && logValue !== undefined && logValue !== '') { + return false; + } + break; + case '$excludeEmpty': + if (!criteria.$exclude && (logValue === undefined || logValue === '')) { + return false; + } + break; case '$since': if (logValue === undefined || parseInfoLoggerDate(logValue) < parseInfoLoggerDate(criteriaValue)) { return false; @@ -347,82 +383,50 @@ export default class LogFilter extends Observable { $until: null, }, hostname: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, rolename: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, pid: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, username: { match: '', exclude: '', $match: null, $exclude: null, + matchEmpty: false, + $matchEmpty: false, + excludeEmpty: false, + $excludeEmpty: false, }, system: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, facility: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, detector: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, partition: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, run: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, errcode: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, errline: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, errsource: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, message: { - match: '', - exclude: '', - $match: null, - $exclude: null, + ...makeDefaultMatchExcludeOperators(), }, severity: { in: 'I W E F', From ce86c0ce634da7a27e1425516d7721754691e94f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:36:20 +0200 Subject: [PATCH 03/26] [OGUI-1193] Add empty-filter toggles and UI logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a compact empty-field toggle (∅) to match/exclude filters. Enforce mutual exclusivity of matchEmpty/excludeEmpty in Log.setCriteria. Clear empty filters when clearing filters from the cell context menu. --- InfoLogger/public/app.css | 34 ++++++++- InfoLogger/public/log/cellContextMenu.js | 5 +- InfoLogger/public/logFilter/LogFilter.js | 8 +++ InfoLogger/public/logFilter/tableFilters.js | 77 +++++++++++++-------- 4 files changed, 95 insertions(+), 29 deletions(-) diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index 030491103..3706c3388 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -123,7 +123,39 @@ 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;} +.filter-input-group .form-control { + 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; +} + +td:has(.text-area-for-message) { position: relative; } +.text-area-for-message:focus { width: 400%; height: 10rem !important; top: 0; right: 0; 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/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 009f20bcf..d6026d0ad 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -142,10 +142,13 @@ export const cellContextMenu = (model) => { createMenuItem(iconTrash(), 'danger', 'Clear Filter', () => { model.log.setCriteria(field, isTimestamp ? 'until' : 'exclude', ''); model.log.setCriteria(field, isTimestamp ? 'since' : 'match', ''); + 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), + : !model.log.filter.criterias[field].match && !model.log.filter.criterias[field].exclude + && !model.log.filter.criterias[field].matchEmpty && !model.log.filter.criterias[field].excludeEmpty), ]; }; diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index c2bdcdfc1..574ad33f4 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -115,6 +115,14 @@ export default class LogFilter extends Observable { this.enforceDisabledSeverities(); } + if (operator === 'matchEmpty' || operator === 'excludeEmpty') { + // Ensure that both matchEmpty and excludeEmpty are not active at the same time + const oppositeKey = operator === 'matchEmpty' ? 'excludeEmpty' : 'matchEmpty'; + if (this.criterias[field][oppositeKey]) { + this.criterias[field][oppositeKey] = false; + } + } + this.notify(); return true; } else { diff --git a/InfoLogger/public/logFilter/tableFilters.js b/InfoLogger/public/logFilter/tableFilters.js index bb571395c..657ff9a31 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,40 @@ 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', { + 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(); + }, + 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 key = command === 'match' ? 'matchEmpty' : 'excludeEmpty'; + const isActive = logModel.filter.criterias[field][key]; + return h('button.btn.empty-toggle', { + className: isActive ? 'active' : '', + title: `${command === 'match' ? 'Match' : 'Exclude'} logs where ${field} is empty`, + 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(), - })); + }, '∅'); +}; From a6c5621e643d81af3cd8a8ef8a91ada6d93b4b3e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:49:39 +0200 Subject: [PATCH 04/26] [OGUI-1193] Add tests for empty-field filter SQL logic Adds QueryService tests for handling $matchEmpty and $excludeEmpty. --- .../lib/services/mocha-query-service.test.js | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/InfoLogger/test/lib/services/mocha-query-service.test.js b/InfoLogger/test/lib/services/mocha-query-service.test.js index ff3470ddd..56e786f79 100644 --- a/InfoLogger/test/lib/services/mocha-query-service.test.js +++ b/InfoLogger/test/lib/services/mocha-query-service.test.js @@ -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([]), ''); From 448b170b75037d8764b66d8000ab890315181596 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:25:13 +0200 Subject: [PATCH 05/26] [OGUI-1193] ESLint Fixes --- .../test/public/log-filter-actions-mocha.js | 74 +++++++++---------- 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 21eb8a098..987ba77c8 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,58 @@ 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 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 +108,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 +126,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(() => { From 2d7087008ab93a2c3d955095152aa4797fd45e3b Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:29:58 +0200 Subject: [PATCH 06/26] [OGUI-1193] Reset $-prefixed opposite empty flag too Forgot to reset $-prefixed value too. --- InfoLogger/public/logFilter/LogFilter.js | 1 + 1 file changed, 1 insertion(+) diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 574ad33f4..4706ec894 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -120,6 +120,7 @@ export default class LogFilter extends Observable { const oppositeKey = operator === 'matchEmpty' ? 'excludeEmpty' : 'matchEmpty'; if (this.criterias[field][oppositeKey]) { this.criterias[field][oppositeKey] = false; + this.criterias[field][`$${oppositeKey}`] = false; } } From e98872d62249f736e176de338cb631191b64a68e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:33:23 +0200 Subject: [PATCH 07/26] [OGUI-1193] Update context-menu reset tests to also check empty operators --- InfoLogger/test/public/log-context-menu-mocha.js | 6 ++++++ 1 file changed, 6 insertions(+) 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); }); From a588e18b002a1d887cf0de3d95b0016558d9b4cd Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:44:01 +0200 Subject: [PATCH 08/26] [OGUI-1193] Add tests for empty field toggle Add a new 'Empty field toggle' test suite. The tests cover its CSS properties, its behaviour when toggled and the disallowing of the toggle to be on for the same field at the same time. --- .../test/public/log-filter-actions-mocha.js | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 987ba77c8..887700b9e 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -305,5 +305,206 @@ 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) => { + return 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); + }); + }); }); }); From 49bb108500d02f70519d925b43d6e35d6123a256 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:46:22 +0200 Subject: [PATCH 09/26] [OGUI-1193] Add tests for empty Toggle in LiveMode Adds a new test suite covering empty-field filter behaviour for in live mode. The suite includes tests for matchEmpty, excludeEmpty, combined match+matchEmpty, and combined exclude+excludeEmpty. --- InfoLogger/test/public/live-mode-mocha.js | 72 ++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 0b7cef97e..508e70d37 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -16,8 +16,8 @@ const assert = require('assert'); const test = require('../mocha-index'); describe('Live Mode test-suite', async () => { - let baseUrl; - let page; + let baseUrl = null; + let page = null; before(async () => { ({ helpers: { baseUrl }, page } = test); }); @@ -116,6 +116,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) => !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) => 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) => !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: 5000 }); + + const list = await page.evaluate(() => window.model.log.list); + const allValid = list.every((log) => 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'); From 019362dc61ff809d5a508d74553eb7682d380862 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:58:36 +0200 Subject: [PATCH 10/26] [OGUI-1193] Fix live-mode due to failing test notifying of issue Break out of the case immediately instead of falling through to the regex for an undefined value which will then fail and then not include it. --- InfoLogger/public/logFilter/LogFilter.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 4706ec894..a00edefa8 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -290,8 +290,11 @@ export default class LogFilter extends Observable { break; } case '$match': { - if ((logValue === undefined || logValue === '') && !criteria.$matchEmpty) { - return false; + if (logValue === undefined || logValue === '') { + if (!criteria.$matchEmpty) { + return false; + } + break; } const criteriaList = criteriaValue.split(separator); if (criteriaList.length > 1) { From e8da9426b7cb96263609105bef02232044094c2f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:09:28 +0200 Subject: [PATCH 11/26] [OGUI-1193] - Testing CI failing test fix --- InfoLogger/test/public/live-mode-mocha.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 508e70d37..df5598290 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -175,7 +175,7 @@ describe('Live Mode test-suite', async () => { 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 }); + await page.waitForFunction('window.model.log.list.length > 0', { timeout: 15000 }); const list = await page.evaluate(() => window.model.log.list); const allValid = list.every((log) => log.rolename && log.rolename !== 'mon-DA-PHS-0'); From a81f5a767105b5c0510ac3e05df1c7cf457ec38d Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:13:01 +0200 Subject: [PATCH 12/26] [OGUI-1193] - Adjust now passing test back a bit to be useful --- InfoLogger/test/public/live-mode-mocha.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index df5598290..a1219fa53 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -175,7 +175,7 @@ describe('Live Mode test-suite', async () => { window.model.log.filter.setCriteria('rolename', 'excludeEmpty', true); }); await page.evaluate(() => window.model.log.liveStart()); - await page.waitForFunction('window.model.log.list.length > 0', { timeout: 15000 }); + await page.waitForFunction('window.model.log.list.length > 5', { timeout: 15000 }); const list = await page.evaluate(() => window.model.log.list); const allValid = list.every((log) => log.rolename && log.rolename !== 'mon-DA-PHS-0'); From 7afc79983f18c52a09bb598a95be27f62c9e06cb Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:42:22 +0200 Subject: [PATCH 13/26] [OGUI-1193] Improve maintainability --- InfoLogger/lib/services/QueryService.js | 3 +-- InfoLogger/public/app.css | 2 +- InfoLogger/public/logFilter/LogFilter.js | 32 ++++++++++++----------- InfoLogger/test/public/live-mode-mocha.js | 10 ++++--- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/InfoLogger/lib/services/QueryService.js b/InfoLogger/lib/services/QueryService.js index 462c1ddde..e52c37456 100644 --- a/InfoLogger/lib/services/QueryService.js +++ b/InfoLogger/lib/services/QueryService.js @@ -211,13 +211,12 @@ class QueryService { if (!filters[field]) { continue; } + const separator = field === 'message' ? '\n' : ' '; for (const operator in filters[field]) { if (filters[field][operator] === null || filters[field][operator] === false || !operator.includes('$')) { continue; } - const separator = field === 'message' ? '\n' : ' '; - if (operator === '$matchEmpty' || operator === '$excludeEmpty') { // no parameterized value needed for $matchEmpty or $excludeEmpty, the SQL is static } else if (operator === '$since' || operator === '$until') { diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index e21691f1b..7fe5a0602 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -124,7 +124,7 @@ 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; } -.filter-input-group { display: flex;} +.filter-input-group { display: flex; } .filter-input-group .form-control { border-radius: .25rem; z-index: 1; diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index a00edefa8..b8abfe96d 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -101,10 +101,10 @@ export default class LogFilter extends Observable { this.criterias[field]['$in'] = value ? value.split(' ') : null; break; case 'matchEmpty': - this.criterias[field]['$matchEmpty'] = value; + this.criterias[field]['$matchEmpty'] = Boolean(value); break; case 'excludeEmpty': - this.criterias[field]['$excludeEmpty'] = value; + this.criterias[field]['$excludeEmpty'] = Boolean(value); break; default: throw new Error('unknown operator'); @@ -115,8 +115,8 @@ export default class LogFilter extends Observable { this.enforceDisabledSeverities(); } - if (operator === 'matchEmpty' || operator === 'excludeEmpty') { // Ensure that both matchEmpty and excludeEmpty are not active at the same time + if (value && (operator === 'matchEmpty' || operator === 'excludeEmpty')) { const oppositeKey = operator === 'matchEmpty' ? 'excludeEmpty' : 'matchEmpty'; if (this.criterias[field][oppositeKey]) { this.criterias[field][oppositeKey] = false; @@ -268,6 +268,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 {*} 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.) @@ -290,7 +299,7 @@ export default class LogFilter extends Observable { break; } case '$match': { - if (logValue === undefined || logValue === '') { + if (isEmpty(logValue)) { if (!criteria.$matchEmpty) { return false; } @@ -306,7 +315,7 @@ export default class LogFilter extends Observable { break; } case '$exclude': { - if ((logValue === undefined || logValue === '') && criteria.$excludeEmpty) { + if (isEmpty(logValue) && criteria.$excludeEmpty) { return false; } const criteriaList = criteriaValue.split(separator); @@ -320,12 +329,12 @@ export default class LogFilter extends Observable { break; } case '$matchEmpty': - if (!criteria.$match && logValue !== undefined && logValue !== '') { + if (!criteria.$match && !isEmpty(logValue)) { return false; } break; case '$excludeEmpty': - if (!criteria.$exclude && (logValue === undefined || logValue === '')) { + if (!criteria.$exclude && isEmpty(logValue)) { return false; } break; @@ -404,14 +413,7 @@ export default class LogFilter extends Observable { ...makeDefaultMatchExcludeOperators(), }, username: { - match: '', - exclude: '', - $match: null, - $exclude: null, - matchEmpty: false, - $matchEmpty: false, - excludeEmpty: false, - $excludeEmpty: false, + ...makeDefaultMatchExcludeOperators(), }, system: { ...makeDefaultMatchExcludeOperators(), diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index a1219fa53..5e5fb98a4 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -15,6 +15,8 @@ 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 = null; let page = null; @@ -128,7 +130,7 @@ describe('Live Mode test-suite', async () => { 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) => !log.rolename); + const allEmpty = list.every((log) => isFieldEmpty(log.rolename)); assert.ok(list.length > 0); assert.ok(allEmpty); }); @@ -144,7 +146,7 @@ describe('Live Mode test-suite', async () => { 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) => log.rolename); + const allNonEmpty = list.every((log) => !isFieldEmpty(log.rolename)); assert.ok(list.length > 0); assert.ok(allNonEmpty); }); @@ -161,7 +163,7 @@ describe('Live Mode test-suite', async () => { 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) => !log.rolename || log.rolename === 'mon-DA-PHS-0'); + const allValid = list.every((log) => isFieldEmpty(log.rolename) || log.rolename === 'mon-DA-PHS-0'); assert.ok(list.length > 0); assert.ok(allValid); }); @@ -178,7 +180,7 @@ describe('Live Mode test-suite', async () => { await page.waitForFunction('window.model.log.list.length > 5', { timeout: 15000 }); const list = await page.evaluate(() => window.model.log.list); - const allValid = list.every((log) => log.rolename && log.rolename !== 'mon-DA-PHS-0'); + const allValid = list.every((log) => !isFieldEmpty(log.rolename) && log.rolename !== 'mon-DA-PHS-0'); assert.ok(list.length > 0); assert.ok(allValid); }); From e1270e54ad43e1d65ab0ef3d78c66e5172cf2a16 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:44:29 +0200 Subject: [PATCH 14/26] [OGUI-1193] Wrap OR criteria to preserve precedence Wraps OR-style match criteria in parentheses when multiple criteria are present so that OR expressions don't bind looser than AND in the WHERE clause. --- InfoLogger/lib/services/QueryService.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InfoLogger/lib/services/QueryService.js b/InfoLogger/lib/services/QueryService.js index e52c37456..8ffd4f53e 100644 --- a/InfoLogger/lib/services/QueryService.js +++ b/InfoLogger/lib/services/QueryService.js @@ -255,6 +255,9 @@ class QueryService { 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 { criteria.push(matchStr); } From eff5c2ffe9dad972607425453900f87ca23ce146 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:54:46 +0200 Subject: [PATCH 15/26] [OGUI-1193] Fix ESLint errors/warnings --- InfoLogger/lib/controller/ConfigController.js | 2 +- .../lib/middleware/serviceAvailabilityCheck.middleware.js | 4 ++-- InfoLogger/lib/utils/fromSqlToNativeError.js | 2 +- InfoLogger/public/common/utils.js | 6 +++--- InfoLogger/public/logFilter/LogFilter.js | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) 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/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/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/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index b8abfe96d..63873c69d 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -24,7 +24,7 @@ import { getDisabledSeverities } from '../constants/log-level-filters.const.js'; */ /** - * @typedef Criteria * @type {Array.} + * @typedef {Array.} Criteria */ /** @@ -115,7 +115,7 @@ export default class LogFilter extends Observable { this.enforceDisabledSeverities(); } - // Ensure that both matchEmpty and excludeEmpty are not active at the same time + // Ensure that both matchEmpty and excludeEmpty are not active at the same time if (value && (operator === 'matchEmpty' || operator === 'excludeEmpty')) { const oppositeKey = operator === 'matchEmpty' ? 'excludeEmpty' : 'matchEmpty'; if (this.criterias[field][oppositeKey]) { @@ -223,7 +223,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() { /** @@ -270,7 +270,7 @@ export default class LogFilter extends Observable { /** * Whether a log field value is considered empty for matchEmpty/excludeEmpty purposes. - * @param {*} logValue - value of the log field + * @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) { From dfaf45195f1670d62c7c019bb5fbcde2e92a09f2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:02:57 +0200 Subject: [PATCH 16/26] [OGUI-1193] Separate timestamp and text field handling in context menu Splits the context menu builder into distinct if branches for timestamp and text fields, removing the complicated final return and only setting match/excludeEmpty on correct fields. --- InfoLogger/public/log/cellContextMenu.js | 39 ++++++++++++++++++------ 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index d6026d0ad..e3f2240c3 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -130,25 +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 - && !model.log.filter.criterias[field].matchEmpty && !model.log.filter.criterias[field].excludeEmpty), + }, isClear), ]; }; From e4a61340ae020ccb4f5bd977f95268fe969a9234 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:03:18 +0200 Subject: [PATCH 17/26] [OGUI-1193] Remove redundant check --- InfoLogger/public/logFilter/LogFilter.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 63873c69d..94c04bb65 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -203,12 +203,8 @@ export default class LogFilter extends Observable { */ 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) { + if (disabled.length === 0 || !current) { return; } From e2195ebc804856922855b3c99f1b0eadef88a53f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:03:52 +0200 Subject: [PATCH 18/26] [OGUI-1193] Update a comment to be more informative --- InfoLogger/public/logFilter/LogFilter.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 94c04bb65..15fd7a39b 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -115,7 +115,10 @@ export default class LogFilter extends Observable { this.enforceDisabledSeverities(); } - // Ensure that both matchEmpty and excludeEmpty are not active at the same time + /* Ensure that both matchEmpty and excludeEmpty are not active at the same time. + * If rules accumulate then a helper function or rule engine could be made + * To handle this logic and make it more maintainable + */ if (value && (operator === 'matchEmpty' || operator === 'excludeEmpty')) { const oppositeKey = operator === 'matchEmpty' ? 'excludeEmpty' : 'matchEmpty'; if (this.criterias[field][oppositeKey]) { From 619cdff19854b655ab9473438ab145f236c6067e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:07:30 +0200 Subject: [PATCH 19/26] [OGUI-1193] Validate operator in setCriteria and update tests Add validation in LogFilter.setCriteria to throw an error when attempting to set a non-existent operator for a field. This prevents accidental creation/modification of the incorrect operator on a field and enforces known operator sets. Update test suite to reflect operator expectations. Initialise and assert available operators per field, add test to check error is thrown for invalid operator usage. Adjust parsing tests to use the correct operators. --- InfoLogger/public/logFilter/LogFilter.js | 4 ++ .../test/public/log-filter-actions-mocha.js | 70 +++++++++++++++---- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 15fd7a39b..1a584b071 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -76,6 +76,10 @@ export default class LogFilter extends Observable { */ setCriteria(field, operator, value) { if (this.criterias[field][operator] !== value) { + if (!(operator in this.criterias[field])) { + throw new Error(`unknown operator ${operator} for ${field}`); + } + this.criterias[field][operator] = value; // auto-complete other properties / parse switch (operator) { diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 887700b9e..7e000c286 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -58,6 +58,53 @@ 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', + 'exclude', + '$match', + '$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 const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; @@ -188,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 () => { @@ -453,9 +499,7 @@ describe('Filter actions test-suite', async () => { window.model.log.filter.setCriteria('hostname', 'matchEmpty', true); }); - await page.waitForFunction((sel) => { - return document.querySelector(sel)?.classList.contains('active'); - }, {}, btnSelector); + await page.waitForFunction((sel) => document.querySelector(sel)?.classList.contains('active'), {}, btnSelector); await page.click(btnSelector); From c76f282b88fb148465950d4f4cfb9dcbc13f35c1 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:08:03 +0200 Subject: [PATCH 20/26] [OGUI-1193] Adjust live-mode test timeouts Tweak Puppeteer waitForFunction timeouts to stabilise local and CI. --- InfoLogger/test/public/live-mode-mocha.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 5e5fb98a4..321b8dbee 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -66,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) @@ -177,7 +177,7 @@ describe('Live Mode test-suite', async () => { 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: 15000 }); + 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'); From 26fb7a1eb14202cc71bd441961794d78920d4349 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:20:36 +0200 Subject: [PATCH 21/26] [OGUI-1193] Fix CI test with incorrect timeout --- InfoLogger/test/public/live-mode-mocha.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 321b8dbee..174df7ce6 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -177,7 +177,7 @@ describe('Live Mode test-suite', async () => { 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 }); + 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'); From eca180fa3f7dabae6b929058da6859ea7ddcf631 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:20:16 +0200 Subject: [PATCH 22/26] Add matchEmpty/excludeEmpty and refactor filters Wire match/excludeEmpty to all appropriate places and make hasActiveTextFilters support boolean operators. Validate operator existence in setCriteria *before* checking it has different value. Use a helper for ensuring matchEmpty/excludeEmpty are exclusive and move to within the switchcase to keep operator logic together. Refactor resetCriteria to generate text fields from a single list. Fix SQL NOT grouping in QueryService to parenthesise or-ed conditions properly. Update tests to reflect the corrected SQL formatting. --- InfoLogger/lib/services/QueryService.js | 6 +- .../constants/text-filter-operators.const.js | 9 +- InfoLogger/public/logFilter/LogFilter.js | 97 ++++++++----------- InfoLogger/public/logFilter/tableFilters.js | 10 +- .../lib/services/mocha-query-service.test.js | 4 +- 5 files changed, 62 insertions(+), 64 deletions(-) diff --git a/InfoLogger/lib/services/QueryService.js b/InfoLogger/lib/services/QueryService.js index 8ffd4f53e..55303198d 100644 --- a/InfoLogger/lib/services/QueryService.js +++ b/InfoLogger/lib/services/QueryService.js @@ -271,7 +271,11 @@ class QueryService { ? `\`${field}\` LIKE (?) AND \`${field}\` IS NOT NULL` : `\`${field}\` = ? AND \`${field}\` IS NOT NULL`; - criteria.push(`NOT(${criteriaArray.map(toExcludeCondition).join(' OR ')})`); + 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) { 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/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 1a584b071..06e1ea750 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -75,11 +75,10 @@ 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) { - if (!(operator in this.criterias[field])) { - throw new Error(`unknown operator ${operator} for ${field}`); - } - this.criterias[field][operator] = value; // auto-complete other properties / parse switch (operator) { @@ -106,9 +105,11 @@ export default class LogFilter extends Observable { 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'); @@ -119,18 +120,6 @@ export default class LogFilter extends Observable { this.enforceDisabledSeverities(); } - /* Ensure that both matchEmpty and excludeEmpty are not active at the same time. - * If rules accumulate then a helper function or rule engine could be made - * To handle this logic and make it more maintainable - */ - if (value && (operator === 'matchEmpty' || operator === 'excludeEmpty')) { - const oppositeKey = operator === 'matchEmpty' ? 'excludeEmpty' : 'matchEmpty'; - if (this.criterias[field][oppositeKey]) { - this.criterias[field][oppositeKey] = false; - this.criterias[field][`$${oppositeKey}`] = false; - } - } - this.notify(); return true; } else { @@ -193,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); + })); } /** @@ -209,9 +201,12 @@ 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); const current = this.criterias.severity.$in; - if (disabled.length === 0 || !current) { + if (!current) { + return; + } + const disabled = getDisabledSeverities(this.criterias.level.max); + if (disabled.length === 0) { return; } @@ -399,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: '', @@ -406,45 +417,7 @@ export default class LogFilter extends Observable { $since: null, $until: null, }, - hostname: { - ...makeDefaultMatchExcludeOperators(), - }, - rolename: { - ...makeDefaultMatchExcludeOperators(), - }, - pid: { - ...makeDefaultMatchExcludeOperators(), - }, - username: { - ...makeDefaultMatchExcludeOperators(), - }, - system: { - ...makeDefaultMatchExcludeOperators(), - }, - facility: { - ...makeDefaultMatchExcludeOperators(), - }, - detector: { - ...makeDefaultMatchExcludeOperators(), - }, - partition: { - ...makeDefaultMatchExcludeOperators(), - }, - run: { - ...makeDefaultMatchExcludeOperators(), - }, - errcode: { - ...makeDefaultMatchExcludeOperators(), - }, - errline: { - ...makeDefaultMatchExcludeOperators(), - }, - errsource: { - ...makeDefaultMatchExcludeOperators(), - }, - message: { - ...makeDefaultMatchExcludeOperators(), - }, + ...Object.fromEntries(TEXT_FIELDS.map((field) => [field, makeDefaultMatchExcludeOperators()])), severity: { in: 'I W E F', $in: ['I', 'W', 'E', 'F'], @@ -456,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 657ff9a31..ba712f6a4 100644 --- a/InfoLogger/public/logFilter/tableFilters.js +++ b/InfoLogger/public/logFilter/tableFilters.js @@ -184,11 +184,17 @@ const createTextAreaField = (model, field, command, tabIndex) => ])); const createEmptyToggle = (logModel, field, command) => { - const key = command === 'match' ? 'matchEmpty' : 'excludeEmpty'; + 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: `${command === 'match' ? 'Match' : 'Exclude'} logs where ${field} is empty`, + title, onclick: (e) => { logModel.setCriteria(field, key, !isActive); e.target.blur(); diff --git a/InfoLogger/test/lib/services/mocha-query-service.test.js b/InfoLogger/test/lib/services/mocha-query-service.test.js index 56e786f79..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`>=?', @@ -280,7 +280,7 @@ describe('\'QueryService\' test suite', () => { }); assert.deepStrictEqual(result.values, ['foo', 'bar']); assert.deepStrictEqual(result.criteria, [ - '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))', '(`hostname` != \'\' AND `hostname` IS NOT NULL)', ]); }); From affb6f23564a507395b26031ceb91d31dc802fc3 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:30:28 +0200 Subject: [PATCH 23/26] [OGUI-1193] Add matchEmpty/excludeEmpty test constants --- InfoLogger/test/public/query-mode-mocha.js | 4 ++++ 1 file changed, 4 insertions(+) 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', }; /** From 42cf5576aeb5bb697338c14291c6196515a3d83d Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:17:30 +0200 Subject: [PATCH 24/26] [OGUI-1193] Fix textarea incorrect height and with issues --- InfoLogger/public/app.css | 18 +++++++++++++++--- InfoLogger/public/logFilter/tableFilters.js | 1 - 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index 7fe5a0602..666573210 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -124,8 +124,11 @@ 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; } -.filter-input-group { display: flex; } +.filter-input-group { display: flex; + position: relative; +} .filter-input-group .form-control { + height: 2em; border-radius: .25rem; z-index: 1; } @@ -153,8 +156,17 @@ footer { border-top: 1px solid var(--color-gray); } display: block; } -td:has(.text-area-for-message) { position: relative; } -.text-area-for-message:focus { width: 400%; height: 10rem !important; top: 0; right: 0; position: absolute; z-index: 10; border-radius: .25rem; } +.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; } diff --git a/InfoLogger/public/logFilter/tableFilters.js b/InfoLogger/public/logFilter/tableFilters.js index ba712f6a4..2ece1067a 100644 --- a/InfoLogger/public/logFilter/tableFilters.js +++ b/InfoLogger/public/logFilter/tableFilters.js @@ -160,7 +160,6 @@ const createInputField = (logModel, field, command, tabIndex = 1) => const createTextAreaField = (model, field, command, tabIndex) => h('td', h('.filter-input-group', [ h('textarea.form-control.text-area-for-message', { - style: 'height:2em; resize: none;', tabIndex, placeholder: !model.messageFocused ? '' From 3db3c6ac7a59fec2b92df610f579119f52c81005 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:18:04 +0200 Subject: [PATCH 25/26] [OGUI-1193] Keep order consistent between $ and non-$ operators --- InfoLogger/public/logFilter/LogFilter.js | 2 +- InfoLogger/test/public/log-filter-actions-mocha.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 06e1ea750..e4522d903 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -33,8 +33,8 @@ import { getDisabledSeverities } from '../constants/log-level-filters.const.js'; */ const makeDefaultMatchExcludeOperators = () => ({ match: '', - exclude: '', $match: null, + exclude: '', $exclude: null, matchEmpty: false, $matchEmpty: false, diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 7e000c286..5369a8c23 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -70,8 +70,8 @@ describe('Filter actions test-suite', async () => { const TEXT_OPS = [ 'match', - 'exclude', '$match', + 'exclude', '$exclude', 'matchEmpty', '$matchEmpty', From 3de3f92193e891ba902019b2577421bd126e8ac1 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:49:56 +0200 Subject: [PATCH 26/26] [OGUI-1193] Remove includes when checking severity Using includes does not catch if $in has been swapped with in. --- .../test/public/log-filter-actions-mocha.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 5369a8c23..c203daa5e 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -298,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 () => { @@ -311,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 () => { @@ -324,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 () => {