From 6687254ed2d0a7ae25e89caec856cb86fed592e5 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 11 Feb 2026 04:51:35 +0300 Subject: [PATCH 1/4] feat: add GET /auth/pools endpoint Add pools API endpoint that aggregates pool device lists and stats from ORK clusters, supports mingo-based filtering, sorting, and field selection, and returns pool summary with totals. --- tests/unit/handlers/pools.handlers.test.js | 149 ++++++++++++++++++ tests/unit/routes/pools.routes.test.js | 39 +++++ workers/lib/constants.js | 19 ++- workers/lib/server/handlers/pools.handlers.js | 146 +++++++++++++++++ workers/lib/server/index.js | 4 +- workers/lib/server/routes/pools.routes.js | 35 ++++ workers/lib/server/schemas/pools.schemas.js | 17 ++ workers/lib/utils.js | 11 +- 8 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 tests/unit/handlers/pools.handlers.test.js create mode 100644 tests/unit/routes/pools.routes.test.js create mode 100644 workers/lib/server/handlers/pools.handlers.js create mode 100644 workers/lib/server/routes/pools.routes.js create mode 100644 workers/lib/server/schemas/pools.schemas.js diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js new file mode 100644 index 0000000..7b80383 --- /dev/null +++ b/tests/unit/handlers/pools.handlers.test.js @@ -0,0 +1,149 @@ +'use strict' + +const test = require('brittle') +const { + getPools, + flattenResults, + flattenStatsResults, + mergePoolData, + calculatePoolsSummary +} = require('../../../workers/lib/server/handlers/pools.handlers') + +test('getPools - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'listThings') { + return [{ id: 'pool1', name: 'Pool 1', tag: 't-minerpool', status: 1 }] + } + if (method === 'getWrkExtData') { + return { data: [{ data: { pool1: { hashrate: 100000, worker_count: 5, balance: 50000 } } }] } + } + return {} + } + } + } + + const mockReq = { query: {} } + const result = await getPools(mockCtx, mockReq, {}) + t.ok(result.pools, 'should return pools array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.pools), 'pools should be array') + t.pass() +}) + +test('getPools - with filter', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'listThings') { + return [ + { id: 'pool1', name: 'Pool 1', status: 1 }, + { id: 'pool2', name: 'Pool 2', status: 0 } + ] + } + return { data: [] } + } + } + } + + const mockReq = { query: { filter: '{"status":1}' } } + const result = await getPools(mockCtx, mockReq, {}) + t.ok(result.pools, 'should return filtered pools') + t.pass() +}) + +test('getPools - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ([]) } + } + + const result = await getPools(mockCtx, { query: {} }, {}) + t.ok(result.pools, 'should return pools array') + t.is(result.pools.length, 0, 'pools should be empty') + t.is(result.summary.poolCount, 0, 'pool count should be 0') + t.pass() +}) + +test('flattenResults - flattens ork arrays', (t) => { + const results = [ + [{ id: 'pool1' }, { id: 'pool2' }], + [{ id: 'pool3' }] + ] + const flat = flattenResults(results) + t.is(flat.length, 3, 'should flatten all items') + t.pass() +}) + +test('flattenResults - handles error results', (t) => { + const results = [{ error: 'timeout' }, [{ id: 'pool1' }]] + const flat = flattenResults(results) + t.is(flat.length, 1, 'should skip error results') + t.pass() +}) + +test('flattenResults - handles non-array input', (t) => { + const flat = flattenResults(null) + t.is(flat.length, 0, 'should return empty array') + t.pass() +}) + +test('flattenStatsResults - builds stats map', (t) => { + const results = [ + { data: [{ data: { pool1: { hashrate: 100 }, pool2: { hashrate: 200 } } }] } + ] + const stats = flattenStatsResults(results) + t.ok(stats.pool1, 'should have pool1 stats') + t.ok(stats.pool2, 'should have pool2 stats') + t.is(stats.pool1.hashrate, 100, 'should have correct hashrate') + t.pass() +}) + +test('flattenStatsResults - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const stats = flattenStatsResults(results) + t.is(Object.keys(stats).length, 0, 'should be empty') + t.pass() +}) + +test('mergePoolData - merges devices with stats', (t) => { + const devices = [ + { id: 'pool1', name: 'Pool 1', status: 1 }, + { id: 'pool2', name: 'Pool 2', status: 1 } + ] + const stats = { + pool1: { hashrate: 100, worker_count: 5, balance: 50000 } + } + + const pools = mergePoolData(devices, stats) + t.is(pools.length, 2, 'should return all devices') + t.is(pools[0].hashrate, 100, 'should merge stats') + t.is(pools[1].hashrate, 0, 'should default missing stats') + t.pass() +}) + +test('calculatePoolsSummary - calculates totals', (t) => { + const pools = [ + { hashrate: 100, workerCount: 5, balance: 50000 }, + { hashrate: 200, workerCount: 10, balance: 30000 } + ] + + const summary = calculatePoolsSummary(pools) + t.is(summary.poolCount, 2, 'should count pools') + t.is(summary.totalHashrate, 300, 'should sum hashrate') + t.is(summary.totalWorkers, 15, 'should sum workers') + t.is(summary.totalBalance, 80000, 'should sum balance') + t.pass() +}) + +test('calculatePoolsSummary - handles empty pools', (t) => { + const summary = calculatePoolsSummary([]) + t.is(summary.poolCount, 0, 'should be zero') + t.is(summary.totalHashrate, 0, 'should be zero') + t.pass() +}) diff --git a/tests/unit/routes/pools.routes.test.js b/tests/unit/routes/pools.routes.test.js new file mode 100644 index 0000000..0e77076 --- /dev/null +++ b/tests/unit/routes/pools.routes.test.js @@ -0,0 +1,39 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions, testOnRequestFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/pools.routes.js' + +test('pools routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'pools') + t.pass() +}) + +test('pools routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/pools'), 'should have pools route') + t.pass() +}) + +test('pools routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + routes.forEach(route => { + t.is(route.method, 'GET', `route ${route.url} should be GET`) + }) + t.pass() +}) + +test('pools routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'pools') + t.pass() +}) + +test('pools routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'pools') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f1f407e..95530ab 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -108,7 +108,10 @@ const ENDPOINTS = { THING_CONFIG: '/auth/thing-config', // WebSocket endpoint - WEBSOCKET: '/ws' + WEBSOCKET: '/ws', + + // Pools endpoints + POOLS: '/auth/pools' } const HTTP_METHODS = { @@ -183,6 +186,16 @@ const STATUS_CODES = { INTERNAL_SERVER_ERROR: 500 } +const RPC_METHODS = { + LIST_THINGS: 'listThings', + GET_WRK_EXT_DATA: 'getWrkExtData' +} + +const MINERPOOL_EXT_DATA_KEYS = { + TRANSACTIONS: 'transactions', + STATS: 'stats' +} + const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 @@ -202,5 +215,7 @@ module.exports = { STATUS_CODES, RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, - USER_SETTINGS_TYPE + USER_SETTINGS_TYPE, + RPC_METHODS, + MINERPOOL_EXT_DATA_KEYS } diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js new file mode 100644 index 0000000..b8c01a5 --- /dev/null +++ b/workers/lib/server/handlers/pools.handlers.js @@ -0,0 +1,146 @@ +'use strict' + +const mingo = require('mingo') +const { + RPC_METHODS, + MINERPOOL_EXT_DATA_KEYS +} = require('../../constants') +const { + requestRpcMapLimit, + parseJsonQueryParam, + runParallel +} = require('../../utils') + +async function getPools (ctx, req) { + const filter = req.query.filter ? parseJsonQueryParam(req.query.filter, 'ERR_FILTER_INVALID_JSON') : null + const sort = req.query.sort ? parseJsonQueryParam(req.query.sort, 'ERR_SORT_INVALID_JSON') : null + const fields = req.query.fields ? parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') : null + + const [deviceResults, statsResults] = await runParallel([ + (cb) => requestRpcMapLimit(ctx, RPC_METHODS.LIST_THINGS, { + query: { tags: { $in: ['t-minerpool'] } }, + status: 1 + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcMapLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: 'minerpool', + query: { key: MINERPOOL_EXT_DATA_KEYS.STATS } + }).then(r => cb(null, r)).catch(cb) + ]) + + const devices = flattenResults(deviceResults) + const stats = flattenStatsResults(statsResults) + + const pools = mergePoolData(devices, stats) + + let result = pools + if (filter) { + const query = new mingo.Query(filter) + result = query.find(result).all() + } + + if (sort) { + const sortKeys = Object.entries(sort) + result.sort((a, b) => { + for (const [key, dir] of sortKeys) { + const aVal = a[key] || 0 + const bVal = b[key] || 0 + if (aVal !== bVal) return dir > 0 ? aVal - bVal : bVal - aVal + } + return 0 + }) + } + + if (fields) { + result = result.map(pool => { + const filtered = {} + for (const key of Object.keys(fields)) { + if (fields[key] && pool[key] !== undefined) { + filtered[key] = pool[key] + } + } + return filtered + }) + } + + const summary = calculatePoolsSummary(pools) + + return { pools: result, summary } +} + +function flattenResults (results) { + const flat = [] + if (!Array.isArray(results)) return flat + for (const orkResult of results) { + if (!orkResult || orkResult.error) continue + const items = Array.isArray(orkResult) ? orkResult : (orkResult.data || orkResult.result || []) + if (Array.isArray(items)) { + flat.push(...items) + } + } + return flat +} + +function flattenStatsResults (results) { + const statsMap = {} + if (!Array.isArray(results)) return statsMap + for (const orkResult of results) { + if (!orkResult || orkResult.error) continue + const items = Array.isArray(orkResult) ? orkResult : (orkResult.data || orkResult.result || []) + if (Array.isArray(items)) { + for (const item of items) { + if (!item) continue + const entries = item.data || item.stats || item + if (typeof entries === 'object' && !Array.isArray(entries)) { + for (const [poolId, stat] of Object.entries(entries)) { + statsMap[poolId] = stat + } + } + } + } + } + return statsMap +} + +function mergePoolData (devices, stats) { + return devices.map(device => { + const poolId = device.id || device.tag || device.name + const stat = stats[poolId] || {} + + return { + id: poolId, + name: device.name || poolId, + tag: device.tag, + status: device.status, + url: device.url || stat.url, + hashrate: stat.hashrate || 0, + workerCount: stat.worker_count || stat.workerCount || 0, + balance: stat.balance || 0, + lastUpdated: stat.lastUpdated || stat.last_updated || null, + ...device, + ...stat + } + }) +} + +function calculatePoolsSummary (pools) { + const totals = pools.reduce((acc, pool) => { + acc.totalHashrate += pool.hashrate || 0 + acc.totalWorkers += pool.workerCount || pool.worker_count || 0 + acc.totalBalance += pool.balance || 0 + return acc + }, { totalHashrate: 0, totalWorkers: 0, totalBalance: 0 }) + + return { + poolCount: pools.length, + ...totals + } +} + +module.exports = { + getPools, + flattenResults, + flattenStatsResults, + mergePoolData, + calculatePoolsSummary +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index ac884da..9ce3cab 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -8,6 +8,7 @@ const globalRoutes = require('./routes/global.routes') const thingsRoutes = require('./routes/things.routes') const settingsRoutes = require('./routes/settings.routes') const wsRoutes = require('./routes/ws.routes') +const poolsRoutes = require('./routes/pools.routes') /** * Collect all routes into a flat array for server injection. @@ -22,7 +23,8 @@ function routes (ctx) { ...thingsRoutes(ctx), ...usersRoutes(ctx), ...settingsRoutes(ctx), - ...wsRoutes(ctx) + ...wsRoutes(ctx), + ...poolsRoutes(ctx) ] } diff --git a/workers/lib/server/routes/pools.routes.js b/workers/lib/server/routes/pools.routes.js new file mode 100644 index 0000000..39093ac --- /dev/null +++ b/workers/lib/server/routes/pools.routes.js @@ -0,0 +1,35 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getPools +} = require('../handlers/pools.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/pools.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOLS, + schema: { + querystring: schemas.query.pools + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'pools', + req.query.filter, + req.query.sort, + req.query.fields + ], + ENDPOINTS.POOLS, + getPools + ) + } + ] +} diff --git a/workers/lib/server/schemas/pools.schemas.js b/workers/lib/server/schemas/pools.schemas.js new file mode 100644 index 0000000..c802b2d --- /dev/null +++ b/workers/lib/server/schemas/pools.schemas.js @@ -0,0 +1,17 @@ +'use strict' + +const schemas = { + query: { + pools: { + type: 'object', + properties: { + filter: { type: 'string' }, + sort: { type: 'string' }, + fields: { type: 'string' }, + overwriteCache: { type: 'boolean' } + } + } + } +} + +module.exports = schemas diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 886ae5f..180b8a7 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -128,6 +128,14 @@ const requestRpcMapLimit = async (ctx, method, payload) => { }) } +const runParallel = (tasks) => + new Promise((resolve, reject) => { + async.parallel(tasks, (err, results) => { + if (err) reject(err) + else resolve(results) + }) + }) + module.exports = { dateNowSec, extractIps, @@ -137,5 +145,6 @@ module.exports = { getAuthTokenFromHeaders, parseJsonQueryParam, requestRpcEachLimit, - requestRpcMapLimit + requestRpcMapLimit, + runParallel } From 39f61dacefde96b00a298879ccdb162e82e27747 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Feb 2026 21:13:34 +0300 Subject: [PATCH 2/4] fix: use ext-data stats array instead of list-things for pools endpoint list-things for t-minerpool returns empty; pool data lives in ext-data stats responses. Replaced flattenResults/flattenStatsResults/mergePoolData with flattenPoolStats that parses the actual stats array format. --- tests/unit/handlers/pools.handlers.test.js | 104 +++++++--------- workers/lib/server/handlers/pools.handlers.js | 113 ++++++++---------- 2 files changed, 94 insertions(+), 123 deletions(-) diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js index 7b80383..9c10a0a 100644 --- a/tests/unit/handlers/pools.handlers.test.js +++ b/tests/unit/handlers/pools.handlers.test.js @@ -3,9 +3,7 @@ const test = require('brittle') const { getPools, - flattenResults, - flattenStatsResults, - mergePoolData, + flattenPoolStats, calculatePoolsSummary } = require('../../../workers/lib/server/handlers/pools.handlers') @@ -16,13 +14,16 @@ test('getPools - happy path', async (t) => { }, net_r0: { jRequest: async (key, method, payload) => { - if (method === 'listThings') { - return [{ id: 'pool1', name: 'Pool 1', tag: 't-minerpool', status: 1 }] - } if (method === 'getWrkExtData') { - return { data: [{ data: { pool1: { hashrate: 100000, worker_count: 5, balance: 50000 } } }] } + return [{ + ts: '1770000000000', + stats: [ + { poolType: 'f2pool', username: 'user1', hashrate: 100000, worker_count: 5, balance: 0.5, timestamp: 1770000000000 }, + { poolType: 'ocean', username: 'user2', hashrate: 200000, worker_count: 10, balance: 1.2, timestamp: 1770000000000 } + ] + }] } - return {} + return [] } } } @@ -32,6 +33,8 @@ test('getPools - happy path', async (t) => { t.ok(result.pools, 'should return pools array') t.ok(result.summary, 'should return summary') t.ok(Array.isArray(result.pools), 'pools should be array') + t.is(result.pools.length, 2, 'should have 2 pools') + t.is(result.summary.poolCount, 2, 'summary should count 2 pools') t.pass() }) @@ -39,21 +42,21 @@ test('getPools - with filter', async (t) => { const mockCtx = { conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { - jRequest: async (key, method) => { - if (method === 'listThings') { - return [ - { id: 'pool1', name: 'Pool 1', status: 1 }, - { id: 'pool2', name: 'Pool 2', status: 0 } - ] - } - return { data: [] } - } + jRequest: async () => [{ + ts: '1770000000000', + stats: [ + { poolType: 'f2pool', username: 'user1', hashrate: 100000, worker_count: 5, balance: 0.5 }, + { poolType: 'ocean', username: 'user2', hashrate: 200000, worker_count: 10, balance: 0 } + ] + }] } } - const mockReq = { query: { filter: '{"status":1}' } } + const mockReq = { query: { filter: '{"pool":"f2pool"}' } } const result = await getPools(mockCtx, mockReq, {}) t.ok(result.pools, 'should return filtered pools') + t.is(result.pools.length, 1, 'should have 1 pool after filter') + t.is(result.pools[0].pool, 'f2pool', 'should match filter') t.pass() }) @@ -70,60 +73,45 @@ test('getPools - empty ork results', async (t) => { t.pass() }) -test('flattenResults - flattens ork arrays', (t) => { +test('flattenPoolStats - extracts pools from ext-data stats array', (t) => { const results = [ - [{ id: 'pool1' }, { id: 'pool2' }], - [{ id: 'pool3' }] + [{ + ts: '1770000000000', + stats: [ + { poolType: 'f2pool', username: 'user1', hashrate: 100000, worker_count: 5, balance: 0.5 }, + { poolType: 'ocean', username: 'user2', hashrate: 200000, worker_count: 10, balance: 1.2 } + ] + }] ] - const flat = flattenResults(results) - t.is(flat.length, 3, 'should flatten all items') - t.pass() -}) - -test('flattenResults - handles error results', (t) => { - const results = [{ error: 'timeout' }, [{ id: 'pool1' }]] - const flat = flattenResults(results) - t.is(flat.length, 1, 'should skip error results') + const pools = flattenPoolStats(results) + t.is(pools.length, 2, 'should extract 2 pools') + t.is(pools[0].pool, 'f2pool', 'should have correct pool type') + t.is(pools[0].account, 'user1', 'should have correct account') + t.is(pools[0].hashrate, 100000, 'should have correct hashrate') + t.is(pools[1].pool, 'ocean', 'should have correct pool type') t.pass() }) -test('flattenResults - handles non-array input', (t) => { - const flat = flattenResults(null) - t.is(flat.length, 0, 'should return empty array') - t.pass() -}) - -test('flattenStatsResults - builds stats map', (t) => { +test('flattenPoolStats - deduplicates pools across orks', (t) => { const results = [ - { data: [{ data: { pool1: { hashrate: 100 }, pool2: { hashrate: 200 } } }] } + [{ ts: '1770000000000', stats: [{ poolType: 'f2pool', username: 'user1', hashrate: 100 }] }], + [{ ts: '1770000000000', stats: [{ poolType: 'f2pool', username: 'user1', hashrate: 200 }] }] ] - const stats = flattenStatsResults(results) - t.ok(stats.pool1, 'should have pool1 stats') - t.ok(stats.pool2, 'should have pool2 stats') - t.is(stats.pool1.hashrate, 100, 'should have correct hashrate') + const pools = flattenPoolStats(results) + t.is(pools.length, 1, 'should deduplicate by poolType:username') t.pass() }) -test('flattenStatsResults - handles error results', (t) => { +test('flattenPoolStats - handles error results', (t) => { const results = [{ error: 'timeout' }] - const stats = flattenStatsResults(results) - t.is(Object.keys(stats).length, 0, 'should be empty') + const pools = flattenPoolStats(results) + t.is(pools.length, 0, 'should be empty') t.pass() }) -test('mergePoolData - merges devices with stats', (t) => { - const devices = [ - { id: 'pool1', name: 'Pool 1', status: 1 }, - { id: 'pool2', name: 'Pool 2', status: 1 } - ] - const stats = { - pool1: { hashrate: 100, worker_count: 5, balance: 50000 } - } - - const pools = mergePoolData(devices, stats) - t.is(pools.length, 2, 'should return all devices') - t.is(pools[0].hashrate, 100, 'should merge stats') - t.is(pools[1].hashrate, 0, 'should default missing stats') +test('flattenPoolStats - handles non-array input', (t) => { + const pools = flattenPoolStats(null) + t.is(pools.length, 0, 'should return empty array') t.pass() }) diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index b8c01a5..793b834 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -7,8 +7,7 @@ const { } = require('../../constants') const { requestRpcMapLimit, - parseJsonQueryParam, - runParallel + parseJsonQueryParam } = require('../../utils') async function getPools (ctx, req) { @@ -16,22 +15,12 @@ async function getPools (ctx, req) { const sort = req.query.sort ? parseJsonQueryParam(req.query.sort, 'ERR_SORT_INVALID_JSON') : null const fields = req.query.fields ? parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') : null - const [deviceResults, statsResults] = await runParallel([ - (cb) => requestRpcMapLimit(ctx, RPC_METHODS.LIST_THINGS, { - query: { tags: { $in: ['t-minerpool'] } }, - status: 1 - }).then(r => cb(null, r)).catch(cb), - - (cb) => requestRpcMapLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { - type: 'minerpool', - query: { key: MINERPOOL_EXT_DATA_KEYS.STATS } - }).then(r => cb(null, r)).catch(cb) - ]) - - const devices = flattenResults(deviceResults) - const stats = flattenStatsResults(statsResults) + const statsResults = await requestRpcMapLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: 'minerpool', + query: { key: MINERPOOL_EXT_DATA_KEYS.STATS } + }) - const pools = mergePoolData(devices, stats) + const pools = flattenPoolStats(statsResults) let result = pools if (filter) { @@ -68,59 +57,55 @@ async function getPools (ctx, req) { return { pools: result, summary } } -function flattenResults (results) { - const flat = [] - if (!Array.isArray(results)) return flat - for (const orkResult of results) { - if (!orkResult || orkResult.error) continue - const items = Array.isArray(orkResult) ? orkResult : (orkResult.data || orkResult.result || []) - if (Array.isArray(items)) { - flat.push(...items) - } - } - return flat -} +/** + * Extracts pool stats from ext-data RPC results. + * The ext-data response structure is: + * [ orkResult1, orkResult2, ... ] + * where each orkResult is: + * [ { ts, stats: [ {poolType, username, hashrate, balance, ...}, ... ], ... } ] + */ +function flattenPoolStats (results) { + const pools = [] + const seen = new Set() + if (!Array.isArray(results)) return pools -function flattenStatsResults (results) { - const statsMap = {} - if (!Array.isArray(results)) return statsMap for (const orkResult of results) { if (!orkResult || orkResult.error) continue const items = Array.isArray(orkResult) ? orkResult : (orkResult.data || orkResult.result || []) - if (Array.isArray(items)) { - for (const item of items) { - if (!item) continue - const entries = item.data || item.stats || item - if (typeof entries === 'object' && !Array.isArray(entries)) { - for (const [poolId, stat] of Object.entries(entries)) { - statsMap[poolId] = stat - } - } + if (!Array.isArray(items)) continue + + for (const item of items) { + if (!item) continue + const stats = item.stats || item.data || [] + if (!Array.isArray(stats)) continue + + for (const stat of stats) { + if (!stat) continue + const poolKey = `${stat.poolType}:${stat.username}` + if (seen.has(poolKey)) continue + seen.add(poolKey) + + pools.push({ + name: stat.username || stat.poolType, + pool: stat.poolType, + account: stat.username, + status: 'active', + hashrate: stat.hashrate || 0, + hashrate1h: stat.hashrate_1h || 0, + hashrate24h: stat.hashrate_24h || 0, + workerCount: stat.worker_count || 0, + activeWorkerCount: stat.active_workers_count || 0, + balance: stat.balance || 0, + unsettled: stat.unsettled || 0, + revenue24h: stat.revenue_24h || stat.estimated_today_income || 0, + yearlyBalances: stat.yearlyBalances || [], + lastUpdated: stat.timestamp || null + }) } } } - return statsMap -} -function mergePoolData (devices, stats) { - return devices.map(device => { - const poolId = device.id || device.tag || device.name - const stat = stats[poolId] || {} - - return { - id: poolId, - name: device.name || poolId, - tag: device.tag, - status: device.status, - url: device.url || stat.url, - hashrate: stat.hashrate || 0, - workerCount: stat.worker_count || stat.workerCount || 0, - balance: stat.balance || 0, - lastUpdated: stat.lastUpdated || stat.last_updated || null, - ...device, - ...stat - } - }) + return pools } function calculatePoolsSummary (pools) { @@ -139,8 +124,6 @@ function calculatePoolsSummary (pools) { module.exports = { getPools, - flattenResults, - flattenStatsResults, - mergePoolData, + flattenPoolStats, calculatePoolsSummary } From 4018332e47af38a15af713a164f192432f5c0347 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Feb 2026 22:53:12 +0300 Subject: [PATCH 3/4] cleanup --- workers/lib/server/handlers/pools.handlers.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 793b834..55cc5ad 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -57,13 +57,6 @@ async function getPools (ctx, req) { return { pools: result, summary } } -/** - * Extracts pool stats from ext-data RPC results. - * The ext-data response structure is: - * [ orkResult1, orkResult2, ... ] - * where each orkResult is: - * [ { ts, stats: [ {poolType, username, hashrate, balance, ...}, ... ], ... } ] - */ function flattenPoolStats (results) { const pools = [] const seen = new Set() From 4ec82724e83c6deaff52d3b9123c7cdc6598c550 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Sun, 15 Feb 2026 16:46:56 +0300 Subject: [PATCH 4/4] fix: rename filter to query param and use mingo projection for pools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align with list-things pattern by renaming filter→query. Replace manual sort and field filtering with mingo cursor methods for consistency. --- tests/unit/handlers/pools.handlers.test.js | 2 +- workers/lib/server/handlers/pools.handlers.js | 35 +++---------------- workers/lib/server/routes/pools.routes.js | 2 +- workers/lib/server/schemas/pools.schemas.js | 2 +- 4 files changed, 8 insertions(+), 33 deletions(-) diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js index 9c10a0a..1b1e6dd 100644 --- a/tests/unit/handlers/pools.handlers.test.js +++ b/tests/unit/handlers/pools.handlers.test.js @@ -52,7 +52,7 @@ test('getPools - with filter', async (t) => { } } - const mockReq = { query: { filter: '{"pool":"f2pool"}' } } + const mockReq = { query: { query: '{"pool":"f2pool"}' } } const result = await getPools(mockCtx, mockReq, {}) t.ok(result.pools, 'should return filtered pools') t.is(result.pools.length, 1, 'should have 1 pool after filter') diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 55cc5ad..593c170 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -11,7 +11,7 @@ const { } = require('../../utils') async function getPools (ctx, req) { - const filter = req.query.filter ? parseJsonQueryParam(req.query.filter, 'ERR_FILTER_INVALID_JSON') : null + const filter = req.query.query ? parseJsonQueryParam(req.query.query, 'ERR_QUERY_INVALID_JSON') : null const sort = req.query.sort ? parseJsonQueryParam(req.query.sort, 'ERR_SORT_INVALID_JSON') : null const fields = req.query.fields ? parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') : null @@ -22,35 +22,10 @@ async function getPools (ctx, req) { const pools = flattenPoolStats(statsResults) - let result = pools - if (filter) { - const query = new mingo.Query(filter) - result = query.find(result).all() - } - - if (sort) { - const sortKeys = Object.entries(sort) - result.sort((a, b) => { - for (const [key, dir] of sortKeys) { - const aVal = a[key] || 0 - const bVal = b[key] || 0 - if (aVal !== bVal) return dir > 0 ? aVal - bVal : bVal - aVal - } - return 0 - }) - } - - if (fields) { - result = result.map(pool => { - const filtered = {} - for (const key of Object.keys(fields)) { - if (fields[key] && pool[key] !== undefined) { - filtered[key] = pool[key] - } - } - return filtered - }) - } + const query = new mingo.Query(filter || {}) + let cursor = query.find(pools, fields || {}) + if (sort) cursor = cursor.sort(sort) + const result = cursor.all() const summary = calculatePoolsSummary(pools) diff --git a/workers/lib/server/routes/pools.routes.js b/workers/lib/server/routes/pools.routes.js index 39093ac..64b982c 100644 --- a/workers/lib/server/routes/pools.routes.js +++ b/workers/lib/server/routes/pools.routes.js @@ -23,7 +23,7 @@ module.exports = (ctx) => { ctx, (req) => [ 'pools', - req.query.filter, + req.query.query, req.query.sort, req.query.fields ], diff --git a/workers/lib/server/schemas/pools.schemas.js b/workers/lib/server/schemas/pools.schemas.js index c802b2d..4dae736 100644 --- a/workers/lib/server/schemas/pools.schemas.js +++ b/workers/lib/server/schemas/pools.schemas.js @@ -5,7 +5,7 @@ const schemas = { pools: { type: 'object', properties: { - filter: { type: 'string' }, + query: { type: 'string' }, sort: { type: 'string' }, fields: { type: 'string' }, overwriteCache: { type: 'boolean' }