diff --git a/tests/unit/handlers/alerts.handlers.test.js b/tests/unit/handlers/alerts.handlers.test.js index 55d767e..9a05c1d 100644 --- a/tests/unit/handlers/alerts.handlers.test.js +++ b/tests/unit/handlers/alerts.handlers.test.js @@ -10,7 +10,11 @@ const { buildSeveritySummary, flattenHistoryAlert } = require('../../../workers/lib/server/handlers/alerts.handlers') -const { matchesFilter, deduplicateAlerts } = require('../../../workers/lib/utils') +const { validateFilter, applyMongoFilter, combineAnd, deduplicateAlerts } = require('../../../workers/lib/utils') +const { + SITE_ALERTS_FILTER_FIELDS, + ALERTS_FILTER_OPERATORS +} = require('../../../workers/lib/constants') const { createMockCtxWithOrks } = require('../helpers/mockHelpers') // ==================== extractAlertsFromThings Tests ==================== @@ -21,7 +25,7 @@ test('extractAlertsFromThings - extracts alerts with device info', (t) => { id: 'miner-1', type: 'miner', code: 'S19', - info: { container: 'container-A' }, + info: { container: 'container-A', pos: '1-2_c3' }, last: { alerts: [ { severity: 'high', name: 'Fan failure' }, @@ -37,6 +41,7 @@ test('extractAlertsFromThings - extracts alerts with device info', (t) => { t.is(result[0].type, 'miner', 'should enrich with device type') t.is(result[0].code, 'S19', 'should enrich with device code') t.is(result[0].container, 'container-A', 'should enrich with container') + t.is(result[0].position, '1-2_c3', 'should enrich with position') t.is(result[0].severity, 'high', 'should preserve alert severity') }) @@ -67,28 +72,82 @@ test('extractAlertsFromThings - skips invalid alert entries', (t) => { t.is(result.length, 1, 'should only include valid object alerts') }) -// ==================== matchesFilter Tests ==================== +// ==================== validateFilter Tests ==================== + +test('validateFilter - returns {} for null/undefined', (t) => { + t.alike(validateFilter(null, SITE_ALERTS_FILTER_FIELDS, ALERTS_FILTER_OPERATORS), {}, 'null -> {}') + t.alike(validateFilter(undefined, SITE_ALERTS_FILTER_FIELDS, ALERTS_FILTER_OPERATORS), {}, 'undefined -> {}') +}) + +test('validateFilter - passes through scalar equality', (t) => { + const out = validateFilter({ type: 'miner' }, SITE_ALERTS_FILTER_FIELDS, ALERTS_FILTER_OPERATORS) + t.alike(out, { type: 'miner' }, 'scalar stays as equality') +}) + +test('validateFilter - normalises bare array to $in', (t) => { + const out = validateFilter({ severity: ['high', 'critical'] }, SITE_ALERTS_FILTER_FIELDS, ALERTS_FILTER_OPERATORS) + t.alike(out, { severity: { $in: ['high', 'critical'] } }, 'array -> $in') +}) + +test('validateFilter - allows whitelisted operators ($ne for operational)', (t) => { + const out = validateFilter({ type: { $ne: 'miner' } }, SITE_ALERTS_FILTER_FIELDS, ALERTS_FILTER_OPERATORS) + t.alike(out, { type: { $ne: 'miner' } }, 'keeps $ne') +}) + +test('validateFilter - throws on disallowed field', (t) => { + t.exception( + () => validateFilter({ secret: 'x' }, SITE_ALERTS_FILTER_FIELDS, ALERTS_FILTER_OPERATORS), + /ERR_INVALID_FILTER/, + 'unknown field is rejected' + ) +}) + +test('validateFilter - throws on disallowed operator', (t) => { + t.exception( + () => validateFilter({ message: { $regex: '.*' } }, SITE_ALERTS_FILTER_FIELDS, ALERTS_FILTER_OPERATORS), + /ERR_INVALID_FILTER/, + '$regex is not allowed' + ) +}) + +test('validateFilter - throws when $in value is not an array', (t) => { + t.exception( + () => validateFilter({ type: { $in: 'miner' } }, SITE_ALERTS_FILTER_FIELDS, ALERTS_FILTER_OPERATORS), + /ERR_INVALID_FILTER/, + '$in requires an array' + ) +}) + +// ==================== applyMongoFilter Tests ==================== -test('matchesFilter - returns true when no filter', (t) => { - t.ok(matchesFilter({ severity: 'high' }, null, ['severity']), 'null filter should match') - t.ok(matchesFilter({ severity: 'high' }, undefined, ['severity']), 'undefined filter should match') +test('applyMongoFilter - no-op for empty filter', (t) => { + const items = [{ severity: 'high' }, { severity: 'low' }] + t.is(applyMongoFilter(items, {}).length, 2, 'empty filter returns all') }) -test('matchesFilter - matches exact value', (t) => { - const item = { severity: 'high', type: 'miner' } - t.ok(matchesFilter(item, { severity: 'high' }, ['severity', 'type']), 'should match') - t.ok(!matchesFilter(item, { severity: 'low' }, ['severity', 'type']), 'should not match') +test('applyMongoFilter - equality and $in', (t) => { + const items = [{ severity: 'high' }, { severity: 'low' }, { severity: 'critical' }] + t.is(applyMongoFilter(items, { severity: 'high' }).length, 1, 'equality matches one') + t.is(applyMongoFilter(items, { severity: { $in: ['high', 'critical'] } }).length, 2, '$in matches two') }) -test('matchesFilter - matches array values', (t) => { - const item = { severity: 'high' } - t.ok(matchesFilter(item, { severity: ['high', 'critical'] }, ['severity']), 'should match when in array') - t.ok(!matchesFilter(item, { severity: ['low', 'medium'] }, ['severity']), 'should not match when not in array') +test('applyMongoFilter - $ne (operational = all except miner)', (t) => { + const items = [{ type: 'miner' }, { type: 'dcs-siemens' }, { type: 'powermeter' }] + const operational = applyMongoFilter(items, { type: { $ne: 'miner' } }) + t.is(operational.length, 2, 'excludes miner') + t.absent(operational.find(a => a.type === 'miner'), 'no miner alerts') }) -test('matchesFilter - ignores fields not in allowedFields', (t) => { - const item = { severity: 'high', secret: 'value' } - t.ok(matchesFilter(item, { secret: 'wrong' }, ['severity']), 'should ignore non-allowed fields') +// ==================== combineAnd Tests ==================== + +test('combineAnd - drops empty operands', (t) => { + t.alike(combineAnd({ a: 1 }, null), { a: 1 }, 'nil right -> left') + t.alike(combineAnd({}, { b: 2 }), { b: 2 }, 'empty left -> right') + t.alike(combineAnd({}, null), {}, 'both empty -> {}') +}) + +test('combineAnd - wraps two non-empty queries in $and', (t) => { + t.alike(combineAnd({ a: 1 }, { b: 2 }), { $and: [{ a: 1 }, { b: 2 }] }, 'AND of both') }) // ==================== matchesSearch Tests ==================== @@ -351,7 +410,7 @@ test('flattenHistoryAlert - flattens nested thing structure', (t) => { const result = flattenHistoryAlert(alert) t.is(result.deviceId, 'miner-1', 'should flatten thing.id to deviceId') - t.is(result.deviceType, 'miner-am-s19xp', 'should flatten thing.type to deviceType') + t.is(result.type, 'miner-am-s19xp', 'should flatten thing.type to type') t.is(result.code, 'AM-S19XP-0104', 'should flatten thing.code to code') t.is(result.container, 'cont-A', 'should flatten thing.info.container to container') t.is(result.position, '1-2_c3', 'should flatten thing.info.pos to position') @@ -611,6 +670,16 @@ test('getSiteAlerts - filters by multiple device tags (array)', async (t) => { t.is(result.total, 2, 'should match both tags') }) +test('getSiteAlerts - searches by alert name', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => [ + { id: 'm-1', type: 'miner', code: 'S19', info: { container: 'cont-A' }, last: { alerts: [{ severity: 'high', name: 'hashrate_low' }] } }, + { id: 'm-2', type: 'miner', code: 'S21', info: { container: 'cont-B' }, last: { alerts: [{ severity: 'low', name: 'temp_warning' }] } } + ]) + const result = await getSiteAlerts(mockCtx, { query: { search: 'hashrate' } }) + t.is(result.total, 1, 'should match by alert name') + t.is(result.alerts[0].name, 'hashrate_low', 'should return the hashrate alert') +}) + test('getSiteAlerts - searches by device tag (message)', async (t) => { const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsThings()) const mockReq = { query: { search: 'fit-7514' } } @@ -658,3 +727,154 @@ test('getAlertsHistory - searches by device tag (message)', async (t) => { t.is(result.total, 1, 'should find one history alert by tag') t.is(result.alerts[0].message, 'FIT-7513', 'should return the FIT-7513 history alert') }) + +// ==================== miner vs operational split ==================== + +const mixedThings = () => [ + { id: 'miner-1', type: 'miner', code: 'S19', info: { container: 'cont-A' }, last: { alerts: [{ severity: 'high', name: 'hashrate_low' }] } }, + { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', info: { container: 'cont-A' }, last: { alerts: [{ severity: 'critical', name: 'flow_alarm' }] } }, + { id: 'pm-1', type: 'powermeter', code: 'PM', info: { container: 'cont-B' }, last: { alerts: [{ severity: 'low', name: 'power_drift' }] } } +] + +test('getSiteAlerts - miner alerts only (type equality)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => mixedThings()) + const mockReq = { query: { filter: JSON.stringify({ type: 'miner' }) } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 1, 'should keep only miner alerts') + t.is(result.alerts[0].type, 'miner', 'should be a miner alert') +}) + +test('getSiteAlerts - operational alerts (type $ne miner)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => mixedThings()) + const mockReq = { query: { filter: JSON.stringify({ type: { $ne: 'miner' } }) } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 2, 'should keep all non-miner alerts') + t.absent(result.alerts.find(a => a.type === 'miner'), 'should exclude miner alerts') +}) + +test('getSiteAlerts - throws on invalid filter field', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => mixedThings()) + const mockReq = { query: { filter: JSON.stringify({ bogus: 'x' }) } } + + await t.exception(getSiteAlerts(mockCtx, mockReq), /ERR_INVALID_FILTER/, 'rejects unknown field') +}) + +const mixedHistory = () => [ + makeHistoryAlert('m1', 1000, 'high', { type: 'miner' }), + makeHistoryAlert('d1', 2000, 'critical', { type: 'dcs-siemens' }), + makeHistoryAlert('p1', 3000, 'low', { type: 'powermeter' }) +] + +test('getAlertsHistory - miner alerts only (type equality)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => mixedHistory()) + const mockReq = { query: { start: 1, end: 5000, filter: JSON.stringify({ type: 'miner' }) } } + + const result = await getAlertsHistory(mockCtx, mockReq) + t.is(result.total, 1, 'should keep only miner alerts') + t.is(result.alerts[0].type, 'miner', 'should be a miner alert') +}) + +test('getAlertsHistory - operational alerts (type $ne miner)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => mixedHistory()) + const mockReq = { query: { start: 1, end: 5000, filter: JSON.stringify({ type: { $ne: 'miner' } }) } } + + const result = await getAlertsHistory(mockCtx, mockReq) + t.is(result.total, 2, 'should keep all non-miner alerts') + t.absent(result.alerts.find(a => a.type === 'miner'), 'should exclude miner alerts') +}) + +// ==================== `type` query param (all/operational/miner) ==================== + +// Includes a subtyped miner ('miner-am-s19xp') to prove the category matches +// miner subtypes, not just the exact 'miner' type. +const typedThings = () => [ + { id: 'miner-1', type: 'miner', code: 'S19', info: { container: 'cont-A' }, last: { alerts: [{ severity: 'high', name: 'a1' }] } }, + { id: 'miner-2', type: 'miner-am-s19xp', code: 'S21', info: { container: 'cont-A' }, last: { alerts: [{ severity: 'low', name: 'a2' }] } }, + { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', info: { container: 'cont-B' }, last: { alerts: [{ severity: 'critical', name: 'a3' }] } }, + { id: 'pm-1', type: 'powermeter', code: 'PM', info: { container: 'cont-B' }, last: { alerts: [{ severity: 'medium', name: 'a4' }] } } +] + +test('getSiteAlerts - type=all returns everything', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => typedThings()) + const result = await getSiteAlerts(mockCtx, { query: { type: 'all' } }) + t.is(result.total, 4, 'all alerts') +}) + +test('getSiteAlerts - no type returns everything', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => typedThings()) + const result = await getSiteAlerts(mockCtx, { query: {} }) + t.is(result.total, 4, 'all alerts when type omitted') +}) + +test('getSiteAlerts - type=miner keeps miner + subtypes', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => typedThings()) + const result = await getSiteAlerts(mockCtx, { query: { type: 'miner' } }) + t.is(result.total, 2, 'miner and miner-am-s19xp') + t.ok(result.alerts.every(a => a.type.startsWith('miner')), 'only miner-family alerts') +}) + +test('getSiteAlerts - type=operational excludes miner family', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => typedThings()) + const result = await getSiteAlerts(mockCtx, { query: { type: 'operational' } }) + t.is(result.total, 2, 'dcs + powermeter') + t.absent(result.alerts.find(a => a.type.startsWith('miner')), 'no miner alerts') +}) + +test('getSiteAlerts - type combines with existing filter (AND)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => typedThings()) + // operational + severity=critical -> only the dcs critical alert + const mockReq = { query: { type: 'operational', filter: JSON.stringify({ severity: 'critical' }) } } + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 1, 'AND of type and filter') + t.is(result.alerts[0].id, 'dcs-1', 'the critical operational alert') +}) + +test('getSiteAlerts - type pushes thing.type constraint to the worker query', async (t) => { + let captured + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async (_pk, _method, params) => { + captured = params + return typedThings() + }) + await getSiteAlerts(mockCtx, { query: { type: 'operational' } }) + t.alike(captured.query, { $and: [{ 'last.alerts': { $ne: null } }, { type: { $not: { $regex: '^miner(-|$)' } } }] }, + 'operational constraint is pushed down to listThings') +}) + +const typedHistory = () => [ + makeHistoryAlert('m1', 1000, 'high', { type: 'miner' }), + makeHistoryAlert('m2', 2000, 'low', { type: 'miner-am-s19xp' }), + makeHistoryAlert('d1', 3000, 'critical', { type: 'dcs-siemens' }), + makeHistoryAlert('p1', 4000, 'medium', { type: 'powermeter' }) +] + +test('getAlertsHistory - type=miner keeps miner + subtypes', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => typedHistory()) + const result = await getAlertsHistory(mockCtx, { query: { start: 1, end: 9000, type: 'miner' } }) + t.is(result.total, 2, 'miner and miner-am-s19xp') + t.ok(result.alerts.every(a => a.type.startsWith('miner')), 'only miner-family alerts') +}) + +test('getAlertsHistory - type=operational excludes miner family', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => typedHistory()) + const result = await getAlertsHistory(mockCtx, { query: { start: 1, end: 9000, type: 'operational' } }) + t.is(result.total, 2, 'dcs + powermeter') + t.absent(result.alerts.find(a => a.type.startsWith('miner')), 'no miner alerts') +}) + +test('getAlertsHistory - type=all returns everything', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => typedHistory()) + const result = await getAlertsHistory(mockCtx, { query: { start: 1, end: 9000, type: 'all' } }) + t.is(result.total, 4, 'all alerts') +}) + +test('getAlertsHistory - type pushes thing.type constraint to the worker query', async (t) => { + let captured + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async (_pk, _method, params) => { + captured = params + return typedHistory() + }) + await getAlertsHistory(mockCtx, { query: { start: 1, end: 9000, type: 'miner' } }) + t.alike(captured.query, { 'thing.type': { $regex: '^miner(-|$)' } }, 'miner constraint pushed to getHistoricalLogs') +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index a69f2b1..98f5301 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -338,11 +338,37 @@ const ALERTS_MAX_HISTORY_LIMIT = 1000 // `message` carries the per-alert device/equipment tag (e.g. 'FIT-7513'), so it // is filterable and searchable on both endpoints. const SITE_ALERTS_FILTER_FIELDS = ['severity', 'type', 'container', 'deviceId', 'message'] -const SITE_ALERTS_SEARCH_FIELDS = ['id', 'code', 'container', 'message', 'description'] +const SITE_ALERTS_SEARCH_FIELDS = ['id', 'code', 'container', 'message', 'description', 'name'] -const HISTORY_FILTER_FIELDS = ['severity', 'code', 'deviceType', 'container', 'deviceId', 'tags', 'message'] +const HISTORY_FILTER_FIELDS = ['severity', 'code', 'type', 'container', 'deviceId', 'tags', 'message'] const HISTORY_SEARCH_FIELDS = ['name', 'description', 'position', 'code', 'message'] +// Operators allowed inside a filter value; anything else is rejected. +const ALERTS_FILTER_OPERATORS = ['$eq', '$ne', '$in', '$nin', '$gt', '$gte', '$lt', '$lte'] + +const ALERT_TYPE_CATEGORIES = ['all', 'operational', 'miner'] + +// Matches the miner base type and its subtypes (e.g. 'miner-am-s19xp'), not 'minerals'. +const MINER_TYPE_REGEX = '^miner(-|$)' + +// Maps site-alert filter fields to the thing-level path used by `listThings`, +// so type/container/deviceId filtering is pushed down to each rack. Fields not +// listed here (severity, message) live inside `last.alerts[]` and are matched +// per-alert after extraction. +const SITE_ALERTS_THING_QUERY_MAP = { type: 'type', container: 'info.container', deviceId: 'id' } + +// Maps history-alert filter fields to the transformed-entry path used by the +// worker's `getHistoricalLogs` query (thing metadata is nested under `thing`). +const HISTORY_ALERTS_QUERY_MAP = { + severity: 'severity', + message: 'message', + code: 'thing.code', + type: 'thing.type', + container: 'thing.info.container', + deviceId: 'thing.id', + tags: 'thing.tags' +} + const POOL_ALERT_TYPES = [ 'all_pools_dead', 'wrong_miner_pool', @@ -812,6 +838,11 @@ module.exports = { SITE_ALERTS_SEARCH_FIELDS, HISTORY_FILTER_FIELDS, HISTORY_SEARCH_FIELDS, + ALERTS_FILTER_OPERATORS, + ALERT_TYPE_CATEGORIES, + MINER_TYPE_REGEX, + SITE_ALERTS_THING_QUERY_MAP, + HISTORY_ALERTS_QUERY_MAP, DEVICE_LIST_FIELDS, MINER_FIELD_MAP, MINER_PROJECTION_MAP, diff --git a/workers/lib/server/handlers/alerts.handlers.js b/workers/lib/server/handlers/alerts.handlers.js index 9537a5a..77db375 100644 --- a/workers/lib/server/handlers/alerts.handlers.js +++ b/workers/lib/server/handlers/alerts.handlers.js @@ -9,9 +9,13 @@ const { SITE_ALERTS_FILTER_FIELDS, SITE_ALERTS_SEARCH_FIELDS, HISTORY_FILTER_FIELDS, - HISTORY_SEARCH_FIELDS + HISTORY_SEARCH_FIELDS, + ALERTS_FILTER_OPERATORS, + MINER_TYPE_REGEX, + SITE_ALERTS_THING_QUERY_MAP, + HISTORY_ALERTS_QUERY_MAP } = require('../../constants') -const { parseJsonQueryParam, matchesFilter, deduplicateAlerts } = require('../../utils') +const { parseJsonQueryParam, validateFilter, applyMongoFilter, combineAnd, deduplicateAlerts } = require('../../utils') function extractAlertsFromThings (things) { const alerts = [] @@ -25,7 +29,8 @@ function extractAlertsFromThings (things) { deviceId: thing.id, type: thing.type, code: thing.code, - container: thing.info?.container + container: thing.info?.container, + position: thing.info?.pos }) } } @@ -82,7 +87,7 @@ function flattenHistoryAlert (entry) { uuid: entry.uuid, message: entry.message, deviceId: thing.id, - deviceType: thing.type, + type: thing.type, code: thing.code, container: thing.info?.container, position: thing.info?.pos, @@ -90,19 +95,57 @@ function flattenHistoryAlert (entry) { } } +// Pushes thing-level fields (type/container/deviceId) to the rack query; +// per-alert fields (severity/message) go in $elemMatch on last.alerts. +function buildSiteAlertsQuery (filter) { + const query = {} + const elem = {} + for (const [field, cond] of Object.entries(filter)) { + const thingPath = SITE_ALERTS_THING_QUERY_MAP[field] + if (thingPath) query[thingPath] = cond + else elem[field] = cond + } + query['last.alerts'] = Object.keys(elem).length ? { $elemMatch: elem } : { $ne: null } + return query +} + +// Maps filter fields to the worker's nested `thing.*` paths for getHistoricalLogs. +function buildHistoryAlertsQuery (filter) { + const query = {} + for (const [field, cond] of Object.entries(filter)) { + query[HISTORY_ALERTS_QUERY_MAP[field]] = cond + } + return Object.keys(query).length ? query : undefined +} + +// `type` param -> type-field condition; undefined means no extra constraint. +function alertTypeCondition (type) { + if (type === 'miner') return { $regex: MINER_TYPE_REGEX } + if (type === 'operational') return { $not: { $regex: MINER_TYPE_REGEX } } + return undefined +} + async function getSiteAlerts (ctx, req) { - const filter = parseJsonQueryParam(req.query.filter, 'ERR_INVALID_FILTER') + const filter = validateFilter( + parseJsonQueryParam(req.query.filter, 'ERR_INVALID_FILTER'), + SITE_ALERTS_FILTER_FIELDS, + ALERTS_FILTER_OPERATORS + ) const sort = parseJsonQueryParam(req.query.sort, 'ERR_INVALID_SORT') const search = req.query.search || '' const offset = Number(req.query.offset) || 0 const limit = Math.min(Number(req.query.limit) || ALERTS_DEFAULT_LIMIT, ALERTS_MAX_SITE_LIMIT) + const typeCond = alertTypeCondition(req.query.type) + const typeFilter = typeCond ? { type: typeCond } : null + const results = await ctx.dataProxy.requestDataMap(RPC_METHODS.LIST_THINGS, { status: 1, - query: { 'last.alerts': { $ne: null } }, + query: combineAnd(buildSiteAlertsQuery(filter), typeFilter), fields: { 'last.alerts': 1, 'info.container': 1, + 'info.pos': 1, type: 1, id: 1, code: 1 @@ -112,10 +155,9 @@ async function getSiteAlerts (ctx, req) { const things = results.flat() let alerts = extractAlertsFromThings(things) - alerts = alerts.filter(a => - matchesFilter(a, filter, SITE_ALERTS_FILTER_FIELDS) && - matchesSearch(a, search, SITE_ALERTS_SEARCH_FIELDS) - ) + // Re-apply on the merged result for per-alert fields and multi-rack correctness. + alerts = applyMongoFilter(alerts, combineAnd(filter, typeFilter)) + alerts = alerts.filter(a => matchesSearch(a, search, SITE_ALERTS_SEARCH_FIELDS)) const summary = buildSeveritySummary(alerts) alerts = applySort(alerts, sort) @@ -133,25 +175,33 @@ async function getAlertsHistory (ctx, req) { throw new Error('ERR_INVALID_DATE_RANGE') } - const filter = parseJsonQueryParam(req.query.filter, 'ERR_INVALID_FILTER') + const filter = validateFilter( + parseJsonQueryParam(req.query.filter, 'ERR_INVALID_FILTER'), + HISTORY_FILTER_FIELDS, + ALERTS_FILTER_OPERATORS + ) const sort = parseJsonQueryParam(req.query.sort, 'ERR_INVALID_SORT') || { createdAt: -1 } const search = req.query.search || '' const offset = Number(req.query.offset) || 0 const limit = Math.min(Number(req.query.limit) || ALERTS_DEFAULT_LIMIT, ALERTS_MAX_HISTORY_LIMIT) + // Worker filters on nested `thing.type`; handler re-filters on flattened `deviceType`. + const typeCond = alertTypeCondition(req.query.type) + const workerQuery = combineAnd(buildHistoryAlertsQuery(filter) || {}, typeCond ? { 'thing.type': typeCond } : null) + const results = await ctx.dataProxy.requestDataMap(RPC_METHODS.GET_HISTORICAL_LOGS, { start, end, - logType: 'alerts' + logType: 'alerts', + query: Object.keys(workerQuery).length ? workerQuery : undefined }) let alerts = results.flat().map(flattenHistoryAlert) alerts = deduplicateAlerts(alerts) - alerts = alerts.filter(a => - matchesFilter(a, filter, HISTORY_FILTER_FIELDS) && - matchesSearch(a, search, HISTORY_SEARCH_FIELDS) - ) + // Re-apply on the merged result for global correctness. + alerts = applyMongoFilter(alerts, combineAnd(filter, typeCond ? { type: typeCond } : null)) + alerts = alerts.filter(a => matchesSearch(a, search, HISTORY_SEARCH_FIELDS)) alerts = applySort(alerts, sort) const total = alerts.length diff --git a/workers/lib/server/routes/alerts.routes.js b/workers/lib/server/routes/alerts.routes.js index f56ad61..5901043 100644 --- a/workers/lib/server/routes/alerts.routes.js +++ b/workers/lib/server/routes/alerts.routes.js @@ -25,6 +25,7 @@ module.exports = (ctx) => { ctx, (req) => [ 'alerts/site', + req.query.type, req.query.filter, req.query.sort, req.query.search, @@ -48,6 +49,7 @@ module.exports = (ctx) => { 'alerts/history', req.query.start, req.query.end, + req.query.type, req.query.filter, req.query.search, req.query.sort, diff --git a/workers/lib/server/schemas/alerts.schemas.js b/workers/lib/server/schemas/alerts.schemas.js index 637e697..8134d14 100644 --- a/workers/lib/server/schemas/alerts.schemas.js +++ b/workers/lib/server/schemas/alerts.schemas.js @@ -5,6 +5,7 @@ const schemas = { siteAlerts: { type: 'object', properties: { + type: { type: 'string', enum: ['all', 'operational', 'miner'] }, filter: { type: 'string' }, sort: { type: 'string' }, search: { type: 'string' }, @@ -18,6 +19,7 @@ const schemas = { properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 0 }, + type: { type: 'string', enum: ['all', 'operational', 'miner'] }, filter: { type: 'string' }, search: { type: 'string' }, sort: { type: 'string' }, diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 5908de6..b34a61f 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -1,6 +1,7 @@ 'use strict' const async = require('async') +const mingo = require('mingo') const { RPC_TIMEOUT } = require('./constants') const { getStartOfDay } = require('./period.utils') @@ -180,6 +181,55 @@ function matchesFilter (item, filter, allowedFields) { return true } +// scalar -> equality; array -> $in; object -> operator conditions (allow-listed). +function normalizeFilterValue (value, allowedOpSet) { + if (Array.isArray(value)) return { $in: value } + if (isValidJsonObject(value)) { + const conds = Object.entries(value) + if (conds.length === 0) throw new Error('ERR_INVALID_FILTER') + const out = {} + for (const [op, opVal] of conds) { + if (!allowedOpSet.has(op)) throw new Error('ERR_INVALID_FILTER') + if ((op === '$in' || op === '$nin') && !Array.isArray(opVal)) { + throw new Error('ERR_INVALID_FILTER') + } + out[op] = opVal + } + return out + } + return value +} + +// Validates a filter against allow-listed fields/operators and returns a mingo +// query. Throws ERR_INVALID_FILTER on anything outside the allow-list. +function validateFilter (filter, allowedFields, allowedOperators) { + if (filter == null) return {} + if (!isValidJsonObject(filter)) throw new Error('ERR_INVALID_FILTER') + const allowedFieldSet = new Set(allowedFields) + const allowedOpSet = new Set(allowedOperators) + const normalized = {} + for (const [field, value] of Object.entries(filter)) { + if (!allowedFieldSet.has(field)) throw new Error('ERR_INVALID_FILTER') + normalized[field] = normalizeFilterValue(value, allowedOpSet) + } + return normalized +} + +// Applies a normalised filter to an in-memory array via mingo (no-op when empty). +function applyMongoFilter (items, normalizedFilter) { + if (!normalizedFilter || Object.keys(normalizedFilter).length === 0) return items + return new mingo.Query(normalizedFilter).find(items).all() +} + +// ANDs two mongo queries, dropping empty operands and only wrapping in $and when both exist. +function combineAnd (a, b) { + const aEmpty = !a || Object.keys(a).length === 0 + const bEmpty = !b || Object.keys(b).length === 0 + if (aEmpty) return b || {} + if (bEmpty) return a + return { $and: [a, b] } +} + module.exports = { dateNowSec, extractIps, @@ -194,6 +244,9 @@ module.exports = { runParallel, deduplicateAlerts, matchesFilter, + validateFilter, + applyMongoFilter, + combineAnd, escapeRegex, csvEscape, stableJsonString,