From 7198ef8f9f38eb8c70f0dfecac4f30ec7b40ecfa Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:57:37 +0530 Subject: [PATCH 01/63] Feature - Site status live api (#4) --- config/common.json.example | 3 +- tests/integration/api.security.test.js | 66 +++++ tests/unit/handlers/site.handlers.test.js | 208 +++++++++++++ tests/unit/routes/site.routes.test.js | 55 ++++ workers/lib/constants.js | 36 +-- workers/lib/server/handlers/site.handlers.js | 292 +++++++++++++++++++ workers/lib/server/index.js | 4 +- workers/lib/server/routes/site.routes.js | 26 ++ 8 files changed, 656 insertions(+), 34 deletions(-) create mode 100644 tests/unit/handlers/site.handlers.test.js create mode 100644 tests/unit/routes/site.routes.test.js create mode 100644 workers/lib/server/handlers/site.handlers.js create mode 100644 workers/lib/server/routes/site.routes.js diff --git a/config/common.json.example b/config/common.json.example index d6b1811..832e1b1 100644 --- a/config/common.json.example +++ b/config/common.json.example @@ -13,7 +13,8 @@ "/auth/actions/batch": "30s", "/auth/actions/:type": "30s", "/auth/actions/:type/:id": "30s", - "/auth/global/data": "30s" + "/auth/global/data": "30s", + "/auth/site/status/live": "15s" }, "featureConfig": { "comments": true, diff --git a/tests/integration/api.security.test.js b/tests/integration/api.security.test.js index 58f9728..341e7f7 100644 --- a/tests/integration/api.security.test.js +++ b/tests/integration/api.security.test.js @@ -399,6 +399,72 @@ test('Api security', { timeout: 90000 }, async (main) => { await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) }) + await main.test('Api: get site/status/live', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.SITE_STATUS_LIVE}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get site/status/live with overwriteCache', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.SITE_STATUS_LIVE}?overwriteCache=true` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get site/status/live - response structure', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.SITE_STATUS_LIVE}` + + await n.test('response should have expected structure', async (t) => { + const headers = await createAuthHeaders(readonlyUser) + try { + const { body: data } = await httpClient.get(api, { headers, encoding }) + + // Verify top-level keys exist + t.ok(data.hashrate !== undefined, 'should have hashrate') + t.ok(data.power !== undefined, 'should have power') + t.ok(data.efficiency !== undefined, 'should have efficiency') + t.ok(data.miners !== undefined, 'should have miners') + t.ok(data.alerts !== undefined, 'should have alerts') + t.ok(data.pools !== undefined, 'should have pools') + t.ok(data.ts !== undefined, 'should have timestamp') + + // Verify hashrate structure + t.ok(data.hashrate.value !== undefined, 'hashrate should have value') + t.ok(data.hashrate.nominal !== undefined, 'hashrate should have nominal') + t.ok(data.hashrate.utilization !== undefined, 'hashrate should have utilization') + + // Verify power structure + t.ok(data.power.value !== undefined, 'power should have value') + t.ok(data.power.nominal !== undefined, 'power should have nominal') + t.ok(data.power.utilization !== undefined, 'power should have utilization') + + // Verify efficiency structure + t.ok(data.efficiency.value !== undefined, 'efficiency should have value') + + // Verify miners structure + t.ok(data.miners.online !== undefined, 'miners should have online') + t.ok(data.miners.offline !== undefined, 'miners should have offline') + t.ok(data.miners.error !== undefined, 'miners should have error') + t.ok(data.miners.sleep !== undefined, 'miners should have sleep') + t.ok(data.miners.total !== undefined, 'miners should have total') + t.ok(data.miners.containerCapacity !== undefined, 'miners should have containerCapacity') + + // Verify alerts structure + t.ok(data.alerts.critical !== undefined, 'alerts should have critical') + t.ok(data.alerts.high !== undefined, 'alerts should have high') + t.ok(data.alerts.medium !== undefined, 'alerts should have medium') + t.ok(data.alerts.total !== undefined, 'alerts should have total') + + // Verify pools structure + t.ok(data.pools.totalHashrate !== undefined, 'pools should have totalHashrate') + t.ok(data.pools.activeWorkers !== undefined, 'pools should have activeWorkers') + t.ok(data.pools.totalWorkers !== undefined, 'pools should have totalWorkers') + + t.pass('response structure is valid') + } catch (e) { + t.fail(`Request failed: ${e.message || e}`) + } + }) + }) + await main.test('Api: get permissions', async (n) => { const api = `${appNodeBaseUrl}${ENDPOINTS.PERMISSIONS}` await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) diff --git a/tests/unit/handlers/site.handlers.test.js b/tests/unit/handlers/site.handlers.test.js new file mode 100644 index 0000000..f328c7d --- /dev/null +++ b/tests/unit/handlers/site.handlers.test.js @@ -0,0 +1,208 @@ +'use strict' + +const test = require('brittle') +const { getSiteLiveStatus } = require('../../../workers/lib/server/handlers/site.handlers') + +function createMockCtx (tailLogMultiResponse, extDataResponse, globalConfigResponse) { + return { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLogMulti') return tailLogMultiResponse + if (method === 'getWrkExtData') return extDataResponse + if (method === 'getGlobalConfig') return globalConfigResponse + return {} + } + } + } +} + +test('getSiteLiveStatus - returns composed response with correct structure', async (t) => { + const tailLogMultiResponse = [ + // Key 0: miner stats + [{ hashrate_mhs_1m_sum_aggr: 601432498437, nominal_hashrate_mhs_sum_aggr: 741423000000, online_or_minor_error_miners_amount_aggr: 1850, not_mining_miners_amount_aggr: 23, offline_or_sleeping_miners_amount_aggr: 45, hashrate_mhs_1m_cnt_aggr: 1930, alerts_aggr: { critical: 8, high: 12, medium: 39 } }], + // Key 1: powermeter stats + [{ site_power_w: 16701560 }], + // Key 2: container stats + [{ container_nominal_miner_capacity_sum_aggr: 2000 }] + ] + + const extDataResponse = [ + { stats: { hashrate: 279670375560265, active_workers_count: 1823, worker_count: 1930 } } + ] + + const globalConfigResponse = { + nominalHashrate: 741423000000, + nominalPowerAvailability_MW: 22.5 + } + + const ctx = createMockCtx(tailLogMultiResponse, extDataResponse, globalConfigResponse) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.ok(result.hashrate, 'should have hashrate') + t.is(result.hashrate.value, 601432498437, 'hashrate value should match') + t.is(result.hashrate.nominal, 741423000000, 'hashrate nominal should match') + t.ok(result.hashrate.utilization > 0, 'hashrate utilization should be > 0') + + t.ok(result.power, 'should have power') + t.is(result.power.value, 16701560, 'power value should match') + t.is(result.power.nominal, 22500000, 'power nominal should be MW * 1000000') + + t.ok(result.efficiency, 'should have efficiency') + t.ok(result.efficiency.value > 0, 'efficiency value should be > 0') + + t.ok(result.miners, 'should have miners') + t.is(result.miners.online, 1850, 'miners online should match') + t.is(result.miners.error, 23, 'miners error should match') + t.is(result.miners.offline, 45, 'miners offline should match') + t.is(result.miners.total, 1930, 'miners total should match') + t.is(result.miners.containerCapacity, 2000, 'container capacity should match') + + t.ok(result.alerts, 'should have alerts') + t.is(result.alerts.critical, 8, 'critical alerts should match') + t.is(result.alerts.high, 12, 'high alerts should match') + t.is(result.alerts.medium, 39, 'medium alerts should match') + t.is(result.alerts.total, 59, 'total alerts should be sum') + + t.ok(result.pools, 'should have pools') + t.is(result.pools.totalHashrate, 279670375560265, 'pool hashrate should match') + t.is(result.pools.activeWorkers, 1823, 'active workers should match') + t.is(result.pools.totalWorkers, 1930, 'total workers should match') + + t.ok(result.ts, 'should have timestamp') + t.pass() +}) + +test('getSiteLiveStatus - handles empty ork responses', async (t) => { + const ctx = createMockCtx([], [], {}) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.hashrate.value, 0, 'hashrate should be 0') + t.is(result.power.value, 0, 'power should be 0') + t.is(result.efficiency.value, 0, 'efficiency should be 0') + t.is(result.miners.total, 0, 'miners total should be 0') + t.is(result.alerts.total, 0, 'alerts total should be 0') + t.is(result.pools.totalHashrate, 0, 'pool hashrate should be 0') + t.pass() +}) + +test('getSiteLiveStatus - computes utilization correctly', async (t) => { + const tailLogMultiResponse = [ + [{ hashrate_mhs_1m_sum_aggr: 500, nominal_hashrate_mhs_sum_aggr: 1000, online_or_minor_error_miners_amount_aggr: 0, not_mining_miners_amount_aggr: 0, offline_or_sleeping_miners_amount_aggr: 0, hashrate_mhs_1m_cnt_aggr: 0, alerts_aggr: {} }], + [{ site_power_w: 750 }], + [{ container_nominal_miner_capacity_sum_aggr: 0 }] + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], { nominalPowerAvailability_MW: 0.001 }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.hashrate.utilization, 50, 'hashrate utilization should be 50%') + t.is(result.power.utilization, 75, 'power utilization should be 75%') + t.pass() +}) + +test('getSiteLiveStatus - handles zero nominal values gracefully', async (t) => { + const tailLogMultiResponse = [ + [{ hashrate_mhs_1m_sum_aggr: 100 }], + [{ site_power_w: 200 }], + [{}] + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], { nominalPowerAvailability_MW: 0 }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.hashrate.utilization, 0, 'should return 0 when nominal hashrate is 0') + t.is(result.power.utilization, 0, 'should return 0 when nominal power is 0') + t.pass() +}) + +test('getSiteLiveStatus - aggregates multiple pool accounts', async (t) => { + const tailLogMultiResponse = [ + [{ hashrate_mhs_1m_sum_aggr: 0 }], + [{}], + [{}] + ] + + const extDataResponse = [ + { stats: { hashrate: 100, active_workers_count: 10, worker_count: 15 } }, + { stats: { hashrate: 200, active_workers_count: 20, worker_count: 25 } } + ] + + const ctx = createMockCtx(tailLogMultiResponse, extDataResponse, {}) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.pools.totalHashrate, 300, 'should sum pool hashrates') + t.is(result.pools.activeWorkers, 30, 'should sum active workers') + t.is(result.pools.totalWorkers, 40, 'should sum total workers') + t.pass() +}) + +test('getSiteLiveStatus - computes sleep miners from remainder', async (t) => { + const tailLogMultiResponse = [ + [{ + hashrate_mhs_1m_sum_aggr: 0, + online_or_minor_error_miners_amount_aggr: 80, + not_mining_miners_amount_aggr: 5, + offline_or_sleeping_miners_amount_aggr: 10, + hashrate_mhs_1m_cnt_aggr: 100 + }], + [{}], + [{}] + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.miners.online, 80, 'online should match') + t.is(result.miners.error, 5, 'error should match') + t.is(result.miners.offline, 10, 'offline should match') + t.is(result.miners.sleep, 5, 'sleep should be total - online - error - offline') + t.is(result.miners.total, 100, 'total should match') + t.pass() +}) + +test('getSiteLiveStatus - uses nominal_hashrate from taillog over globalConfig', async (t) => { + const tailLogMultiResponse = [ + [{ hashrate_mhs_1m_sum_aggr: 500, nominal_hashrate_mhs_sum_aggr: 1000 }], + [{}], + [{}] + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], { nominalHashrate: 2000 }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.hashrate.nominal, 1000, 'should prefer nominal from taillog aggr') + t.pass() +}) + +test('getSiteLiveStatus - falls back to globalConfig nominalHashrate', async (t) => { + const tailLogMultiResponse = [ + [{ hashrate_mhs_1m_sum_aggr: 500, nominal_hashrate_mhs_sum_aggr: 0 }], + [{}], + [{}] + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], { nominalHashrate: 2000 }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.hashrate.nominal, 2000, 'should fall back to globalConfig nominalHashrate') + t.pass() +}) diff --git a/tests/unit/routes/site.routes.test.js b/tests/unit/routes/site.routes.test.js new file mode 100644 index 0000000..9c15794 --- /dev/null +++ b/tests/unit/routes/site.routes.test.js @@ -0,0 +1,55 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +test('site routes - module structure', (t) => { + testModuleStructure(t, '../../../workers/lib/server/routes/site.routes.js', '/site') + t.pass() +}) + +test('site routes - route definitions', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/site.routes.js') + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/site/status/live'), 'should have site status live route') + + t.pass() +}) + +test('site routes - HTTP methods', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/site.routes.js') + + const siteStatusRoute = routes.find(r => r.url === '/auth/site/status/live') + t.is(siteStatusRoute.method, 'GET', 'site status live route should be GET') + + t.pass() +}) + +test('site routes - schema validation', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/site.routes.js') + + const siteStatusRoute = routes.find(r => r.url === '/auth/site/status/live') + t.ok(siteStatusRoute.schema, 'site status live route should have schema') + t.ok(siteStatusRoute.schema.querystring, 'should have querystring schema') + t.is(siteStatusRoute.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + + t.pass() +}) + +test('site routes - handler functions', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/site.routes.js') + testHandlerFunctions(t, routes, '/site') + t.pass() +}) + +test('site routes - onRequest functions', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/site.routes.js') + + routes.forEach(route => { + t.ok(typeof route.onRequest === 'function', `/site route ${route.url} should have onRequest function`) + }) + + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f1f407e..1636ae2 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -108,7 +108,9 @@ const ENDPOINTS = { THING_CONFIG: '/auth/thing-config', // WebSocket endpoint - WEBSOCKET: '/ws' + WEBSOCKET: '/ws', + + SITE_STATUS_LIVE: '/auth/site/status/live' } const HTTP_METHODS = { @@ -122,51 +124,21 @@ const HTTP_METHODS = { const OPERATIONS = { // Auth operations AUTH_USERINFO_READ: 'auth.userinfo.read', - AUTH_TOKEN_GENERATE: 'auth.token.generate', - AUTH_PERMISSIONS_READ: 'auth.permissions.read', - AUTH_EXT_DATA_READ: 'auth.extData.read', // User operations - USER_READ: 'user.read', USER_CREATE: 'user.create', USER_UPDATE: 'user.update', USER_DELETE: 'user.delete', - // Global operations - GLOBAL_CONFIG_READ: 'global.config.read', - GLOBAL_CONFIG_WRITE: 'global.config.write', - GLOBAL_DATA_READ: 'global.data.read', - GLOBAL_DATA_WRITE: 'global.data.write', - GLOBAL_FEATURE_CONFIG_READ: 'global.featureConfig.read', - GLOBAL_FEATURES_READ: 'global.features.read', - GLOBAL_FEATURES_WRITE: 'global.features.write', - GLOBAL_SITE_CONFIG_READ: 'global.siteConfig.read', - // Actions operations ACTIONS_QUERY: 'actions.query', - ACTIONS_BATCH_QUERY: 'actions.batch.query', - ACTIONS_SINGLE_READ: 'actions.single.read', ACTIONS_VOTING: 'actions.voting', ACTIONS_VOTING_BATCH: 'actions.voting.batch', ACTIONS_VOTE: 'actions.vote', ACTIONS_CANCEL: 'actions.cancel', - // Logs operations - LOGS_TAIL_READ: 'logs.tail.read', - LOGS_TAIL_MULTI_READ: 'logs.tail.multi.read', - LOGS_TAIL_RANGE_AGGR_READ: 'logs.tail.range.aggr.read', - LOGS_HISTORY_READ: 'logs.history.read', - // Things operations - THINGS_LIST_READ: 'things.list.read', - RACKS_LIST_READ: 'racks.list.read', - THING_COMMENT_READ: 'thing.comment.read', - THING_COMMENT_WRITE: 'thing.comment.write', - THING_COMMENT_DELETE: 'thing.comment.delete', - THING_SETTINGS_READ: 'thing.settings.read', - THING_SETTINGS_WRITE: 'thing.settings.write', - WORKER_CONFIG_READ: 'worker.config.read', - THING_CONFIG_READ: 'thing.config.read' + THING_COMMENT_WRITE: 'thing.comment.write' } const DEFAULTS = { diff --git a/workers/lib/server/handlers/site.handlers.js b/workers/lib/server/handlers/site.handlers.js new file mode 100644 index 0000000..18066b5 --- /dev/null +++ b/workers/lib/server/handlers/site.handlers.js @@ -0,0 +1,292 @@ +'use strict' + +const { requestRpcMapLimit } = require('../../utils') + +/** + * Extracts the latest entry from a tail-log key result. + * tailLogMulti returns results per key in order. + * Each ork result is an array of key results, each key result is an array of entries. + * With limit=1, each key result has at most 1 entry. + * + * @param {Array} orkResult - Single ork's tailLogMulti response + * @param {number} keyIndex - Index of the key in the keys array + * @returns {Object|null} The latest entry for that key, or null + */ +function extractKeyEntry (orkResult, keyIndex) { + if (!Array.isArray(orkResult)) return null + const keyResult = orkResult[keyIndex] + if (!Array.isArray(keyResult) || keyResult.length === 0) return null + return keyResult[0] || null +} + +/** + * Aggregates miner stats from tailLogMulti results across all orks. + * Key index 0 = miner data (stat-rtd, type: miner) + * + * @param {Array} tailLogResults - Array of ork responses from tailLogMulti + * @returns {Object} Aggregated miner stats + */ +function aggregateMinerStats (tailLogResults) { + const stats = { + hashrate: 0, + nominalHashrate: 0, + online: 0, + error: 0, + offline: 0, + total: 0, + alerts: { critical: 0, high: 0, medium: 0 } + } + + for (const orkResult of tailLogResults) { + const entry = extractKeyEntry(orkResult, 0) + if (!entry) continue + + stats.hashrate += entry.hashrate_mhs_1m_sum_aggr || 0 + stats.nominalHashrate += entry.nominal_hashrate_mhs_sum_aggr || 0 + stats.online += entry.online_or_minor_error_miners_amount_aggr || 0 + stats.error += entry.not_mining_miners_amount_aggr || 0 + stats.offline += entry.offline_or_sleeping_miners_amount_aggr || 0 + stats.total += entry.hashrate_mhs_1m_cnt_aggr || 0 + + const alerts = entry.alerts_aggr + if (alerts && typeof alerts === 'object') { + stats.alerts.critical += alerts.critical || 0 + stats.alerts.high += alerts.high || 0 + stats.alerts.medium += alerts.medium || 0 + } + } + + return stats +} + +/** + * Extracts site power from powermeter tail-log results across all orks. + * Key index 1 = powermeter data (stat-rtd, type: powermeter) + * + * @param {Array} tailLogResults - Array of ork responses from tailLogMulti + * @returns {number} Total site power in Watts + */ +function aggregatePowerStats (tailLogResults) { + let sitePower = 0 + + for (const orkResult of tailLogResults) { + const entry = extractKeyEntry(orkResult, 1) + if (!entry) continue + sitePower += entry.site_power_w || 0 + } + + return sitePower +} + +/** + * Extracts container capacity from container tail-log results across all orks. + * Key index 2 = container data (stat-rtd, type: container) + * + * @param {Array} tailLogResults - Array of ork responses from tailLogMulti + * @returns {number} Total container nominal miner capacity + */ +function aggregateContainerCapacity (tailLogResults) { + let capacity = 0 + + for (const orkResult of tailLogResults) { + const entry = extractKeyEntry(orkResult, 2) + if (!entry) continue + capacity += entry.container_nominal_miner_capacity_sum_aggr || 0 + } + + return capacity +} + +/** + * Aggregates pool stats from ext-data minerpool results across all orks. + * + * @param {Array} poolDataResults - Array of ork responses from getWrkExtData + * @returns {Object} Aggregated pool stats + */ +function aggregatePoolStats (poolDataResults) { + const stats = { + totalHashrate: 0, + activeWorkers: 0, + totalWorkers: 0 + } + + for (const orkResult of poolDataResults) { + if (!Array.isArray(orkResult)) continue + for (const pool of orkResult) { + if (!pool || !pool.stats) continue + stats.totalHashrate += pool.stats.hashrate || 0 + stats.activeWorkers += pool.stats.active_workers_count || 0 + stats.totalWorkers += pool.stats.worker_count || 0 + } + } + + return stats +} + +/** + * Extracts nominal values from global config results. + * Merges across orks (typically only 1 ork has global config). + * + * @param {Array} globalConfigResults - Array of ork responses from getGlobalConfig + * @returns {Object} Nominal configuration values + */ +function extractGlobalConfig (globalConfigResults) { + const config = { + nominalHashrate: 0, + nominalPowerAvailability_MW: 0 + } + + for (const orkResult of globalConfigResults) { + if (!orkResult || typeof orkResult !== 'object') continue + if (orkResult.nominalHashrate) { config.nominalHashrate = orkResult.nominalHashrate } + if (orkResult.nominalPowerAvailability_MW) { + config.nominalPowerAvailability_MW = + orkResult.nominalPowerAvailability_MW + } + } + + return config +} + +/** + * Computes utilization percentage safely. + * + * @param {number} value - Current value + * @param {number} nominal - Nominal/max value + * @returns {number} Utilization percentage rounded to 1 decimal, or 0 if nominal is 0 + */ +function computeUtilization (value, nominal) { + if (!nominal || nominal === 0) return 0 + return Math.round((value / nominal) * 1000) / 10 +} + +/** + * Composes the site live status response from all data sources. + * + * @param {Array} tailLogResults - tailLogMulti RPC results + * @param {Array} poolDataResults - getWrkExtData (minerpool) RPC results + * @param {Array} globalConfigResults - getGlobalConfig RPC results + * @returns {Object} Composed site status response + */ +function composeSiteStatus ( + tailLogResults, + poolDataResults, + globalConfigResults +) { + const minerStats = aggregateMinerStats(tailLogResults) + const sitePower = aggregatePowerStats(tailLogResults) + const containerCapacity = aggregateContainerCapacity(tailLogResults) + const poolStats = aggregatePoolStats(poolDataResults) + const globalConfig = extractGlobalConfig(globalConfigResults) + + const nominalPowerW = globalConfig.nominalPowerAvailability_MW * 1000000 + const hashrateNominal = + minerStats.nominalHashrate || globalConfig.nominalHashrate || 0 + + const hashrateValue = minerStats.hashrate + const hashrateThs = hashrateValue / 1000000 + const efficiencyWPerTh = + hashrateThs > 0 ? Math.round((sitePower / hashrateThs) * 10) / 10 : 0 + + const sleep = Math.max( + 0, + minerStats.total - + minerStats.online - + minerStats.error - + minerStats.offline + ) + const alertTotal = + minerStats.alerts.critical + + minerStats.alerts.high + + minerStats.alerts.medium + + return { + hashrate: { + value: hashrateValue, + nominal: hashrateNominal, + utilization: computeUtilization(hashrateValue, hashrateNominal) + }, + power: { + value: sitePower, + nominal: nominalPowerW, + utilization: computeUtilization(sitePower, nominalPowerW) + }, + efficiency: { + value: efficiencyWPerTh + }, + miners: { + online: minerStats.online, + offline: minerStats.offline, + error: minerStats.error, + sleep, + total: minerStats.total, + containerCapacity + }, + alerts: { + critical: minerStats.alerts.critical, + high: minerStats.alerts.high, + medium: minerStats.alerts.medium, + total: alertTotal + }, + pools: poolStats, + ts: Date.now() + } +} + +/** + * GET /auth/site/status/live + * + * Returns a composite site status snapshot by aggregating: + * - tailLogMulti (miner hashrate/counts/alerts, powermeter power, container capacity) + * - getWrkExtData (pool hashrate, worker counts) + * - getGlobalConfig (nominal hashrate, nominal power availability) + * + * Replaces 5 separate frontend API calls with a single server-side composition. + */ +async function getSiteLiveStatus (ctx, req) { + const tailLogPayload = { + keys: [ + { key: 'stat-rtd', type: 'miner', tag: 't-miner' }, + { key: 'stat-rtd', type: 'powermeter', tag: 't-powermeter' }, + { key: 'stat-rtd', type: 'container', tag: 't-container' } + ], + limit: 1, + aggrFields: { + hashrate_mhs_1m_sum_aggr: 1, + nominal_hashrate_mhs_sum_aggr: 1, + alerts_aggr: 1, + online_or_minor_error_miners_amount_aggr: 1, + not_mining_miners_amount_aggr: 1, + offline_or_sleeping_miners_amount_aggr: 1, + hashrate_mhs_1m_cnt_aggr: 1, + site_power_w: 1, + container_nominal_miner_capacity_sum_aggr: 1 + } + } + + const poolPayload = { + type: 'minerpool', + query: { key: 'stats' } + } + + const globalConfigPayload = { + fields: { nominalHashrate: 1, nominalPowerAvailability_MW: 1 } + } + + const [tailLogResults, poolDataResults, globalConfigResults] = + await Promise.all([ + requestRpcMapLimit(ctx, 'tailLogMulti', tailLogPayload), + requestRpcMapLimit(ctx, 'getWrkExtData', poolPayload), + requestRpcMapLimit(ctx, 'getGlobalConfig', globalConfigPayload) + ]) + + return composeSiteStatus( + tailLogResults, + poolDataResults, + globalConfigResults + ) +} + +module.exports = { + getSiteLiveStatus +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index ac884da..112b509 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 siteRoutes = require('./routes/site.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), + ...siteRoutes(ctx) ] } diff --git a/workers/lib/server/routes/site.routes.js b/workers/lib/server/routes/site.routes.js new file mode 100644 index 0000000..461b894 --- /dev/null +++ b/workers/lib/server/routes/site.routes.js @@ -0,0 +1,26 @@ +'use strict' + +const { ENDPOINTS, HTTP_METHODS } = require('../../constants') +const { getSiteLiveStatus } = require('../handlers/site.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.SITE_STATUS_LIVE, + schema: { + querystring: { + type: 'object', + properties: { + overwriteCache: { type: 'boolean' } + } + } + }, + ...createCachedAuthRoute( + ctx, + ['site-status-live'], + ENDPOINTS.SITE_STATUS_LIVE, + getSiteLiveStatus + ) + } +] From baa5c513128e13fdb409dbd5cb1d65b0d3cc782a Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Tue, 17 Feb 2026 18:37:59 +0300 Subject: [PATCH 02/63] feat: add API v2 endpoints (finance, pools, pool-stats, pool-manager) (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add GET /auth/finance/energy-balance endpoint Add energy balance API endpoint that aggregates power consumption, pool transactions, BTC prices, and production costs into a unified time-series response with period-based aggregation (daily/monthly/yearly). Includes shared infrastructure: period.utils, constants for RPC methods, worker types, aggregation fields, and utility functions (getStartOfDay, safeDiv, runParallel). * feat: add GET /auth/pools/:pool/balance-history endpoint Add pool balance history API endpoint that fetches pool balance snapshots via tailLog RPC, groups them into time buckets (1D/1W/1M), and returns time-series data with balance and revenue per bucket. * tiny improvements * feat: add GET /auth/finance/ebitda endpoint Add EBITDA API endpoint that aggregates pool transactions, power/hashrate data, BTC prices, production costs, and block data to calculate selling and HODL EBITDA metrics with period-based aggregation. Includes shared infrastructure: period.utils, constants, and utility functions required for finance endpoints. * feat: add GET /auth/finance/cost-summary endpoint Add cost summary API endpoint that aggregates production costs, BTC prices, and power consumption to calculate energy and operational cost breakdowns with period-based aggregation. * 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. * feat: add GET /auth/pool-stats/aggregate endpoint Add pool stats aggregate API endpoint that fetches pool stats history and transactions, processes daily stats and revenue, and aggregates by period (daily/weekly/monthly) with optional pool filtering. * Fix costs bugs * feat: add curtailment, operational issues, and power utilization to energy-balance - Add ELECTRICITY worker type, ENERGY_AGGR/ACTIVE_ENERGY_IN/UTE_ENERGY aggr fields, and GLOBAL_CONFIG RPC method to constants - Add 3 new parallel RPC calls: electricity stats-history (active_energy_in, ute_energy) and getGlobalConfig (nominalPowerAvailability_MW) - Calculate curtailmentMWh, curtailmentRate, operationalIssuesRate, and powerUtilization per log entry - Add avgCurtailmentRate, avgOperationalIssuesRate, avgPowerUtilization to summary - Fix getProductionCosts to use ctx.globalDataLib instead of direct Hyperbee access - Fix processCostsData to return daily costs (energyCostPerDay, operationalCostPerDay) - Update tests to match new cost format and globalDataLib mock * feat: remove block data, fix costs processing, add site param to ebitda - Remove block data RPC call and processBlockData (not in spec) - Remove blockCount, difficulty, ebitdaMarginSelling, ebitdaMarginHodl from log - Fix processCostsData to handle flat objects from globalDataLib with daily costs - Replace direct globalDataLib.getGlobalData range query with getProductionCosts helper - Add site query param to schema, cache key, and handler for site-specific costs * feat: fix costs processing and add site param to cost-summary - Fix processCostsData to handle flat objects from globalDataLib with daily costs - Replace direct globalDataLib range query with getProductionCosts helper - Add site query param to schema, cache key, and handler for site-specific costs - Update tests to match new cost format * feat: add hashrate and remove snapshotCount from balance-history log entries Align pool balance-history endpoint with API v2 spec which requires balance, hashrate, and revenue time-series data. * feat: remove snapshotCount from pool-stats-aggregate log entries Align pool stats aggregate endpoint with API v2 spec which does not include snapshotCount in the response. * fix: use ext-data transactions instead of tail-log for pool stats aggregate tail-log returns empty for type=minerpool. Switch to ext-data with transactions key which has daily revenue and hashrate data. Also fix changed_balance handling (values are BTC, not satoshis). * fix: use ext-data transactions instead of tail-log for balance history tail-log returns empty for type=minerpool. Switch to ext-data with transactions key which provides daily revenue (changed_balance) and hashrate (mining_extra.hash_rate). Also treat 'all' pool param as no filter. * 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. * code cleanup * cleanup * cleanup * Ports pool manager from moria * Removes regions concept to align with site-focused nature of API v2 * chore: remove comments from pool manager files * Remove unncecessaey sute param and add integration tests * removes site * fix: remove site param from ebitda endpoint and add integration test Data is fetched for a single site, so the site parameter is unnecessary. Added security integration test for finance/ebitda endpoint. * fix: remove site param from cost-summary endpoint and add integration test Data is fetched for a single site, so the site parameter is unnecessary. Added security integration test for finance/cost-summary endpoint. * fix: rename filter to query param and use mingo projection for pools Align with list-things pattern by renaming filter→query. Replace manual sort and field filtering with mingo cursor methods for consistency. * fix: move pool filter to RPC payload for balance-history endpoint Pool name filter should be handled by the RPC worker, not filtered client-side. The tx username is the account id. * fix: move pool filter to RPC payload for pool-stats-aggregate endpoint Pool name filter should be handled by the RPC worker, not filtered client-side. The tx username is the account id. * Address PR comments * fix failing tests * Removes tags from projections * Fix integration tests * requestRpcMapAllPages to fetch all pages from orks * fix linting * Fix failing tests --- config/facs/auth.config.json.example | 11 +- tests/integration/api.poolManager.test.js | 377 +++++++++ tests/integration/api.security.test.js | 19 +- .../workers/test.dashboard.node.wrk.js | 5 + tests/unit/global.data.test.js | 14 +- tests/unit/handlers/finance.handlers.test.js | 571 +++++++++++++ tests/unit/handlers/pools.handlers.test.js | 479 +++++++++++ tests/unit/lib/constants.test.js | 2 +- tests/unit/lib/period.utils.test.js | 170 ++++ tests/unit/routes/finance.routes.test.js | 59 ++ tests/unit/routes/pools.routes.test.js | 41 + tests/unit/services.poolManager.test.js | 457 +++++++++++ workers/lib/constants.js | 127 ++- workers/lib/period.utils.js | 192 +++++ workers/lib/server/controllers/poolManager.js | 123 +++ .../lib/server/handlers/finance.handlers.js | 771 ++++++++++++++++++ workers/lib/server/handlers/pools.handlers.js | 303 +++++++ workers/lib/server/index.js | 6 + workers/lib/server/routes/finance.routes.js | 73 ++ .../lib/server/routes/poolManager.routes.js | 166 ++++ workers/lib/server/routes/pools.routes.js | 75 ++ workers/lib/server/schemas/finance.schemas.js | 38 + workers/lib/server/schemas/pools.schemas.js | 38 + workers/lib/server/services/poolManager.js | 409 ++++++++++ workers/lib/utils.js | 59 +- 25 files changed, 4567 insertions(+), 18 deletions(-) create mode 100644 tests/integration/api.poolManager.test.js create mode 100644 tests/integration/workers/test.dashboard.node.wrk.js create mode 100644 tests/unit/handlers/finance.handlers.test.js create mode 100644 tests/unit/handlers/pools.handlers.test.js create mode 100644 tests/unit/lib/period.utils.test.js create mode 100644 tests/unit/routes/finance.routes.test.js create mode 100644 tests/unit/routes/pools.routes.test.js create mode 100644 tests/unit/services.poolManager.test.js create mode 100644 workers/lib/period.utils.js create mode 100644 workers/lib/server/controllers/poolManager.js create mode 100644 workers/lib/server/handlers/finance.handlers.js create mode 100644 workers/lib/server/handlers/pools.handlers.js create mode 100644 workers/lib/server/routes/finance.routes.js create mode 100644 workers/lib/server/routes/poolManager.routes.js create mode 100644 workers/lib/server/routes/pools.routes.js create mode 100644 workers/lib/server/schemas/finance.schemas.js create mode 100644 workers/lib/server/schemas/pools.schemas.js create mode 100644 workers/lib/server/services/poolManager.js diff --git a/config/facs/auth.config.json.example b/config/facs/auth.config.json.example index 369a204..7dbd3ae 100644 --- a/config/facs/auth.config.json.example +++ b/config/facs/auth.config.json.example @@ -23,7 +23,8 @@ "reporting:rw", "settings:rw", "ticket:rw", - "power_spot_forecast:rw" + "power_spot_forecast:rw", + "pool_manager:rw" ], "roles": { "admin": [ @@ -46,7 +47,8 @@ "reporting:rw", "settings:rw", "ticket:rw", - "power_spot_forecast:rw" + "power_spot_forecast:rw", + "pool_manager:rw" ], "reporting_tool_manager": [ "revenue:rw", @@ -136,8 +138,9 @@ "comments:r", "settings:r", "ticket:r", - "alerts:r" - ] + "alerts:r", + "pool_manager:r" + ] }, "roleManagement": { "admin": [ diff --git a/tests/integration/api.poolManager.test.js b/tests/integration/api.poolManager.test.js new file mode 100644 index 0000000..6f786f4 --- /dev/null +++ b/tests/integration/api.poolManager.test.js @@ -0,0 +1,377 @@ +'use strict' + +const test = require('brittle') +const fs = require('fs') +const { createWorker } = require('tether-svc-test-helper').worker +const { setTimeout: sleep } = require('timers/promises') +const HttpFacility = require('bfx-facs-http') + +test('Pool Manager API', { timeout: 90000 }, async (main) => { + const baseDir = 'tests/integration' + let worker + let httpClient + const appNodePort = 5001 + const ip = '127.0.0.1' + const appNodeBaseUrl = `http://${ip}:${appNodePort}` + const testUser = 'poolmanager@test' + const encoding = 'json' + + main.teardown(async () => { + await httpClient.stop() + await worker.stop() + await sleep(2000) + fs.rmSync(`./${baseDir}/store`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/status`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/config`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/db`, { recursive: true, force: true }) + }) + + const createConfig = () => { + if (!fs.existsSync(`./${baseDir}/config/facs`)) { + if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`) + fs.mkdirSync(`./${baseDir}/config/facs`) + } + if (!fs.existsSync(`./${baseDir}/db`)) fs.mkdirSync(`./${baseDir}/db`) + + const commonConf = { + dir_log: 'logs', + debug: 0, + orks: { 'cluster-1': { region: 'AB', rpcPublicKey: '' } }, + cacheTiming: {}, + featureConfig: {} + } + const netConf = { r0: {} } + const httpdConf = { h0: {} } + const httpdOauthConf = { + h0: { + method: 'google', + credentials: { client: { id: 'i', secret: 's' } }, + users: [{ email: testUser, write: true }] + } + } + const authConf = require('../../config/facs/auth.config.json') + fs.writeFileSync(`./${baseDir}/config/common.json`, JSON.stringify(commonConf)) + fs.writeFileSync(`./${baseDir}/config/facs/net.config.json`, JSON.stringify(netConf)) + fs.writeFileSync(`./${baseDir}/config/facs/httpd.config.json`, JSON.stringify(httpdConf)) + fs.writeFileSync(`./${baseDir}/config/facs/httpd-oauth2.config.json`, JSON.stringify(httpdOauthConf)) + fs.writeFileSync(`./${baseDir}/config/facs/auth.config.json`, JSON.stringify(authConf)) + } + + const mockMiners = [ + { + id: 'miner-001', + info: { model: 'Antminer S19 XP', ip_address: '192.168.1.100' }, + snap: { + ts: Date.now(), + config: { + pool_config: [ + { url: 'stratum+tcp://btc.f2pool.com:3333', username: 'tether.worker1' } + ] + }, + stats: { + status: 'mining', + pool_status: [{ pool: 'btc.f2pool.com:3333', status: 'Alive', accepted: 100, rejected: 1 }], + hashrate_mhs: { t_5m: 140000 } + } + }, + tags: { unit: 'unit-A', rack: 'rack-1' }, + alerts: {} + }, + { + id: 'miner-002', + info: { model: 'Antminer S19 XP', ip_address: '192.168.1.101' }, + snap: { + ts: Date.now(), + config: { + pool_config: [ + { url: 'stratum+tcp://btc.f2pool.com:3333', username: 'tether.worker2' } + ] + }, + stats: { + status: 'mining', + pool_status: [{ pool: 'btc.f2pool.com:3333', status: 'Alive', accepted: 150, rejected: 2 }], + hashrate_mhs: { t_5m: 145000 } + } + }, + tags: { unit: 'unit-A', rack: 'rack-1' }, + alerts: {} + }, + { + id: 'miner-003', + info: { model: 'Whatsminer M50S', ip_address: '192.168.1.102' }, + snap: { + ts: Date.now(), + config: { + pool_config: [ + { url: 'stratum+tcp://ocean.xyz:3333', username: 'tether.worker3' } + ] + }, + stats: { + status: 'mining', + pool_status: [{ pool: 'ocean.xyz:3333', status: 'Alive', accepted: 200, rejected: 3 }], + hashrate_mhs: { t_5m: 130000 } + } + }, + tags: { unit: 'unit-B', rack: 'rack-2' }, + alerts: { wrong_miner_pool: { ts: Date.now() } } + } + ] + + const startWorker = async () => { + worker = createWorker({ + env: 'test', + wtype: 'wrk-node-dashboard-test', + rack: 'test-rack', + tmpdir: baseDir, + storeDir: 'test-store', + serviceRoot: `${process.cwd()}/${baseDir}`, + port: appNodePort + }) + + await worker.start() + worker.worker.net_r0.jRequest = (publicKey, method, params) => { + if (method === 'listThings') { + return Promise.resolve(mockMiners) + } + if (method === 'applyThings') { + return Promise.resolve({ success: true, affected: params.query?.id?.$in?.length || 0 }) + } + if (method === 'getWrkExtData') { + return Promise.resolve([{ + stats: [ + { + poolType: 'f2pool', + username: 'tether.worker1', + hashrate: 285000, + hashrate_1h: 280000, + hashrate_24h: 275000, + worker_count: 3, + active_workers_count: 2, + balance: 0.005, + unsettled: 0.001, + revenue_24h: 0.0002, + yearlyBalances: [], + timestamp: Date.now() + } + ] + }]) + } + return Promise.resolve([]) + } + } + + const createHttpClient = async () => { + httpClient = new HttpFacility({}, { ns: 'c0', timeout: 30000, debug: false }, { env: 'test' }) + await httpClient.start() + } + + const getTestToken = async (email) => { + worker.worker.authLib._auth.addHandlers({ + google: () => { return { email } } + }) + const token = await worker.worker.auth_a0.authCallbackHandler('google', { ip }) + return token + } + + createConfig() + await startWorker() + await createHttpClient() + await sleep(2000) + + const baseParams = 'regions=["AB"]' + + await main.test('Api: auth/pool-manager/stats', async (n) => { + const api = `${appNodeBaseUrl}/auth/pool-manager/stats?${baseParams}` + + await n.test('api should fail for missing auth token', async (t) => { + try { + await httpClient.get(api, { encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('api should succeed and return stats', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + try { + const res = await httpClient.get(api, { headers, encoding }) + t.ok(res.body) + t.ok(typeof res.body.totalPools === 'number') + t.ok(typeof res.body.totalWorkers === 'number') + t.ok(typeof res.body.errors === 'number') + t.pass() + } catch (e) { + console.error('Stats error:', e) + t.fail() + } + }) + }) + + await main.test('Api: auth/pool-manager/pools', async (n) => { + const api = `${appNodeBaseUrl}/auth/pool-manager/pools?${baseParams}` + + await n.test('api should fail for missing auth token', async (t) => { + try { + await httpClient.get(api, { encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('api should succeed and return pools list', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + try { + const res = await httpClient.get(api, { headers, encoding }) + t.ok(res.body) + t.ok(Array.isArray(res.body.pools)) + t.ok(typeof res.body.total === 'number') + if (res.body.pools.length > 0) { + t.ok(res.body.pools[0].pool) + t.ok(res.body.pools[0].name) + } + t.pass() + } catch (e) { + console.error('Pools error:', e) + t.fail() + } + }) + }) + + await main.test('Api: auth/pool-manager/miners', async (n) => { + const api = `${appNodeBaseUrl}/auth/pool-manager/miners?${baseParams}` + + await n.test('api should fail for missing auth token', async (t) => { + try { + await httpClient.get(api, { encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('api should succeed and return paginated miners', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + try { + const res = await httpClient.get(api, { headers, encoding }) + t.ok(res.body) + t.ok(Array.isArray(res.body.miners)) + t.ok(typeof res.body.total === 'number') + t.ok(typeof res.body.page === 'number') + t.ok(typeof res.body.limit === 'number') + t.pass() + } catch (e) { + console.error('Miners error:', e) + t.fail() + } + }) + + await n.test('api should support pagination params', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + const paginatedApi = `${api}&page=1&limit=10` + try { + const res = await httpClient.get(paginatedApi, { headers, encoding }) + t.is(res.body.page, 1) + t.is(res.body.limit, 10) + t.pass() + } catch (e) { + console.error('Pagination error:', e) + t.fail() + } + }) + }) + + await main.test('Api: auth/pool-manager/units', async (n) => { + const api = `${appNodeBaseUrl}/auth/pool-manager/units?${baseParams}` + + await n.test('api should fail for missing auth token', async (t) => { + try { + await httpClient.get(api, { encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('api should succeed and return units list', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + try { + const res = await httpClient.get(api, { headers, encoding }) + t.ok(res.body) + t.ok(Array.isArray(res.body.units)) + t.ok(typeof res.body.total === 'number') + t.pass() + } catch (e) { + console.error('Units error:', e) + t.fail() + } + }) + }) + + await main.test('Api: auth/pool-manager/alerts', async (n) => { + const api = `${appNodeBaseUrl}/auth/pool-manager/alerts?${baseParams}` + + await n.test('api should fail for missing auth token', async (t) => { + try { + await httpClient.get(api, { encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('api should succeed and return alerts list', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + try { + const res = await httpClient.get(api, { headers, encoding }) + t.ok(res.body) + t.ok(Array.isArray(res.body.alerts)) + t.ok(typeof res.body.total === 'number') + if (res.body.alerts.length > 0) { + t.ok(res.body.alerts[0].type) + t.ok(res.body.alerts[0].minerId) + t.ok(res.body.alerts[0].severity) + } + t.pass() + } catch (e) { + console.error('Alerts error:', e) + t.fail() + } + }) + }) + + await main.test('Api: auth/pool-manager/miners/assign', async (n) => { + const api = `${appNodeBaseUrl}/auth/pool-manager/miners/assign?${baseParams}` + const body = { + minerIds: ['miner-001', 'miner-002'] + } + + await n.test('api should fail for missing auth token', async (t) => { + try { + await httpClient.post(api, { body, encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('api should fail without pool_manager permission', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + try { + await httpClient.post(api, { body, headers, encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_POOL_MANAGER_PERM_REQUIRED'), true) + t.pass() + } + }) + }) +}) diff --git a/tests/integration/api.security.test.js b/tests/integration/api.security.test.js index 341e7f7..931513c 100644 --- a/tests/integration/api.security.test.js +++ b/tests/integration/api.security.test.js @@ -475,6 +475,16 @@ test('Api security', { timeout: 90000 }, async (main) => { await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) }) + await main.test('Api: get finance/ebitda', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.FINANCE_EBITDA}?start=1700000000000&end=1700100000000` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get finance/cost-summary', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.FINANCE_COST_SUMMARY}?start=1700000000000&end=1700100000000` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + await main.test('Api: get ext-data', async (n) => { const api = `${appNodeBaseUrl}${ENDPOINTS.EXT_DATA}?type=miner` await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) @@ -523,7 +533,7 @@ test('Api security', { timeout: 90000 }, async (main) => { await n.test('api should fail due to invalid permissions', async (t) => { await testEndpointWithAuthAndError(t, httpClient, 'post', api, readonlyUser, 'ERR_AUTH_FAIL_NO_PERMS', { - body: { data: { email: 'dev@test.test', role: 'dev' } }, + body: { data: { email: 'dev@test.test', role: 'read_only_user' } }, encoding }) }) @@ -537,7 +547,7 @@ test('Api security', { timeout: 90000 }, async (main) => { await n.test('api should succeed for valid permissions (admin)', async (t) => { await testEndpointWithAuth(t, httpClient, 'post', api, newCreatedUser, { - body: { data: { email: 'dev@test.test', role: 'dev' } }, + body: { data: { email: 'dev@test.test', role: 'read_only_user' } }, encoding }) }) @@ -625,6 +635,11 @@ test('Api security', { timeout: 90000 }, async (main) => { }) }) + await main.test('Api: get finance/energy-balance', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.FINANCE_ENERGY_BALANCE}?start=1700000000000&end=1700100000000` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + await main.test('Token expiration: api should fail due to token expiration', async (t) => { const api = `${appNodeBaseUrl}${ENDPOINTS.LIST_RACKS}?type=miner` worker.worker.auth_a0.conf.ttl = 5 diff --git a/tests/integration/workers/test.dashboard.node.wrk.js b/tests/integration/workers/test.dashboard.node.wrk.js new file mode 100644 index 0000000..728c295 --- /dev/null +++ b/tests/integration/workers/test.dashboard.node.wrk.js @@ -0,0 +1,5 @@ +'use strict' + +const WrkServerHttp = require('../../../workers/http.node.wrk') +class WrkDashboardNodeTest extends WrkServerHttp {} +module.exports = WrkDashboardNodeTest diff --git a/tests/unit/global.data.test.js b/tests/unit/global.data.test.js index 57fcd79..b52c26f 100644 --- a/tests/unit/global.data.test.js +++ b/tests/unit/global.data.test.js @@ -266,10 +266,10 @@ test('GlobalDataLib - setContainerSettingsData adds new setting to empty hashtab t.is(result, true, 'should return true') t.ok(typeof savedData === 'object', 'saved data should be an object') - t.ok(savedData['container-bd-d40_Test'], 'should have entry with model_site key') - t.is(savedData['container-bd-d40_Test'].model, 'container-bd-d40', 'should save correct model') - t.is(savedData['container-bd-d40_Test'].site, 'Test', 'should save correct site') - t.is(savedData['container-bd-d40_Test'].thresholds.oilTemperature.alarm, 45, 'should save correct threshold') + t.ok(savedData['container-bd-d40_test-site'], 'should have entry with model_site key') + t.is(savedData['container-bd-d40_test-site'].model, 'container-bd-d40', 'should save correct model') + t.is(savedData['container-bd-d40_test-site'].site, 'Test', 'should save correct site') + t.is(savedData['container-bd-d40_test-site'].thresholds.oilTemperature.alarm, 45, 'should save correct threshold') t.pass() }) @@ -303,7 +303,7 @@ test('GlobalDataLib - setContainerSettingsData adds new setting to existing hash t.ok(typeof savedData === 'object', 'saved data should be an object') t.is(Object.keys(savedData).length, 2, 'hashtable should have two entries') t.is(savedData['existing-model_existing-site'].model, 'existing-model', 'should preserve existing entry') - t.is(savedData['new-model_new-site'].model, 'new-model', 'should add new entry') + t.is(savedData['new-model_test-site'].model, 'new-model', 'should add new entry') t.pass() }) @@ -324,7 +324,7 @@ test('GlobalDataLib - setContainerSettingsData updates existing setting by model }) } - const globalDataLib = new GlobalDataLib(mockBeeWithData, 'test-site') + const globalDataLib = new GlobalDataLib(mockBeeWithData, 'site-1') const data = { model: 'model-a', @@ -371,7 +371,7 @@ test('GlobalDataLib - setContainerSettingsData handles existing object data and t.ok(typeof savedData === 'object', 'saved data should be an object') t.is(Object.keys(savedData).length, 2, 'hashtable should have two entries (legacy + new)') t.is(savedData['legacy-model_legacy-site'].model, 'legacy-model', 'should preserve legacy entry') - t.is(savedData['new-model_new-site'].model, 'new-model', 'should add new entry') + t.is(savedData['new-model_test-site'].model, 'new-model', 'should add new entry') t.pass() }) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js new file mode 100644 index 0000000..702ea38 --- /dev/null +++ b/tests/unit/handlers/finance.handlers.test.js @@ -0,0 +1,571 @@ +'use strict' + +const test = require('brittle') +const { + getEnergyBalance, + processConsumptionData, + processTransactionData, + processPriceData, + extractCurrentPrice, + processCostsData, + calculateSummary, + getEbitda, + processTailLogData, + processEbitdaTransactions, + processEbitdaPrices, + extractEbitdaCurrentPrice, + calculateEbitdaSummary, + getCostSummary, + calculateCostSummary +} = require('../../../workers/lib/server/handlers/finance.handlers') + +// ==================== Energy Balance Tests ==================== + +test('getEnergyBalance - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ type: 'powermeter', data: [{ ts: dayTs, val: { site_power_w: 5000 } }], error: null }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return [{ ts: dayTs, transactions: [{ ts: dayTs, changed_balance: 0.5 }] }] + } + if (payload.query && payload.query.key === 'HISTORICAL_PRICES') { + return [{ ts: dayTs, priceUSD: 40000 }] + } + if (payload.query && payload.query.key === 'current_price') { + return [{ currentPrice: 40000 }] + } + if (payload.query && payload.query.key === 'stats-history') { + return [] + } + } + if (method === 'getGlobalConfig') { + return { nominalPowerAvailability_MW: 10 } + } + return {} + } + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getEnergyBalance(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.pass() +}) + +test('getEnergyBalance - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [], site: 'test-site' }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { end: 1700100000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEnergyBalance - missing end throws', async (t) => { + const mockCtx = { + conf: { orks: [], site: 'test-site' }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { start: 1700000000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEnergyBalance - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [], site: 'test-site' }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const mockReq = { query: { start: 1700100000000, end: 1700000000000 } } + + try { + await getEnergyBalance(mockCtx, mockReq, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getEnergyBalance - empty ork results', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => ({}) + }, + globalDataBee: { + sub: () => ({ + sub: () => ({ + createReadStream: () => (async function * () {})() + }) + }) + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getEnergyBalance(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.is(result.log.length, 0, 'log should be empty with no data') + t.pass() +}) + +test('processConsumptionData - processes daily data from ORK', (t) => { + const results = [ + [{ type: 'powermeter', data: [{ ts: 1700006400000, val: { site_power_w: 5000 } }], error: null }] + ] + + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].powerW, 5000, 'should extract power from val') + t.pass() +}) + +test('processConsumptionData - processes object-keyed data', (t) => { + const results = [ + [{ data: { 1700006400000: { site_power_w: 5000 } } }] + ] + + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processConsumptionData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processTransactionData - processes F2Pool data', (t) => { + const results = [ + [{ ts: 1700006400000, transactions: [{ created_at: 1700006400, changed_balance: 0.001 }] }] + ] + + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly as BTC') + t.pass() +}) + +test('processTransactionData - processes Ocean data', (t) => { + const results = [ + [{ ts: 1700006400000, transactions: [{ ts: 1700006400, satoshis_net_earned: 50000000 }] }] + ] + + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') + t.pass() +}) + +test('processTransactionData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processPriceData - processes mempool price data', (t) => { + const results = [ + [{ ts: 1700006400000, priceUSD: 40000 }] + ] + + const daily = processPriceData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key], 40000, 'should extract priceUSD') + t.pass() +}) + +test('extractCurrentPrice - extracts currentPrice from mempool data', (t) => { + const results = [ + [{ currentPrice: 42000, blockHeight: 900000 }] + ] + const price = extractCurrentPrice(results) + t.is(price, 42000, 'should extract currentPrice') + t.pass() +}) + +test('extractCurrentPrice - extracts priceUSD', (t) => { + const results = [ + [{ ts: 1700006400000, priceUSD: 42000 }] + ] + const price = extractCurrentPrice(results) + t.is(price, 42000, 'should extract priceUSD') + t.pass() +}) + +test('extractCurrentPrice - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const price = extractCurrentPrice(results) + t.is(price, 0, 'should return 0 for error results') + t.pass() +}) + +test('processCostsData - processes dashboard format (energyCostsUSD)', (t) => { + const costs = [ + { region: 'site1', year: 2023, month: 11, energyCostsUSD: 30000, operationalCostsUSD: 6000 } + ] + + const result = processCostsData(costs) + t.ok(result['2023-11'], 'should have month key') + t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)') + t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)') + t.pass() +}) + +test('processCostsData - processes app-node format (energyCost)', (t) => { + const costs = [ + { site: 'site1', year: 2023, month: 11, energyCost: 30000, operationalCost: 6000 } + ] + + const result = processCostsData(costs) + t.ok(result['2023-11'], 'should have month key') + t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)') + t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)') + t.pass() +}) + +test('processCostsData - handles non-array input', (t) => { + const result = processCostsData(null) + t.ok(typeof result === 'object', 'should return object') + t.is(Object.keys(result).length, 0, 'should be empty') + t.pass() +}) + +test('calculateSummary - calculates from log entries', (t) => { + const log = [ + { revenueBTC: 0.5, revenueUSD: 20000, totalCostUSD: 5000, profitUSD: 15000, consumptionMWh: 100 }, + { revenueBTC: 0.3, revenueUSD: 12000, totalCostUSD: 3000, profitUSD: 9000, consumptionMWh: 60 } + ] + + const summary = calculateSummary(log) + t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue') + t.is(summary.totalRevenueUSD, 32000, 'should sum USD revenue') + t.is(summary.totalCostUSD, 8000, 'should sum costs') + t.is(summary.totalProfitUSD, 24000, 'should sum profit') + t.is(summary.totalConsumptionMWh, 160, 'should sum consumption') + t.ok(summary.avgCostPerMWh !== null, 'should calculate avg cost per MWh') + t.ok(summary.avgRevenuePerMWh !== null, 'should calculate avg revenue per MWh') + t.pass() +}) + +test('calculateSummary - handles empty log', (t) => { + const summary = calculateSummary([]) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.totalRevenueUSD, 0, 'should be zero') + t.is(summary.avgCostPerMWh, null, 'should be null') + t.pass() +}) + +// ==================== EBITDA Tests ==================== + +test('getEbitda - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ data: { 1700006400000: { site_power_w: 5000, hashrate_mhs_5m_sum_aggr: 100000 } } }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return { data: [{ transactions: [{ ts: 1700006400000, changed_balance: 50000000 }] }] } + } + if (payload.query && payload.query.key === 'prices') { + return { data: [{ prices: [{ ts: 1700006400000, price: 40000 }] }] } + } + if (payload.query && payload.query.key === 'current_price') { + return { data: { USD: 40000 } } + } + } + return {} + } + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getEbitda(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.summary.currentBtcPrice !== undefined, 'summary should have currentBtcPrice') + t.pass() +}) + +test('getEbitda - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getEbitda(mockCtx, { query: { end: 1700100000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEbitda - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getEbitda(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getEbitda - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const result = await getEbitda(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('processTailLogData - processes power and hashrate', (t) => { + const results = [ + [{ data: { 1700006400000: { site_power_w: 5000, hashrate_mhs_5m_sum_aggr: 100000 } } }] + ] + + const daily = processTailLogData(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processTailLogData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processTailLogData(results) + t.is(Object.keys(daily).length, 0, 'should be empty for errors') + t.pass() +}) + +test('processEbitdaTransactions - processes valid data', (t) => { + const results = [ + [{ transactions: [{ ts: 1700006400000, changed_balance: 100000000 }] }] + ] + const daily = processEbitdaTransactions(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('processEbitdaPrices - processes valid data', (t) => { + const results = [ + [{ prices: [{ ts: 1700006400000, price: 40000 }] }] + ] + const daily = processEbitdaPrices(results) + t.ok(typeof daily === 'object', 'should return object') + t.pass() +}) + +test('extractEbitdaCurrentPrice - extracts numeric price', (t) => { + const results = [{ data: 42000 }] + t.is(extractEbitdaCurrentPrice(results), 42000, 'should extract numeric price') + t.pass() +}) + +test('extractEbitdaCurrentPrice - extracts object price', (t) => { + const results = [{ data: { USD: 42000 } }] + t.is(extractEbitdaCurrentPrice(results), 42000, 'should extract USD') + t.pass() +}) + +test('calculateEbitdaSummary - calculates from log entries', (t) => { + const log = [ + { revenueBTC: 0.5, revenueUSD: 20000, totalCostsUSD: 5000, ebitdaSelling: 15000, ebitdaHodl: 15000 }, + { revenueBTC: 0.3, revenueUSD: 12000, totalCostsUSD: 3000, ebitdaSelling: 9000, ebitdaHodl: 9000 } + ] + + const summary = calculateEbitdaSummary(log, 40000) + t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue') + t.is(summary.totalRevenueUSD, 32000, 'should sum USD revenue') + t.is(summary.totalCostsUSD, 8000, 'should sum costs') + t.is(summary.totalEbitdaSelling, 24000, 'should sum selling EBITDA') + t.is(summary.currentBtcPrice, 40000, 'should include current BTC price') + t.ok(summary.avgBtcProductionCost !== null, 'should calculate avg production cost') + t.pass() +}) + +test('calculateEbitdaSummary - handles empty log', (t) => { + const summary = calculateEbitdaSummary([], 40000) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.avgBtcProductionCost, null, 'should be null') + t.is(summary.currentBtcPrice, 40000, 'should include current price') + t.pass() +}) + +// ==================== Cost Summary Tests ==================== + +test('getCostSummary - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ data: { 1700006400000: { site_power_w: 5000 } } }] + } + if (method === 'getWrkExtData') { + return { data: [{ prices: [{ ts: 1700006400000, price: 40000 }] }] } + } + return {} + } + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getCostSummary(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.pass() +}) + +test('getCostSummary - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getCostSummary(mockCtx, { query: { end: 1700100000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getCostSummary - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getCostSummary(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getCostSummary - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const result = await getCostSummary(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('calculateCostSummary - calculates from log entries', (t) => { + const log = [ + { energyCostsUSD: 5000, operationalCostsUSD: 1000, totalCostsUSD: 6000, consumptionMWh: 100, btcPrice: 40000 }, + { energyCostsUSD: 3000, operationalCostsUSD: 600, totalCostsUSD: 3600, consumptionMWh: 60, btcPrice: 42000 } + ] + + const summary = calculateCostSummary(log) + t.is(summary.totalEnergyCostsUSD, 8000, 'should sum energy costs') + t.is(summary.totalOperationalCostsUSD, 1600, 'should sum operational costs') + t.is(summary.totalCostsUSD, 9600, 'should sum total costs') + t.is(summary.totalConsumptionMWh, 160, 'should sum consumption') + t.ok(summary.avgAllInCostPerMWh !== null, 'should calculate avg all-in cost') + t.ok(summary.avgBtcPrice !== null, 'should calculate avg BTC price') + t.pass() +}) + +test('calculateCostSummary - handles empty log', (t) => { + const summary = calculateCostSummary([]) + t.is(summary.totalCostsUSD, 0, 'should be zero') + t.is(summary.avgAllInCostPerMWh, null, 'should be null') + t.pass() +}) diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js new file mode 100644 index 0000000..3eddfa7 --- /dev/null +++ b/tests/unit/handlers/pools.handlers.test.js @@ -0,0 +1,479 @@ +'use strict' + +const test = require('brittle') +const { + getPools, + flattenPoolStats, + calculatePoolsSummary, + getPoolBalanceHistory, + flattenTransactionResults, + groupByBucket, + getPoolStatsAggregate, + processTransactionData, + calculateAggregateSummary +} = 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 === 'getWrkExtData') { + 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 [] + } + } + } + + 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.is(result.pools.length, 2, 'should have 2 pools') + t.is(result.summary.poolCount, 2, 'summary should count 2 pools') + t.pass() +}) + +test('getPools - with filter', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + 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: { 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') + t.is(result.pools[0].pool, 'f2pool', 'should match filter') + 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('flattenPoolStats - extracts pools from ext-data stats array', (t) => { + const results = [ + [{ + 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 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('flattenPoolStats - deduplicates pools across orks', (t) => { + const results = [ + [{ ts: '1770000000000', stats: [{ poolType: 'f2pool', username: 'user1', hashrate: 100 }] }], + [{ ts: '1770000000000', stats: [{ poolType: 'f2pool', username: 'user1', hashrate: 200 }] }] + ] + const pools = flattenPoolStats(results) + t.is(pools.length, 1, 'should deduplicate by poolType:username') + t.pass() +}) + +test('flattenPoolStats - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const pools = flattenPoolStats(results) + t.is(pools.length, 0, 'should be empty') + t.pass() +}) + +test('flattenPoolStats - handles non-array input', (t) => { + const pools = flattenPoolStats(null) + t.is(pools.length, 0, 'should return empty array') + 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() +}) + +test('getPoolBalanceHistory - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ + ts: '1700006400000', + transactions: [{ + username: 'user1', + changed_balance: 0.001, + mining_extra: { hash_rate: 611000000000000 } + }] + }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, range: '1D' }, + params: {} + } + + const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'should have entries') + const entry = result.log[0] + t.ok(entry.hashrate > 0, 'should include hashrate') + t.ok(entry.revenue > 0, 'should include revenue') + t.pass() +}) + +test('getPoolBalanceHistory - with pool filter', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001 }, + { username: 'user2', changed_balance: 0.002 } + ] + }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000 }, + params: { pool: 'user1' } + } + + const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.is(capturedPayload.query.pool, 'user1', 'should pass pool filter in RPC payload') + t.pass() +}) + +test('getPoolBalanceHistory - "all" pool filter returns all pools', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => { + return [{ + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001 }, + { username: 'user2', changed_balance: 0.002 } + ] + }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000 }, + params: { pool: 'all' } + } + + const result = await getPoolBalanceHistory(mockCtx, mockReq, {}) + t.ok(result.log.length > 0, 'should return entries for all pools') + t.pass() +}) + +test('getPoolBalanceHistory - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPoolBalanceHistory(mockCtx, { query: { end: 1700100000000 }, params: {} }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getPoolBalanceHistory - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPoolBalanceHistory(mockCtx, { query: { start: 1700100000000, end: 1700000000000 }, params: {} }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getPoolBalanceHistory - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getPoolBalanceHistory(mockCtx, { query: { start: 1700000000000, end: 1700100000000 }, params: {} }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('flattenTransactionResults - extracts daily entries from ext-data', (t) => { + const results = [ + [{ + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001, mining_extra: { hash_rate: 500000 } }, + { username: 'user2', changed_balance: 0.002, mining_extra: { hash_rate: 600000 } } + ] + }] + ] + const entries = flattenTransactionResults(results) + t.is(entries.length, 1, 'should have 1 daily entry') + t.ok(entries[0].revenue > 0, 'should have revenue') + t.ok(entries[0].hashrate > 0, 'should have hashrate') + t.pass() +}) + +test('flattenTransactionResults - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const entries = flattenTransactionResults(results) + t.is(entries.length, 0, 'should be empty for errors') + t.pass() +}) + +test('groupByBucket - groups by daily bucket', (t) => { + const entries = [ + { ts: 1700006400000, revenue: 100 }, + { ts: 1700050000000, revenue: 200 }, + { ts: 1700092800000, revenue: 300 } + ] + const bucketSize = 86400000 + const buckets = groupByBucket(entries, bucketSize) + t.ok(typeof buckets === 'object', 'should return object') + t.ok(Object.keys(buckets).length >= 1, 'should have at least one bucket') + t.pass() +}) + +test('groupByBucket - handles empty entries', (t) => { + const buckets = groupByBucket([], 86400000) + t.is(Object.keys(buckets).length, 0, 'should be empty') + t.pass() +}) + +test('groupByBucket - handles missing timestamps', (t) => { + const entries = [ + { revenue: 100 }, + { ts: 1700006400000, revenue: 200 } + ] + const buckets = groupByBucket(entries, 86400000) + t.ok(Object.keys(buckets).length >= 1, 'should skip items without ts') + t.pass() +}) + +test('getPoolStatsAggregate - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ + ts: '1700006400000', + transactions: [{ + username: 'user1', + changed_balance: 0.001, + mining_extra: { hash_rate: 611000000000000 } + }] + }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, range: 'daily' } + } + + const result = await getPoolStatsAggregate(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'should have entries') + t.ok(result.log[0].revenueBTC > 0, 'should have revenue') + t.pass() +}) + +test('getPoolStatsAggregate - with pool filter', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001 }, + { username: 'user2', changed_balance: 0.002 } + ] + }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, pool: 'user1' } + } + + const result = await getPoolStatsAggregate(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log') + t.is(capturedPayload.query.pool, 'user1', 'should pass pool filter in RPC payload') + t.pass() +}) + +test('getPoolStatsAggregate - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPoolStatsAggregate(mockCtx, { query: { end: 1700100000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getPoolStatsAggregate - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPoolStatsAggregate(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getPoolStatsAggregate - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getPoolStatsAggregate(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('processTransactionData - processes valid transactions', (t) => { + const results = [ + [{ + ts: '1700006400000', + transactions: [ + { username: 'user1', changed_balance: 0.001, mining_extra: { hash_rate: 500000 } }, + { username: 'user2', changed_balance: 0.002, mining_extra: { hash_rate: 600000 } } + ] + }] + ] + const daily = processTransactionData(results) + t.ok(typeof daily === 'object', 'should return object') + const keys = Object.keys(daily) + t.ok(keys.length > 0, 'should have entries') + const entry = daily[keys[0]] + t.ok(entry.revenueBTC > 0, 'should have revenue') + t.ok(entry.hashrate > 0, 'should have hashrate') + t.pass() +}) + +test('processTransactionData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processTransactionData(results) + t.is(Object.keys(daily).length, 0, 'should be empty') + t.pass() +}) + +test('calculateAggregateSummary - calculates from log', (t) => { + const log = [ + { revenueBTC: 0.5, hashrate: 100, workerCount: 5, balance: 50000 }, + { revenueBTC: 0.3, hashrate: 200, workerCount: 10, balance: 60000 } + ] + + const summary = calculateAggregateSummary(log) + t.is(summary.totalRevenueBTC, 0.8, 'should sum revenue') + t.is(summary.avgHashrate, 150, 'should avg hashrate') + t.is(summary.avgWorkerCount, 7.5, 'should avg workers') + t.is(summary.latestBalance, 60000, 'should take latest balance') + t.is(summary.periodCount, 2, 'should count periods') + t.pass() +}) + +test('calculateAggregateSummary - handles empty log', (t) => { + const summary = calculateAggregateSummary([]) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.avgHashrate, 0, 'should be zero') + t.is(summary.periodCount, 0, 'should be zero') + t.pass() +}) diff --git a/tests/unit/lib/constants.test.js b/tests/unit/lib/constants.test.js index 95e4fd8..c5d70ba 100644 --- a/tests/unit/lib/constants.test.js +++ b/tests/unit/lib/constants.test.js @@ -126,7 +126,7 @@ test('constants - OPERATIONS', (t) => { t.is(OPERATIONS.USER_UPDATE, 'user.update', 'should have user update operation') t.is(OPERATIONS.ACTIONS_QUERY, 'actions.query', 'should have actions query operation') t.is(OPERATIONS.THING_COMMENT_WRITE, 'thing.comment.write', 'should have thing comment write operation') - t.ok(Object.keys(OPERATIONS).length >= 30, 'should have multiple operations') + t.ok(Object.keys(OPERATIONS).length >= 10, 'should have multiple operations') t.pass() }) diff --git a/tests/unit/lib/period.utils.test.js b/tests/unit/lib/period.utils.test.js new file mode 100644 index 0000000..46ebaf4 --- /dev/null +++ b/tests/unit/lib/period.utils.test.js @@ -0,0 +1,170 @@ +'use strict' + +const test = require('brittle') +const { + getStartOfDay, + convertMsToSeconds, + getPeriodEndDate, + aggregateByPeriod, + getPeriodKey, + isTimestampInPeriod, + getFilteredPeriodData +} = require('../../../workers/lib/period.utils') + +test('getStartOfDay - returns start of day timestamp', (t) => { + const ts = 1700050000000 + const result = getStartOfDay(ts) + t.ok(result <= ts, 'should be less than or equal to input') + t.is(result % 86400000, 0, 'should be divisible by 86400000') + t.pass() +}) + +test('getStartOfDay - already at start of day', (t) => { + const ts = 1700006400000 + const result = getStartOfDay(ts) + t.is(result, ts, 'should return same timestamp if already start of day') + t.pass() +}) + +test('aggregateByPeriod - returns log unchanged for daily period', (t) => { + const log = [ + { ts: 1700006400000, value: 10 }, + { ts: 1700092800000, value: 20 } + ] + const result = aggregateByPeriod(log, 'daily') + t.is(result.length, 2, 'should return same length') + t.alike(result, log, 'should return same entries') + t.pass() +}) + +test('aggregateByPeriod - aggregates monthly', (t) => { + const log = [ + { ts: 1700006400000, value: 10, region: 'us' }, + { ts: 1700092800000, value: 20, region: 'us' } + ] + const result = aggregateByPeriod(log, 'monthly') + t.ok(result.length >= 1, 'should have at least one aggregated entry') + t.ok(result[0].month, 'should have month field') + t.ok(result[0].year, 'should have year field') + t.pass() +}) + +test('aggregateByPeriod - aggregates yearly', (t) => { + const log = [ + { ts: 1700006400000, value: 10, region: 'us' }, + { ts: 1700092800000, value: 20, region: 'us' } + ] + const result = aggregateByPeriod(log, 'yearly') + t.ok(result.length >= 1, 'should have at least one aggregated entry') + t.ok(result[0].year, 'should have year field') + t.pass() +}) + +test('aggregateByPeriod - handles empty log', (t) => { + const result = aggregateByPeriod([], 'monthly') + t.is(result.length, 0, 'should return empty array') + t.pass() +}) + +test('aggregateByPeriod - handles invalid timestamps', (t) => { + const log = [ + { ts: 'invalid', value: 10 }, + { ts: 1700006400000, value: 20 } + ] + const result = aggregateByPeriod(log, 'monthly') + t.ok(result.length >= 1, 'should skip invalid entries') + t.pass() +}) + +test('getPeriodKey - daily returns start of day', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'daily') + t.is(result % 86400000, 0, 'should be start of day') + t.pass() +}) + +test('getPeriodKey - monthly returns start of month', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'monthly') + const date = new Date(result) + t.is(date.getDate(), 1, 'should be first day of month') + t.pass() +}) + +test('getPeriodKey - yearly returns start of year', (t) => { + const ts = 1700050000000 + const result = getPeriodKey(ts, 'yearly') + const date = new Date(result) + t.is(date.getMonth(), 0, 'should be January') + t.is(date.getDate(), 1, 'should be first day') + t.pass() +}) + +test('isTimestampInPeriod - daily exact match', (t) => { + const ts = 1700006400000 + t.ok(isTimestampInPeriod(ts, ts, 'daily'), 'should match exact timestamp') + t.ok(!isTimestampInPeriod(ts + 86400000, ts, 'daily'), 'should not match different day') + t.pass() +}) + +test('isTimestampInPeriod - monthly range', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const midMonth = new Date(2023, 10, 15).getTime() + const nextMonth = new Date(2023, 11, 1).getTime() + + t.ok(isTimestampInPeriod(midMonth, monthStart, 'monthly'), 'mid-month should be in period') + t.ok(!isTimestampInPeriod(nextMonth, monthStart, 'monthly'), 'next month should not be in period') + t.pass() +}) + +test('getFilteredPeriodData - daily returns direct lookup', (t) => { + const data = { 1700006400000: { value: 42 } } + const result = getFilteredPeriodData(data, 1700006400000, 'daily', () => null) + t.alike(result, { value: 42 }, 'should return data for timestamp') + t.pass() +}) + +test('getFilteredPeriodData - daily returns empty object for missing with default filterFn', (t) => { + const data = {} + const result = getFilteredPeriodData(data, 1700006400000, 'daily') + t.alike(result, {}, 'should return empty object for missing data with default filterFn') + t.pass() +}) + +test('getFilteredPeriodData - monthly filters with callback', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const day1 = new Date(2023, 10, 5).getTime() + const day2 = new Date(2023, 10, 15).getTime() + const data = { + [day1]: { value: 10 }, + [day2]: { value: 20 } + } + + const result = getFilteredPeriodData(data, monthStart, 'monthly', (entries) => { + return entries.reduce((sum, [, val]) => sum + val.value, 0) + }) + + t.is(result, 30, 'should sum values in period') + t.pass() +}) + +test('convertMsToSeconds - converts milliseconds to seconds', (t) => { + t.is(convertMsToSeconds(1700006400000), 1700006400, 'should convert ms to seconds') + t.is(convertMsToSeconds(1700006400500), 1700006400, 'should floor fractional seconds') + t.pass() +}) + +test('getPeriodEndDate - monthly returns next month', (t) => { + const monthStart = new Date(2023, 10, 1).getTime() + const result = getPeriodEndDate(monthStart, 'monthly') + t.is(result.getMonth(), 11, 'should be next month') + t.is(result.getFullYear(), 2023, 'should be same year') + t.pass() +}) + +test('getPeriodEndDate - yearly returns next year', (t) => { + const yearStart = new Date(2023, 0, 1).getTime() + const result = getPeriodEndDate(yearStart, 'yearly') + t.is(result.getFullYear(), 2024, 'should be next year') + t.pass() +}) diff --git a/tests/unit/routes/finance.routes.test.js b/tests/unit/routes/finance.routes.test.js new file mode 100644 index 0000000..07f2c9d --- /dev/null +++ b/tests/unit/routes/finance.routes.test.js @@ -0,0 +1,59 @@ +'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/finance.routes.js' + +test('finance routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'finance') + t.pass() +}) + +test('finance routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/finance/energy-balance'), 'should have energy-balance route') + t.ok(routeUrls.includes('/auth/finance/ebitda'), 'should have ebitda route') + t.ok(routeUrls.includes('/auth/finance/cost-summary'), 'should have cost-summary route') + + t.pass() +}) + +test('finance 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('finance routes - schema integration', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routesWithSchemas = routes.filter(route => route.schema) + routesWithSchemas.forEach(route => { + t.ok(route.schema, `route ${route.url} should have schema`) + if (route.schema.querystring) { + t.ok(typeof route.schema.querystring === 'object', `route ${route.url} querystring should be object`) + } + }) + + t.pass() +}) + +test('finance routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'finance') + t.pass() +}) + +test('finance routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'finance') + 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..c5e3ee6 --- /dev/null +++ b/tests/unit/routes/pools.routes.test.js @@ -0,0 +1,41 @@ +'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.ok(routeUrls.includes('/auth/pools/:pool/balance-history'), 'should have balance-history route') + t.ok(routeUrls.includes('/auth/pool-stats/aggregate'), 'should have pool-stats aggregate 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/tests/unit/services.poolManager.test.js b/tests/unit/services.poolManager.test.js new file mode 100644 index 0000000..9778333 --- /dev/null +++ b/tests/unit/services.poolManager.test.js @@ -0,0 +1,457 @@ +'use strict' + +const test = require('brittle') + +const { + getPoolStats, + getPoolConfigs, + getMinersWithPools, + getUnitsWithPoolData, + getPoolAlerts, + assignPoolToMiners, + setPowerMode +} = require('../../workers/lib/server/services/poolManager') + +function createMockCtx (responseData) { + return { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: (pk, method, payload) => { + if (Array.isArray(responseData) && typeof payload?.limit === 'number') { + const offset = payload.offset || 0 + const limit = payload.limit + return Promise.resolve(responseData.slice(offset, offset + limit)) + } + return Promise.resolve(responseData) + } + } + } +} + +function createMockPoolStatsResponse (pools) { + return [{ + stats: pools.map(p => ({ + poolType: p.poolType || 'f2pool', + username: p.username || 'worker1', + hashrate: p.hashrate || 100000, + hashrate_1h: p.hashrate_1h || 100000, + hashrate_24h: p.hashrate_24h || 95000, + worker_count: p.worker_count || 5, + active_workers_count: p.active_workers_count || 4, + balance: p.balance || 0.001, + unsettled: p.unsettled || 0, + revenue_24h: p.revenue_24h || 0.0001, + yearlyBalances: p.yearlyBalances || [], + timestamp: p.timestamp || Date.now() + })) + }] +} + +function createMockMiner (id, options = {}) { + return { + id, + code: options.code || 'AM-S19XP-0001', + type: options.type || 'miner-am-s19xp', + info: { + container: options.container || 'bitmain-imm-1', + serialNum: options.serialNum || 'HTM3X01', + nominalHashrateMhs: options.nominalHashrateMhs || 204000000 + }, + address: options.address || '192.168.1.100', + alerts: options.alerts || {} + } +} + +test('poolManager:getPoolStats returns correct aggregates', async function (t) { + const poolData = createMockPoolStatsResponse([ + { poolType: 'f2pool', username: 'worker1', hashrate: 100000, worker_count: 5, active_workers_count: 4, balance: 0.001 }, + { poolType: 'ocean', username: 'addr1', hashrate: 200000, worker_count: 10, active_workers_count: 8, balance: 0.002 } + ]) + + const mockCtx = createMockCtx(poolData) + + const result = await getPoolStats(mockCtx) + + t.is(result.totalPools, 2) + t.is(result.totalWorkers, 15) + t.is(result.activeWorkers, 12) + t.is(result.totalHashrate, 300000) + t.is(result.totalBalance, 0.003) + t.is(result.errors, 3) +}) + +test('poolManager:getPoolStats handles empty orks', async function (t) { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: () => Promise.resolve([]) } + } + + const result = await getPoolStats(mockCtx) + + t.is(result.totalPools, 0) + t.is(result.totalWorkers, 0) + t.is(result.activeWorkers, 0) + t.is(result.totalHashrate, 0) + t.is(result.totalBalance, 0) +}) + +test('poolManager:getPoolStats deduplicates pools by key', async function (t) { + const poolData = createMockPoolStatsResponse([ + { poolType: 'f2pool', username: 'worker1', worker_count: 5, active_workers_count: 4 } + ]) + + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }, { rpcPublicKey: 'key2' }] }, + net_r0: { jRequest: () => Promise.resolve(poolData) } + } + + const result = await getPoolStats(mockCtx) + + t.is(result.totalPools, 1) + t.is(result.totalWorkers, 5) +}) + +test('poolManager:getPoolConfigs returns pool objects', async function (t) { + const poolData = createMockPoolStatsResponse([ + { poolType: 'f2pool', username: 'worker1', hashrate: 100000, balance: 0.001 }, + { poolType: 'ocean', username: 'addr1', hashrate: 200000, balance: 0.002 } + ]) + + const mockCtx = createMockCtx(poolData) + + const result = await getPoolConfigs(mockCtx) + + t.ok(Array.isArray(result)) + t.is(result.length, 2) + + const f2pool = result.find(p => p.pool === 'f2pool') + t.ok(f2pool) + t.is(f2pool.name, 'worker1') + t.is(f2pool.account, 'worker1') + t.is(f2pool.hashrate, 100000) + t.is(f2pool.balance, 0.001) +}) + +test('poolManager:getPoolConfigs returns empty for no data', async function (t) { + const mockCtx = createMockCtx([]) + + const result = await getPoolConfigs(mockCtx) + + t.ok(Array.isArray(result)) + t.is(result.length, 0) +}) + +test('poolManager:getMinersWithPools returns paginated results', async function (t) { + const miners = [] + for (let i = 0; i < 100; i++) { + miners.push(createMockMiner(`miner-${i}`, { code: `AM-S19XP-${i}` })) + } + + const mockCtx = createMockCtx(miners) + + const result = await getMinersWithPools(mockCtx, { page: 1, limit: 10 }) + + t.is(result.miners.length, 10) + t.is(result.total, 100) + t.is(result.page, 1) + t.is(result.limit, 10) + t.is(result.totalPages, 10) +}) + +test('poolManager:getMinersWithPools fetches all pages from ork workers', async function (t) { + const miners = [] + for (let i = 0; i < 250; i++) { + miners.push(createMockMiner(`miner-${i}`, { code: `AM-S19XP-${i}` })) + } + + const requestLog = [] + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: (pk, method, payload) => { + requestLog.push({ offset: payload.offset, limit: payload.limit }) + const offset = payload.offset || 0 + const limit = payload.limit + return Promise.resolve(miners.slice(offset, offset + limit)) + } + } + } + + const result = await getMinersWithPools(mockCtx, { page: 1, limit: 50 }) + + t.is(result.total, 250, 'should fetch all 250 miners across multiple pages') + t.is(result.miners.length, 50, 'should return requested page size') + t.is(result.totalPages, 5) + t.is(requestLog.length, 3, 'should make 3 RPC calls (100+100+50)') + t.is(requestLog[0].offset, 0) + t.is(requestLog[1].offset, 100) + t.is(requestLog[2].offset, 200) +}) + +test('poolManager:getMinersWithPools extracts model from type', async function (t) { + const miners = [ + createMockMiner('m1', { type: 'miner-am-s19xp', code: 'AM-S19XP-001' }), + createMockMiner('m2', { type: 'miner-wm-m56s', code: 'WM-M56S-001' }), + createMockMiner('m3', { type: 'miner-av-a1346', code: 'AV-A1346-001' }) + ] + + const mockCtx = createMockCtx(miners) + + const result = await getMinersWithPools(mockCtx, {}) + + t.is(result.miners[0].model, 'Antminer S19XP') + t.is(result.miners[1].model, 'Whatsminer M56S') + t.is(result.miners[2].model, 'Avalon A1346') +}) + +test('poolManager:getMinersWithPools filters by search', async function (t) { + const miners = [ + createMockMiner('miner-1', { code: 'AM-S19XP-0001', serialNum: 'HTM3X01' }), + createMockMiner('miner-2', { code: 'WM-M56S-0002', serialNum: 'WMT001' }) + ] + + const mockCtx = createMockCtx(miners) + + const result = await getMinersWithPools(mockCtx, { search: 'S19XP' }) + + t.is(result.total, 1) + t.is(result.miners[0].id, 'miner-1') +}) + +test('poolManager:getMinersWithPools filters by model', async function (t) { + const miners = [ + createMockMiner('m1', { type: 'miner-am-s19xp' }), + createMockMiner('m2', { type: 'miner-wm-m56s' }) + ] + + const mockCtx = createMockCtx(miners) + + const result = await getMinersWithPools(mockCtx, { model: 'whatsminer' }) + + t.is(result.total, 1) + t.is(result.miners[0].model, 'Whatsminer M56S') +}) + +test('poolManager:getMinersWithPools maps thing fields correctly', async function (t) { + const miners = [createMockMiner('miner-1', { + code: 'AM-S19XP-0165', + type: 'miner-am-s19xp', + container: 'bitmain-imm-1', + address: '10.0.0.1', + serialNum: 'HTM3X10', + nominalHashrateMhs: 204000000 + })] + + const mockCtx = createMockCtx(miners) + + const result = await getMinersWithPools(mockCtx, {}) + + const miner = result.miners[0] + t.is(miner.id, 'miner-1') + t.is(miner.code, 'AM-S19XP-0165') + t.is(miner.type, 'miner-am-s19xp') + t.is(miner.model, 'Antminer S19XP') + t.is(miner.container, 'bitmain-imm-1') + t.is(miner.ipAddress, '10.0.0.1') + t.is(miner.serialNum, 'HTM3X10') + t.is(miner.nominalHashrate, 204000000) +}) + +test('poolManager:getUnitsWithPoolData groups miners by container', async function (t) { + const miners = [ + createMockMiner('m1', { container: 'bitmain-imm-1' }), + createMockMiner('m2', { container: 'bitmain-imm-1' }), + createMockMiner('m3', { container: 'bitdeer-4a' }) + ] + + const mockCtx = createMockCtx(miners) + + const result = await getUnitsWithPoolData(mockCtx) + + t.ok(Array.isArray(result)) + t.is(result.length, 2) + + const imm1 = result.find(u => u.name === 'bitmain-imm-1') + t.ok(imm1) + t.is(imm1.minersCount, 2) +}) + +test('poolManager:getUnitsWithPoolData sums nominal hashrate', async function (t) { + const miners = [ + createMockMiner('m1', { container: 'unit-A', nominalHashrateMhs: 100000 }), + createMockMiner('m2', { container: 'unit-A', nominalHashrateMhs: 150000 }) + ] + + const mockCtx = createMockCtx(miners) + + const result = await getUnitsWithPoolData(mockCtx) + + const unitA = result.find(u => u.name === 'unit-A') + t.is(unitA.nominalHashrate, 250000) +}) + +test('poolManager:getUnitsWithPoolData reads container from info only', async function (t) { + const miners = [ + createMockMiner('m1', { container: 'bitmain-imm-2' }) + ] + + const mockCtx = createMockCtx(miners) + + const result = await getUnitsWithPoolData(mockCtx) + + t.is(result.length, 1) + t.is(result[0].name, 'bitmain-imm-2') +}) + +test('poolManager:getUnitsWithPoolData assigns unassigned for no container', async function (t) { + const miners = [ + createMockMiner('m1', { container: undefined }) + ] + miners[0].info.container = undefined + + const mockCtx = createMockCtx(miners) + + const result = await getUnitsWithPoolData(mockCtx) + + t.is(result[0].name, 'unassigned') +}) + +test('poolManager:getPoolAlerts returns pool-related alerts', async function (t) { + const miners = [ + createMockMiner('miner-1', { + alerts: { + wrong_miner_pool: { ts: Date.now() }, + wrong_miner_subaccount: { ts: Date.now() } + } + }), + createMockMiner('miner-2', { + alerts: { all_pools_dead: { ts: Date.now() } } + }) + ] + + const mockCtx = createMockCtx(miners) + + const result = await getPoolAlerts(mockCtx) + + t.ok(Array.isArray(result)) + t.is(result.length, 3) +}) + +test('poolManager:getPoolAlerts respects limit', async function (t) { + const miners = [] + for (let i = 0; i < 10; i++) { + miners.push(createMockMiner(`miner-${i}`, { + type: 'miner-am-s19xp', + code: `AM-S19XP-${i}`, + alerts: { wrong_miner_pool: { ts: Date.now() - i * 1000 } } + })) + } + + const mockCtx = createMockCtx(miners) + + const result = await getPoolAlerts(mockCtx, { limit: 5 }) + + t.is(result.length, 5) +}) + +test('poolManager:getPoolAlerts includes severity', async function (t) { + const miners = [ + createMockMiner('miner-1', { + alerts: { all_pools_dead: { ts: Date.now() } } + }) + ] + + const mockCtx = createMockCtx(miners) + + const result = await getPoolAlerts(mockCtx) + + t.is(result[0].severity, 'critical') + t.is(result[0].type, 'all_pools_dead') +}) + +test('poolManager:assignPoolToMiners validates miner IDs', async function (t) { + const mockCtx = createMockCtx({ success: true }) + + await t.exception(async () => { + await assignPoolToMiners(mockCtx, []) + }, /ERR_MINER_IDS_REQUIRED/) +}) + +test('poolManager:assignPoolToMiners calls RPC with correct params', async function (t) { + let capturedParams + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: (pk, method, params) => { + capturedParams = params + return Promise.resolve({ success: true, affected: 2 }) + } + } + } + + const result = await assignPoolToMiners(mockCtx, ['miner-1', 'miner-2']) + + t.ok(capturedParams) + t.is(capturedParams.action, 'setupPools') + t.alike(capturedParams.query, { id: { $in: ['miner-1', 'miner-2'] } }) + t.is(capturedParams.params, undefined) + t.is(result.success, true) + t.is(result.assigned, 2) +}) + +test('poolManager:setPowerMode validates miner IDs', async function (t) { + const mockCtx = createMockCtx({ success: true }) + + await t.exception(async () => { + await setPowerMode(mockCtx, [], 'sleep') + }, /ERR_MINER_IDS_REQUIRED/) +}) + +test('poolManager:setPowerMode validates power mode', async function (t) { + const mockCtx = createMockCtx({ success: true }) + + await t.exception(async () => { + await setPowerMode(mockCtx, ['miner-1'], 'invalid-mode') + }, /ERR_INVALID_POWER_MODE/) +}) + +test('poolManager:setPowerMode calls RPC with correct params', async function (t) { + let capturedParams + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: (pk, method, params) => { + capturedParams = params + return Promise.resolve({ success: true, affected: 2 }) + } + } + } + + const result = await setPowerMode(mockCtx, ['miner-1', 'miner-2'], 'sleep') + + t.ok(capturedParams) + t.is(capturedParams.action, 'setPowerMode') + t.is(capturedParams.params.mode, 'sleep') + t.ok(result.success) + t.is(result.affected, 2) + t.is(result.mode, 'sleep') +}) + +test('poolManager:setPowerMode accepts all valid modes', async function (t) { + const validModes = ['low', 'normal', 'high', 'sleep'] + + for (const mode of validModes) { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: () => Promise.resolve({ success: true, affected: 1 }) + } + } + + const result = await setPowerMode(mockCtx, ['miner-1'], mode) + t.ok(result.success) + t.is(result.mode, mode) + } +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 1636ae2..a621419 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -110,6 +110,27 @@ const ENDPOINTS = { // WebSocket endpoint WEBSOCKET: '/ws', + // Finance endpoints + FINANCE_ENERGY_BALANCE: '/auth/finance/energy-balance', + FINANCE_EBITDA: '/auth/finance/ebitda', + FINANCE_COST_SUMMARY: '/auth/finance/cost-summary', + + // Pools endpoints + POOLS: '/auth/pools', + POOLS_BALANCE_HISTORY: '/auth/pools/:pool/balance-history', + + // Pool stats endpoints + POOL_STATS_AGGREGATE: '/auth/pool-stats/aggregate', + + // Pool Manager endpoints + POOL_MANAGER_STATS: '/auth/pool-manager/stats', + POOL_MANAGER_POOLS: '/auth/pool-manager/pools', + POOL_MANAGER_MINERS: '/auth/pool-manager/miners', + POOL_MANAGER_UNITS: '/auth/pool-manager/units', + POOL_MANAGER_ALERTS: '/auth/pool-manager/alerts', + POOL_MANAGER_ASSIGN: '/auth/pool-manager/miners/assign', + POOL_MANAGER_POWER_MODE: '/auth/pool-manager/miners/power-mode', + SITE_STATUS_LIVE: '/auth/site/status/live' } @@ -155,8 +176,96 @@ const STATUS_CODES = { INTERNAL_SERVER_ERROR: 500 } +const LIST_THINGS = 'listThings' +const APPLY_THINGS = 'applyThings' +const GET_HISTORICAL_LOGS = 'getHistoricalLogs' + +const RPC_METHODS = { + TAIL_LOG_RANGE_AGGR: 'tailLogCustomRangeAggr', + GET_WRK_EXT_DATA: 'getWrkExtData', + LIST_THINGS: 'listThings', + TAIL_LOG: 'tailLog', + GLOBAL_CONFIG: 'getGlobalConfig' +} + +const WORKER_TYPES = { + MINER: 'miner', + CONTAINER: 'container', + POWERMETER: 'powermeter', + MINERPOOL: 'minerpool', + MEMPOOL: 'mempool', + ELECTRICITY: 'electricity' +} + +const CACHE_KEYS = { + POOL_MANAGER_STATS: 'pool-manager/stats', + POOL_MANAGER_POOLS: 'pool-manager/pools', + POOL_MANAGER_MINERS: 'pool-manager/miners', + POOL_MANAGER_UNITS: 'pool-manager/units', + POOL_MANAGER_ALERTS: 'pool-manager/alerts' +} + +const POOL_ALERT_TYPES = [ + 'all_pools_dead', + 'wrong_miner_pool', + 'wrong_miner_subaccount', + 'wrong_worker_name', + 'ip_worker_name' +] + +const MINER_POOL_STATUS = { + ONLINE: 'online', + OFFLINE: 'offline', + INACTIVE: 'inactive' +} + +const POWER_MODES = { + LOW: 'low', + NORMAL: 'normal', + HIGH: 'high', + SLEEP: 'sleep' +} + +const AGGR_FIELDS = { + HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', + SITE_POWER: 'site_power_w', + ENERGY_AGGR: 'energy_aggr', + ACTIVE_ENERGY_IN: 'active_energy_in_aggr', + UTE_ENERGY: 'ute_energy_aggr' +} + +const PERIOD_TYPES = { + DAILY: 'daily', + WEEKLY: 'weekly', + MONTHLY: 'monthly', + YEARLY: 'yearly' +} + +const MINERPOOL_EXT_DATA_KEYS = { + TRANSACTIONS: 'transactions', + STATS: 'stats' +} + +const NON_METRIC_KEYS = [ + 'ts', + 'site', + 'year', + 'monthName', + 'month', + 'period' +] + +const BTC_SATS = 100000000 + +const RANGE_BUCKETS = { + '1D': 86400000, + '1W': 604800000, + '1M': 2592000000 +} + const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 +const RPC_PAGE_LIMIT = 100 module.exports = { SUPER_ADMIN_ROLE, @@ -174,5 +283,21 @@ module.exports = { STATUS_CODES, RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, - USER_SETTINGS_TYPE + RPC_PAGE_LIMIT, + USER_SETTINGS_TYPE, + LIST_THINGS, + APPLY_THINGS, + GET_HISTORICAL_LOGS, + RPC_METHODS, + WORKER_TYPES, + CACHE_KEYS, + POOL_ALERT_TYPES, + MINER_POOL_STATUS, + POWER_MODES, + AGGR_FIELDS, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + NON_METRIC_KEYS, + BTC_SATS, + RANGE_BUCKETS } diff --git a/workers/lib/period.utils.js b/workers/lib/period.utils.js new file mode 100644 index 0000000..5f206a4 --- /dev/null +++ b/workers/lib/period.utils.js @@ -0,0 +1,192 @@ +'use strict' + +const { PERIOD_TYPES, NON_METRIC_KEYS } = require('./constants') + +const getStartOfDay = (ts) => Math.floor(ts / 86400000) * 86400000 + +const convertMsToSeconds = (timestampMs) => { + return Math.floor(timestampMs / 1000) +} + +const PERIOD_CALCULATORS = { + daily: (timestamp) => getStartOfDay(timestamp), + weekly: (timestamp) => { + const date = new Date(timestamp) + const day = date.getUTCDay() + const diff = date.getUTCDate() - day + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), diff)).getTime() + }, + monthly: (timestamp) => { + const date = new Date(timestamp) + return new Date(date.getFullYear(), date.getMonth(), 1).getTime() + }, + yearly: (timestamp) => { + const date = new Date(timestamp) + return new Date(date.getFullYear(), 0, 1).getTime() + } +} + +const aggregateByPeriod = (log, period, nonMetricKeys = []) => { + if (period === PERIOD_TYPES.DAILY) { + return log + } + + const allNonMetricKeys = new Set([...NON_METRIC_KEYS, ...nonMetricKeys]) + + const grouped = log.reduce((acc, entry) => { + let date + try { + date = new Date(Number(entry.ts)) + + if (isNaN(date.getTime())) { + return acc + } + } catch (error) { + return acc + } + + let groupKey + + if (period === PERIOD_TYPES.MONTHLY) { + groupKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + } else if (period === PERIOD_TYPES.YEARLY) { + groupKey = `${date.getFullYear()}` + } else if (period === PERIOD_TYPES.WEEKLY) { + const day = date.getUTCDay() + const diff = date.getUTCDate() - day + const weekStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), diff)) + groupKey = `${weekStart.getTime()}` + } else { + groupKey = `${entry.ts}` + } + + if (!acc[groupKey]) { + acc[groupKey] = [] + } + acc[groupKey].push(entry) + return acc + }, {}) + + const aggregatedResults = Object.entries(grouped).map(([groupKey, entries]) => { + const aggregated = entries.reduce((acc, entry) => { + Object.entries(entry).forEach(([key, val]) => { + if (allNonMetricKeys.has(key)) { + if (!acc[key] || acc[key] === null || acc[key] === undefined) { + acc[key] = val + } + } else { + const numVal = Number(val) || 0 + acc[key] = (acc[key] || 0) + numVal + } + }) + return acc + }, {}) + + try { + if (period === PERIOD_TYPES.MONTHLY) { + const [year, month] = groupKey.split('-').map(Number) + + const newDate = new Date(year, month - 1, 1) + if (isNaN(newDate.getTime())) { + throw new Error(`Invalid date for monthly grouping: ${groupKey}`) + } + + aggregated.ts = newDate.getTime() + aggregated.month = month + aggregated.year = year + aggregated.monthName = newDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + const year = parseInt(groupKey) + + const newDate = new Date(year, 0, 1) + if (isNaN(newDate.getTime())) { + throw new Error(`Invalid date for yearly grouping: ${groupKey}`) + } + + aggregated.ts = newDate.getTime() + aggregated.year = year + } else if (period === PERIOD_TYPES.WEEKLY) { + aggregated.ts = Number(groupKey) + } + } catch (error) { + aggregated.ts = entries[0].ts + + try { + const fallbackDate = new Date(Number(entries[0].ts)) + if (!isNaN(fallbackDate.getTime())) { + if (period === PERIOD_TYPES.MONTHLY) { + aggregated.month = fallbackDate.getMonth() + 1 + aggregated.year = fallbackDate.getFullYear() + aggregated.monthName = fallbackDate.toLocaleString('en-US', { month: 'long' }) + } else if (period === PERIOD_TYPES.YEARLY) { + aggregated.year = fallbackDate.getFullYear() + } + } + } catch (fallbackError) { + console.warn('Could not extract date info from fallback timestamp', fallbackError) + } + } + + return aggregated + }) + + return aggregatedResults.sort((a, b) => Number(b.ts) - Number(a.ts)) +} + +const getPeriodKey = (timestamp, period) => { + const calculator = PERIOD_CALCULATORS[period] || PERIOD_CALCULATORS.daily + return calculator(timestamp) +} + +const getPeriodEndDate = (periodTs, period) => { + const periodEnd = new Date(periodTs) + + switch (period) { + case PERIOD_TYPES.WEEKLY: + periodEnd.setDate(periodEnd.getDate() + 7) + break + case PERIOD_TYPES.MONTHLY: + periodEnd.setMonth(periodEnd.getMonth() + 1) + break + case PERIOD_TYPES.YEARLY: + periodEnd.setFullYear(periodEnd.getFullYear() + 1) + break + } + + return periodEnd +} + +const isTimestampInPeriod = (timestamp, periodTs, period) => { + if (period === PERIOD_TYPES.DAILY) return timestamp === periodTs + + const periodEnd = getPeriodEndDate(periodTs, period) + return timestamp >= periodTs && timestamp < periodEnd.getTime() +} + +const getFilteredPeriodData = ( + sourceData, + periodTs, + period, + filterFn = (entries) => entries +) => { + if (period === PERIOD_TYPES.DAILY) { + return sourceData[periodTs] || (typeof filterFn === 'function' ? {} : 0) + } + + const entriesInPeriod = Object.entries(sourceData).filter(([tsStr]) => { + const timestamp = Number(tsStr) + return isTimestampInPeriod(timestamp, periodTs, period) + }) + + return filterFn(entriesInPeriod, sourceData) +} + +module.exports = { + getStartOfDay, + convertMsToSeconds, + getPeriodEndDate, + aggregateByPeriod, + getPeriodKey, + isTimestampInPeriod, + getFilteredPeriodData +} diff --git a/workers/lib/server/controllers/poolManager.js b/workers/lib/server/controllers/poolManager.js new file mode 100644 index 0000000..b6cca73 --- /dev/null +++ b/workers/lib/server/controllers/poolManager.js @@ -0,0 +1,123 @@ +'use strict' + +const poolManagerService = require('../services/poolManager') + +const getStats = async (ctx, req) => { + return poolManagerService.getPoolStats(ctx) +} + +const getPools = async (ctx, req) => { + const pools = await poolManagerService.getPoolConfigs(ctx) + + return { + pools, + total: pools.length + } +} + +const getMiners = async (ctx, req) => { + const filters = { + search: req.query.search, + status: req.query.status, + poolUrl: req.query.poolUrl, + model: req.query.model, + page: parseInt(req.query.page) || 1, + limit: parseInt(req.query.limit) || 50 + } + + return poolManagerService.getMinersWithPools(ctx, filters) +} + +const getUnits = async (ctx, req) => { + const units = await poolManagerService.getUnitsWithPoolData(ctx) + + return { + units, + total: units.length + } +} + +const getAlerts = async (ctx, req) => { + const filters = { + limit: parseInt(req.query.limit) || 50 + } + + const alerts = await poolManagerService.getPoolAlerts(ctx, filters) + + return { + alerts, + total: alerts.length + } +} + +const assignPool = async (ctx, req) => { + const { write } = await ctx.authLib.getTokenPerms(req._info.authToken) + if (!write) { + throw new Error('ERR_WRITE_PERM_REQUIRED') + } + + const hasPoolManagerPerm = await ctx.authLib.tokenHasPerms( + req._info.authToken, + true, + ['pool_manager:rw'] + ) + if (!hasPoolManagerPerm) { + throw new Error('ERR_POOL_MANAGER_PERM_REQUIRED') + } + + const { minerIds } = req.body + + if (!minerIds || !Array.isArray(minerIds) || minerIds.length === 0) { + throw new Error('ERR_MINER_IDS_REQUIRED') + } + + const auditInfo = { + user: req._info.user?.metadata?.email || 'unknown', + timestamp: Date.now() + } + + return poolManagerService.assignPoolToMiners(ctx, minerIds, auditInfo) +} + +const setPowerMode = async (ctx, req) => { + const { write } = await ctx.authLib.getTokenPerms(req._info.authToken) + if (!write) { + throw new Error('ERR_WRITE_PERM_REQUIRED') + } + + const hasPoolManagerPerm = await ctx.authLib.tokenHasPerms( + req._info.authToken, + true, + ['pool_manager:rw'] + ) + if (!hasPoolManagerPerm) { + throw new Error('ERR_POOL_MANAGER_PERM_REQUIRED') + } + + const { minerIds, mode } = req.body + + if (!minerIds || !Array.isArray(minerIds) || minerIds.length === 0) { + throw new Error('ERR_MINER_IDS_REQUIRED') + } + + if (!mode) { + throw new Error('ERR_POWER_MODE_REQUIRED') + } + + const auditInfo = { + user: req._info.user?.metadata?.email || 'unknown', + timestamp: Date.now() + } + + return poolManagerService.setPowerMode(ctx, minerIds, mode, auditInfo) +} + +module.exports = { + getStats, + getPools, + getMiners, + getUnits, + getAlerts, + assignPool, + setPowerMode +} diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js new file mode 100644 index 0000000..b0c8948 --- /dev/null +++ b/workers/lib/server/handlers/finance.handlers.js @@ -0,0 +1,771 @@ +'use strict' + +const { + WORKER_TYPES, + AGGR_FIELDS, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + RPC_METHODS, + BTC_SATS, + GLOBAL_DATA_TYPES +} = require('../../constants') +const { + requestRpcEachLimit, + getStartOfDay, + safeDiv, + runParallel +} = require('../../utils') +const { aggregateByPeriod } = require('../../period.utils') + +// ==================== Energy Balance ==================== + +async function getEnergyBalance (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const period = req.query.period || PERIOD_TYPES.DAILY + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const [ + consumptionResults, + transactionResults, + priceResults, + currentPriceResults, + productionCosts, + activeEnergyInResults, + uteEnergyResults, + globalConfigResults + ] = await runParallel([ + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [{ + type: WORKER_TYPES.POWERMETER, + startDate, + endDate, + fields: { [AGGR_FIELDS.SITE_POWER]: 1 }, + shouldReturnDailyData: 1 + }] + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MINERPOOL, + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'HISTORICAL_PRICES', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'current_price' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => getProductionCosts(ctx, null, start, end) + .then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GLOBAL_CONFIG, {}) + .then(r => cb(null, r)).catch(cb) + ]) + + const dailyConsumption = processConsumptionData(consumptionResults) + const dailyTransactions = processTransactionData(transactionResults) + const dailyPrices = processPriceData(priceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) + const costsByMonth = processCostsData(productionCosts) + const dailyActiveEnergyIn = processEnergyData(activeEnergyInResults, AGGR_FIELDS.ACTIVE_ENERGY_IN) + const dailyUteEnergy = processEnergyData(uteEnergyResults, AGGR_FIELDS.UTE_ENERGY) + const nominalPowerMW = extractNominalPower(globalConfigResults) + + const allDays = new Set([ + ...Object.keys(dailyConsumption), + ...Object.keys(dailyTransactions) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const consumption = dailyConsumption[dayTs] || {} + const transactions = dailyTransactions[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 + + const powerW = consumption.powerW || 0 + const powerMWh = (powerW * 24) / 1000000 + const revenueBTC = transactions.revenueBTC || 0 + const revenueUSD = revenueBTC * btcPrice + + const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const costs = costsByMonth[monthKey] || {} + const energyCostUSD = costs.energyCostPerDay || 0 + const totalCostUSD = energyCostUSD + (costs.operationalCostPerDay || 0) + + const activeEnergyIn = dailyActiveEnergyIn[dayTs] || 0 + const uteEnergy = dailyUteEnergy[dayTs] || 0 + const consumptionMWh = powerMWh + + const curtailmentMWh = activeEnergyIn > 0 + ? activeEnergyIn - consumptionMWh + : null + const curtailmentRate = curtailmentMWh !== null + ? safeDiv(curtailmentMWh, consumptionMWh) + : null + + const operationalIssuesRate = uteEnergy > 0 + ? safeDiv(uteEnergy - consumptionMWh, uteEnergy) + : null + + const actualPowerMW = powerW / 1000000 + const powerUtilization = nominalPowerMW > 0 + ? safeDiv(actualPowerMW, nominalPowerMW) + : null + + log.push({ + ts, + powerW, + consumptionMWh, + revenueBTC, + revenueUSD, + btcPrice, + energyCostUSD, + totalCostUSD, + energyRevenuePerMWh: safeDiv(revenueUSD, powerMWh), + allInCostPerMWh: safeDiv(totalCostUSD, powerMWh), + profitUSD: revenueUSD - totalCostUSD, + curtailmentMWh, + curtailmentRate, + operationalIssuesRate, + powerUtilization + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateSummary(aggregated) + + return { log: aggregated, summary } +} + +function processConsumptionData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry || entry.error) continue + const items = entry.data || entry.items || entry + if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp) + if (!ts) continue + if (!daily[ts]) daily[ts] = { powerW: 0 } + const val = item.val || item + daily[ts].powerW += (val[AGGR_FIELDS.SITE_POWER] || val.site_power_w || 0) + } + } else if (typeof items === 'object') { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!ts) continue + if (!daily[ts]) daily[ts] = { powerW: 0 } + const power = typeof val === 'object' ? (val[AGGR_FIELDS.SITE_POWER] || val.site_power_w || 0) : (Number(val) || 0) + daily[ts].powerW += power + } + } + } + } + return daily +} + +function normalizeTimestampMs (ts) { + if (!ts) return 0 + return ts < 1e12 ? ts * 1000 : ts +} + +function processTransactionData (results) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const tx of data) { + if (!tx) continue + const txList = tx.data || tx.transactions || tx + if (!Array.isArray(txList)) continue + for (const t of txList) { + if (!t) continue + const rawTs = t.ts || t.created_at || t.timestamp || t.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + if (!ts) continue + const day = daily[ts] ??= { revenueBTC: 0 } + if (t.satoshis_net_earned) { + day.revenueBTC += Math.abs(t.satoshis_net_earned) / BTC_SATS + } else { + day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) + } + } + } + } + return daily +} + +function processPriceData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const rawTs = entry.ts || entry.timestamp || entry.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + const price = entry.priceUSD || entry.price + if (ts && price) { + daily[ts] = price + } + } + } + return daily +} + +function extractCurrentPrice (results) { + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : [res] + for (const entry of data) { + if (!entry) continue + if (entry.currentPrice) return entry.currentPrice + if (entry.priceUSD) return entry.priceUSD + if (entry.price) return entry.price + } + } + return 0 +} + +function processEnergyData (results, aggrField) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = Array.isArray(entry) ? entry : (entry.data || entry) + if (Array.isArray(items)) { + for (const item of items) { + if (!item) continue + const ts = getStartOfDay(item.ts || item.timestamp) + if (!ts) continue + const energyAggr = item[AGGR_FIELDS.ENERGY_AGGR] + if (energyAggr && energyAggr[aggrField]) { + daily[ts] = (daily[ts] || 0) + Number(energyAggr[aggrField]) + } + } + } + } + } + return daily +} + +function extractNominalPower (results) { + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : [res] + for (const entry of data) { + if (!entry) continue + if (entry.nominalPowerAvailability_MW) return entry.nominalPowerAvailability_MW + } + } + return 0 +} + +function calculateSummary (log) { + if (!log.length) { + return { + totalRevenueBTC: 0, + totalRevenueUSD: 0, + totalCostUSD: 0, + totalProfitUSD: 0, + avgCostPerMWh: null, + avgRevenuePerMWh: null, + totalConsumptionMWh: 0, + avgCurtailmentRate: null, + avgOperationalIssuesRate: null, + avgPowerUtilization: null + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.revenueUSD += entry.revenueUSD || 0 + acc.costUSD += entry.totalCostUSD || 0 + acc.profitUSD += entry.profitUSD || 0 + acc.consumptionMWh += entry.consumptionMWh || 0 + if (entry.curtailmentRate !== null && entry.curtailmentRate !== undefined) { + acc.curtailmentRateSum += entry.curtailmentRate + acc.curtailmentRateCount++ + } + if (entry.operationalIssuesRate !== null && entry.operationalIssuesRate !== undefined) { + acc.operationalIssuesRateSum += entry.operationalIssuesRate + acc.operationalIssuesRateCount++ + } + if (entry.powerUtilization !== null && entry.powerUtilization !== undefined) { + acc.powerUtilizationSum += entry.powerUtilization + acc.powerUtilizationCount++ + } + return acc + }, { + revenueBTC: 0, + revenueUSD: 0, + costUSD: 0, + profitUSD: 0, + consumptionMWh: 0, + curtailmentRateSum: 0, + curtailmentRateCount: 0, + operationalIssuesRateSum: 0, + operationalIssuesRateCount: 0, + powerUtilizationSum: 0, + powerUtilizationCount: 0 + }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalRevenueUSD: totals.revenueUSD, + totalCostUSD: totals.costUSD, + totalProfitUSD: totals.profitUSD, + avgCostPerMWh: safeDiv(totals.costUSD, totals.consumptionMWh), + avgRevenuePerMWh: safeDiv(totals.revenueUSD, totals.consumptionMWh), + totalConsumptionMWh: totals.consumptionMWh, + avgCurtailmentRate: safeDiv(totals.curtailmentRateSum, totals.curtailmentRateCount), + avgOperationalIssuesRate: safeDiv(totals.operationalIssuesRateSum, totals.operationalIssuesRateCount), + avgPowerUtilization: safeDiv(totals.powerUtilizationSum, totals.powerUtilizationCount) + } +} + +// ==================== EBITDA ==================== + +async function getEbitda (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const period = req.query.period || PERIOD_TYPES.MONTHLY + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const [transactionResults, tailLogResults, priceResults, currentPriceResults, productionCosts] = await runParallel([ + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MINERPOOL, + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [ + { + type: WORKER_TYPES.POWERMETER, + startDate, + endDate, + fields: { [AGGR_FIELDS.SITE_POWER]: 1 }, + shouldReturnDailyData: 1 + }, + { + type: WORKER_TYPES.MINER, + startDate, + endDate, + fields: { [AGGR_FIELDS.HASHRATE_SUM]: 1 }, + shouldReturnDailyData: 1 + } + ] + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'prices', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'current_price' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => getProductionCosts(ctx, null, start, end) + .then(r => cb(null, r)).catch(cb) + ]) + + const dailyTransactions = processEbitdaTransactions(transactionResults) + const dailyTailLog = processTailLogData(tailLogResults) + const dailyPrices = processEbitdaPrices(priceResults) + const currentBtcPrice = extractEbitdaCurrentPrice(currentPriceResults) + const costsByMonth = processCostsData(productionCosts) + + const allDays = new Set([ + ...Object.keys(dailyTransactions), + ...Object.keys(dailyTailLog) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const transactions = dailyTransactions[dayTs] || {} + const tailLog = dailyTailLog[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 + + const revenueBTC = transactions.revenueBTC || 0 + const revenueUSD = revenueBTC * btcPrice + const powerW = tailLog.powerW || 0 + const hashrateMhs = tailLog.hashrateMhs || 0 + const powerMWh = (powerW * 24) / 1000000 + + const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const costs = costsByMonth[monthKey] || {} + const energyCostsUSD = costs.energyCostPerDay || 0 + const operationalCostsUSD = costs.operationalCostPerDay || 0 + const totalCostsUSD = energyCostsUSD + operationalCostsUSD + + const ebitdaSelling = revenueUSD - totalCostsUSD + const ebitdaHodl = (revenueBTC * currentBtcPrice) - totalCostsUSD + const btcProductionCost = safeDiv(totalCostsUSD, revenueBTC) + + log.push({ + ts, + revenueBTC, + revenueUSD, + btcPrice, + powerW, + hashrateMhs, + consumptionMWh: powerMWh, + energyCostsUSD, + operationalCostsUSD, + totalCostsUSD, + ebitdaSelling, + ebitdaHodl, + btcProductionCost + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateEbitdaSummary(aggregated, currentBtcPrice) + + return { log: aggregated, summary } +} + +function processTailLogData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.items || entry + if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!daily[ts]) daily[ts] = { powerW: 0, hashrateMhs: 0 } + if (typeof val === 'object') { + daily[ts].powerW += (val[AGGR_FIELDS.SITE_POWER] || 0) + daily[ts].hashrateMhs += (val[AGGR_FIELDS.HASHRATE_SUM] || 0) + } + } + } else if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp) + if (!daily[ts]) daily[ts] = { powerW: 0, hashrateMhs: 0 } + daily[ts].powerW += (item[AGGR_FIELDS.SITE_POWER] || 0) + daily[ts].hashrateMhs += (item[AGGR_FIELDS.HASHRATE_SUM] || 0) + } + } + } + } + return daily +} + +function processEbitdaTransactions (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const tx of data) { + if (!tx) continue + const txList = tx.data || tx.transactions || tx + if (!Array.isArray(txList)) continue + for (const t of txList) { + if (!t) continue + const ts = getStartOfDay(t.ts || t.timestamp || t.time) + if (!ts) continue + if (!daily[ts]) daily[ts] = { revenueBTC: 0 } + const amount = t.changed_balance || t.amount || t.value || 0 + daily[ts].revenueBTC += Math.abs(amount) / BTC_SATS + } + } + } + return daily +} + +function processEbitdaPrices (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.prices || entry + if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp || item.time) + if (ts && item.price) { + daily[ts] = item.price + } + } + } else if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (ts) { + daily[ts] = typeof val === 'object' ? (val.USD || val.price || 0) : Number(val) || 0 + } + } + } + } + } + return daily +} + +function extractEbitdaCurrentPrice (results) { + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res[0] : res + if (!data) continue + const price = data.data || data.result || data + if (typeof price === 'number') return price + if (typeof price === 'object') return price.USD || price.price || price.current_price || 0 + } + return 0 +} + +function calculateEbitdaSummary (log, currentBtcPrice) { + if (!log.length) { + return { + totalRevenueBTC: 0, + totalRevenueUSD: 0, + totalCostsUSD: 0, + totalEbitdaSelling: 0, + totalEbitdaHodl: 0, + avgBtcProductionCost: null, + currentBtcPrice: currentBtcPrice || 0 + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.revenueUSD += entry.revenueUSD || 0 + acc.costsUSD += entry.totalCostsUSD || 0 + acc.ebitdaSelling += entry.ebitdaSelling || 0 + acc.ebitdaHodl += entry.ebitdaHodl || 0 + return acc + }, { revenueBTC: 0, revenueUSD: 0, costsUSD: 0, ebitdaSelling: 0, ebitdaHodl: 0 }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalRevenueUSD: totals.revenueUSD, + totalCostsUSD: totals.costsUSD, + totalEbitdaSelling: totals.ebitdaSelling, + totalEbitdaHodl: totals.ebitdaHodl, + avgBtcProductionCost: safeDiv(totals.costsUSD, totals.revenueBTC), + currentBtcPrice: currentBtcPrice || 0 + } +} + +// ==================== Cost Summary ==================== + +async function getCostSummary (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const period = req.query.period || PERIOD_TYPES.MONTHLY + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const [productionCosts, priceResults, consumptionResults] = await runParallel([ + (cb) => getProductionCosts(ctx, null, start, end) + .then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'prices', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [{ + type: WORKER_TYPES.POWERMETER, + startDate, + endDate, + fields: { [AGGR_FIELDS.SITE_POWER]: 1 }, + shouldReturnDailyData: 1 + }] + }).then(r => cb(null, r)).catch(cb) + ]) + + const costsByMonth = processCostsData(productionCosts) + const dailyPrices = processEbitdaPrices(priceResults) + const dailyConsumption = processConsumptionData(consumptionResults) + + const allDays = new Set([ + ...Object.keys(dailyConsumption), + ...Object.keys(dailyPrices) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const consumption = dailyConsumption[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || 0 + + const powerW = consumption.powerW || 0 + const consumptionMWh = (powerW * 24) / 1000000 + + const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const costs = costsByMonth[monthKey] || {} + const energyCostsUSD = costs.energyCostPerDay || 0 + const operationalCostsUSD = costs.operationalCostPerDay || 0 + const totalCostsUSD = energyCostsUSD + operationalCostsUSD + + log.push({ + ts, + consumptionMWh, + energyCostsUSD, + operationalCostsUSD, + totalCostsUSD, + allInCostPerMWh: safeDiv(totalCostsUSD, consumptionMWh), + energyCostPerMWh: safeDiv(energyCostsUSD, consumptionMWh), + btcPrice + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateCostSummary(aggregated) + + return { log: aggregated, summary } +} + +function calculateCostSummary (log) { + if (!log.length) { + return { + totalEnergyCostsUSD: 0, + totalOperationalCostsUSD: 0, + totalCostsUSD: 0, + totalConsumptionMWh: 0, + avgAllInCostPerMWh: null, + avgEnergyCostPerMWh: null, + avgBtcPrice: null + } + } + + const totals = log.reduce((acc, entry) => { + acc.energyCosts += entry.energyCostsUSD || 0 + acc.operationalCosts += entry.operationalCostsUSD || 0 + acc.totalCosts += entry.totalCostsUSD || 0 + acc.consumption += entry.consumptionMWh || 0 + acc.btcPriceSum += entry.btcPrice || 0 + acc.btcPriceCount += entry.btcPrice ? 1 : 0 + return acc + }, { energyCosts: 0, operationalCosts: 0, totalCosts: 0, consumption: 0, btcPriceSum: 0, btcPriceCount: 0 }) + + return { + totalEnergyCostsUSD: totals.energyCosts, + totalOperationalCostsUSD: totals.operationalCosts, + totalCostsUSD: totals.totalCosts, + totalConsumptionMWh: totals.consumption, + avgAllInCostPerMWh: safeDiv(totals.totalCosts, totals.consumption), + avgEnergyCostPerMWh: safeDiv(totals.energyCosts, totals.consumption), + avgBtcPrice: safeDiv(totals.btcPriceSum, totals.btcPriceCount) + } +} + +// ==================== Shared ==================== + +async function getProductionCosts (ctx, site, start, end) { + if (!ctx.globalDataLib) return [] + const costs = await ctx.globalDataLib.getGlobalData({ + type: GLOBAL_DATA_TYPES.PRODUCTION_COSTS + }) + if (!Array.isArray(costs)) return [] + + const startDate = new Date(start) + const endDate = new Date(end) + return costs.filter(entry => { + if (!entry || !entry.year || !entry.month) return false + if (site && entry.site !== site) return false + const entryDate = new Date(entry.year, entry.month - 1, 1) + return entryDate >= startDate && entryDate <= endDate + }) +} + +function processCostsData (costs) { + const byMonth = {} + if (!Array.isArray(costs)) return byMonth + for (const entry of costs) { + if (!entry || !entry.year || !entry.month) continue + const key = `${entry.year}-${String(entry.month).padStart(2, '0')}` + const daysInMonth = new Date(entry.year, entry.month, 0).getDate() + byMonth[key] = { + energyCostPerDay: (entry.energyCost || entry.energyCostsUSD || 0) / daysInMonth, + operationalCostPerDay: (entry.operationalCost || entry.operationalCostsUSD || 0) / daysInMonth + } + } + return byMonth +} + +module.exports = { + getEnergyBalance, + getEbitda, + getCostSummary, + getProductionCosts, + processConsumptionData, + processTransactionData, + processPriceData, + extractCurrentPrice, + processEnergyData, + extractNominalPower, + processCostsData, + calculateSummary, + processTailLogData, + processEbitdaTransactions, + processEbitdaPrices, + extractEbitdaCurrentPrice, + calculateEbitdaSummary, + calculateCostSummary +} diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js new file mode 100644 index 0000000..0ec7ffe --- /dev/null +++ b/workers/lib/server/handlers/pools.handlers.js @@ -0,0 +1,303 @@ +'use strict' + +const mingo = require('mingo') +const { + RPC_METHODS, + WORKER_TYPES, + PERIOD_TYPES, + MINERPOOL_EXT_DATA_KEYS, + RANGE_BUCKETS +} = require('../../constants') +const { + requestRpcMapLimit, + requestRpcEachLimit, + parseJsonQueryParam, + getStartOfDay +} = require('../../utils') +const { aggregateByPeriod } = require('../../period.utils') + +async function getPools (ctx, req) { + 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 + + const statsResults = await requestRpcMapLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: 'minerpool', + query: { key: MINERPOOL_EXT_DATA_KEYS.STATS } + }) + + const pools = flattenPoolStats(statsResults) + + 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) + + return { pools: result, summary } +} + +function flattenPoolStats (results) { + const pools = [] + const seen = new Set() + if (!Array.isArray(results)) return pools + + for (const orkResult of results) { + if (!orkResult || orkResult.error) continue + const items = Array.isArray(orkResult) ? orkResult : (orkResult.data || orkResult.result || []) + 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 pools +} + +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 + } +} + +async function getPoolBalanceHistory (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const range = req.query.range || '1D' + const poolParam = req.params.pool || null + const poolFilter = poolParam === 'all' ? null : poolParam + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: 'minerpool', + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end, pool: poolFilter } + }) + + const dailyEntries = flattenTransactionResults(results) + + const bucketSize = RANGE_BUCKETS[range] || RANGE_BUCKETS['1D'] + const buckets = groupByBucket(dailyEntries, bucketSize) + + const log = Object.entries(buckets) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([ts, entries]) => { + const totalRevenue = entries.reduce((sum, e) => sum + (e.revenue || 0), 0) + const hashrates = entries.filter(e => e.hashrate > 0) + const avgHashrate = hashrates.length + ? hashrates.reduce((sum, e) => sum + e.hashrate, 0) / hashrates.length + : 0 + + return { + ts: Number(ts), + balance: totalRevenue, + hashrate: avgHashrate, + revenue: totalRevenue + } + }) + + return { log } +} + +function flattenTransactionResults (results) { + const daily = [] + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + + for (const entry of data) { + if (!entry) continue + const ts = Number(entry.ts) + if (!ts) continue + + const txs = entry.transactions || [] + if (!Array.isArray(txs) || txs.length === 0) continue + + let revenue = 0 + let hashrate = 0 + let hashCount = 0 + + for (const tx of txs) { + if (!tx) continue + revenue += Math.abs(tx.changed_balance || 0) + if (tx.mining_extra?.hash_rate) { + hashrate += tx.mining_extra.hash_rate + hashCount++ + } + } + + if (revenue === 0 && hashCount === 0) continue + + daily.push({ + ts: getStartOfDay(ts), + revenue, + hashrate: hashCount > 0 ? hashrate / hashCount : 0 + }) + } + } + + return daily +} + +function groupByBucket (entries, bucketSize) { + const buckets = {} + for (const entry of entries) { + const ts = entry.ts + if (!ts) continue + const bucketTs = Math.floor(ts / bucketSize) * bucketSize + if (!buckets[bucketTs]) buckets[bucketTs] = [] + buckets[bucketTs].push(entry) + } + return buckets +} + +async function getPoolStatsAggregate (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const range = req.query.range || PERIOD_TYPES.DAILY + const poolFilter = req.query.pool || null + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const transactionResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MINERPOOL, + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end, pool: poolFilter } + }) + + const dailyData = processTransactionData(transactionResults) + + const log = Object.entries(dailyData) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([ts, data]) => ({ + ts: Number(ts), + balance: data.revenueBTC, + hashrate: data.hashCount > 0 ? data.hashrate / data.hashCount : 0, + workerCount: 0, + revenueBTC: data.revenueBTC + })) + + const aggregated = aggregateByPeriod(log, range) + const summary = calculateAggregateSummary(aggregated) + + return { log: aggregated, summary } +} + +function processTransactionData (results) { + const daily = {} + for (const res of results) { + if (res.error || !res) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + + for (const entry of data) { + if (!entry) continue + const ts = getStartOfDay(Number(entry.ts)) + if (!ts) continue + + const txs = entry.transactions || [] + if (!Array.isArray(txs) || txs.length === 0) continue + + for (const tx of txs) { + if (!tx) continue + + if (!daily[ts]) daily[ts] = { revenueBTC: 0, hashrate: 0, hashCount: 0 } + daily[ts].revenueBTC += Math.abs(tx.changed_balance || 0) + if (tx.mining_extra?.hash_rate) { + daily[ts].hashrate += tx.mining_extra.hash_rate + daily[ts].hashCount++ + } + } + } + } + return daily +} + +function calculateAggregateSummary (log) { + if (!log.length) { + return { + totalRevenueBTC: 0, + avgHashrate: 0, + avgWorkerCount: 0, + latestBalance: 0, + periodCount: 0 + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.hashrate += entry.hashrate || 0 + acc.workerCount += entry.workerCount || 0 + return acc + }, { revenueBTC: 0, hashrate: 0, workerCount: 0 }) + + const latest = log[log.length - 1] + + return { + totalRevenueBTC: totals.revenueBTC, + avgHashrate: totals.hashrate / log.length, + avgWorkerCount: totals.workerCount / log.length, + latestBalance: latest.balance || 0, + periodCount: log.length + } +} + +module.exports = { + getPools, + flattenPoolStats, + calculatePoolsSummary, + getPoolBalanceHistory, + flattenTransactionResults, + groupByBucket, + getPoolStatsAggregate, + processTransactionData, + calculateAggregateSummary +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 112b509..840e405 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -8,6 +8,9 @@ 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 financeRoutes = require('./routes/finance.routes') +const poolsRoutes = require('./routes/pools.routes') +const poolManagerRoutes = require('./routes/poolManager.routes') const siteRoutes = require('./routes/site.routes') /** @@ -24,6 +27,9 @@ function routes (ctx) { ...usersRoutes(ctx), ...settingsRoutes(ctx), ...wsRoutes(ctx), + ...financeRoutes(ctx), + ...poolsRoutes(ctx), + ...poolManagerRoutes(ctx), ...siteRoutes(ctx) ] } diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js new file mode 100644 index 0000000..65970b0 --- /dev/null +++ b/workers/lib/server/routes/finance.routes.js @@ -0,0 +1,73 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getEnergyBalance, + getEbitda, + getCostSummary +} = require('../handlers/finance.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/finance.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_ENERGY_BALANCE, + schema: { + querystring: schemas.query.energyBalance + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/energy-balance', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_ENERGY_BALANCE, + getEnergyBalance + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_EBITDA, + schema: { + querystring: schemas.query.ebitda + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/ebitda', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_EBITDA, + getEbitda + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_COST_SUMMARY, + schema: { + querystring: schemas.query.costSummary + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/cost-summary', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_COST_SUMMARY, + getCostSummary + ) + } + ] +} diff --git a/workers/lib/server/routes/poolManager.routes.js b/workers/lib/server/routes/poolManager.routes.js new file mode 100644 index 0000000..ecc3cf3 --- /dev/null +++ b/workers/lib/server/routes/poolManager.routes.js @@ -0,0 +1,166 @@ +'use strict' + +const { + getStats, + getPools, + getMiners, + getUnits, + getAlerts, + assignPool, + setPowerMode +} = require('../controllers/poolManager') +const { ENDPOINTS, HTTP_METHODS } = require('../../constants') +const { createCachedAuthRoute, createAuthRoute } = require('../lib/routeHelpers') + +const POOL_MANAGER_MINERS_SCHEMA = { + querystring: { + type: 'object', + properties: { + search: { type: 'string' }, + status: { type: 'string' }, + poolUrl: { type: 'string' }, + model: { type: 'string' }, + page: { type: 'integer' }, + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + } + } +} + +const POOL_MANAGER_ALERTS_SCHEMA = { + querystring: { + type: 'object', + properties: { + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + } + } +} + +const POOL_MANAGER_CACHE_SCHEMA = { + querystring: { + type: 'object', + properties: { + overwriteCache: { type: 'boolean' } + } + } +} + +const POOL_MANAGER_ASSIGN_SCHEMA = { + body: { + type: 'object', + properties: { + minerIds: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['minerIds'] + } +} + +const POOL_MANAGER_POWER_MODE_SCHEMA = { + body: { + type: 'object', + properties: { + minerIds: { + type: 'array', + items: { type: 'string' } + }, + mode: { + type: 'string', + enum: ['low', 'normal', 'high', 'sleep'] + } + }, + required: ['minerIds', 'mode'] + } +} + +module.exports = (ctx) => { + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOL_MANAGER_STATS, + schema: POOL_MANAGER_CACHE_SCHEMA, + ...createCachedAuthRoute( + ctx, + ['pool-manager/stats'], + ENDPOINTS.POOL_MANAGER_STATS, + getStats + ) + }, + + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOL_MANAGER_POOLS, + schema: POOL_MANAGER_CACHE_SCHEMA, + ...createCachedAuthRoute( + ctx, + ['pool-manager/pools'], + ENDPOINTS.POOL_MANAGER_POOLS, + getPools + ) + }, + + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOL_MANAGER_MINERS, + schema: POOL_MANAGER_MINERS_SCHEMA, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'pool-manager/miners', + req.query.search, + req.query.status, + req.query.poolUrl, + req.query.model, + req.query.page, + req.query.limit + ], + ENDPOINTS.POOL_MANAGER_MINERS, + getMiners + ) + }, + + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOL_MANAGER_UNITS, + schema: POOL_MANAGER_CACHE_SCHEMA, + ...createCachedAuthRoute( + ctx, + ['pool-manager/units'], + ENDPOINTS.POOL_MANAGER_UNITS, + getUnits + ) + }, + + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOL_MANAGER_ALERTS, + schema: POOL_MANAGER_ALERTS_SCHEMA, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'pool-manager/alerts', + req.query.limit + ], + ENDPOINTS.POOL_MANAGER_ALERTS, + getAlerts + ) + }, + + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.POOL_MANAGER_ASSIGN, + schema: POOL_MANAGER_ASSIGN_SCHEMA, + ...createAuthRoute(ctx, assignPool) + }, + + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.POOL_MANAGER_POWER_MODE, + schema: POOL_MANAGER_POWER_MODE_SCHEMA, + ...createAuthRoute(ctx, setPowerMode) + } + ] +} diff --git a/workers/lib/server/routes/pools.routes.js b/workers/lib/server/routes/pools.routes.js new file mode 100644 index 0000000..097ee18 --- /dev/null +++ b/workers/lib/server/routes/pools.routes.js @@ -0,0 +1,75 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getPools, + getPoolBalanceHistory, + getPoolStatsAggregate +} = 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.query, + req.query.sort, + req.query.fields + ], + ENDPOINTS.POOLS, + getPools + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOLS_BALANCE_HISTORY, + schema: { + querystring: schemas.query.balanceHistory + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'pools/balance-history', + req.params.pool, + req.query.start, + req.query.end, + req.query.range + ], + ENDPOINTS.POOLS_BALANCE_HISTORY, + getPoolBalanceHistory + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOL_STATS_AGGREGATE, + schema: { + querystring: schemas.query.poolStatsAggregate + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'pool-stats/aggregate', + req.query.start, + req.query.end, + req.query.range, + req.query.pool + ], + ENDPOINTS.POOL_STATS_AGGREGATE, + getPoolStatsAggregate + ) + } + ] +} diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js new file mode 100644 index 0000000..f30bd07 --- /dev/null +++ b/workers/lib/server/schemas/finance.schemas.js @@ -0,0 +1,38 @@ +'use strict' + +const schemas = { + query: { + energyBalance: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + ebitda: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + costSummary: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + } +} + +module.exports = schemas diff --git a/workers/lib/server/schemas/pools.schemas.js b/workers/lib/server/schemas/pools.schemas.js new file mode 100644 index 0000000..d5dcd6f --- /dev/null +++ b/workers/lib/server/schemas/pools.schemas.js @@ -0,0 +1,38 @@ +'use strict' + +const schemas = { + query: { + pools: { + type: 'object', + properties: { + query: { type: 'string' }, + sort: { type: 'string' }, + fields: { type: 'string' }, + overwriteCache: { type: 'boolean' } + } + }, + balanceHistory: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + range: { type: 'string', enum: ['1D', '1W', '1M'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + poolStatsAggregate: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + range: { type: 'string', enum: ['daily', 'weekly', 'monthly'] }, + pool: { type: 'string' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + } +} + +module.exports = schemas diff --git a/workers/lib/server/services/poolManager.js b/workers/lib/server/services/poolManager.js new file mode 100644 index 0000000..21ecf44 --- /dev/null +++ b/workers/lib/server/services/poolManager.js @@ -0,0 +1,409 @@ +'use strict' + +const { + LIST_THINGS, + WORKER_TYPES, + POOL_ALERT_TYPES, + APPLY_THINGS, + POWER_MODES, + RPC_METHODS, + MINERPOOL_EXT_DATA_KEYS +} = require('../../constants') +const { + requestRpcMapLimit, + requestRpcMapAllPages, + requestRpcEachLimit +} = require('../../utils') + +const getPoolStats = async (ctx) => { + const pools = await _fetchPoolStats(ctx) + + const totalWorkers = pools.reduce((sum, p) => sum + (p.workerCount || 0), 0) + const activeWorkers = pools.reduce((sum, p) => sum + (p.activeWorkerCount || 0), 0) + const totalHashrate = pools.reduce((sum, p) => sum + (p.hashrate || 0), 0) + const totalBalance = pools.reduce((sum, p) => sum + (p.balance || 0), 0) + + return { + totalPools: pools.length, + totalWorkers, + activeWorkers, + totalHashrate, + totalBalance, + errors: totalWorkers - activeWorkers + } +} + +const getPoolConfigs = async (ctx) => { + return _fetchPoolStats(ctx) +} + +const getMinersWithPools = async (ctx, filters = {}) => { + const { search, model, page = 1, limit = 50 } = filters + + const results = await requestRpcMapAllPages(ctx, LIST_THINGS, { + type: WORKER_TYPES.MINER, + query: {}, + fields: { id: 1, code: 1, type: 1, info: 1, address: 1 } + }) + + let allMiners = [] + + results.forEach((clusterData) => { + if (!Array.isArray(clusterData)) return + + clusterData.forEach((thing) => { + if (!thing?.type?.startsWith('miner-')) return + + allMiners.push({ + id: thing.id, + code: thing.code, + type: thing.type, + model: _extractModelFromType(thing.type), + container: thing.info?.container || null, + ipAddress: thing.address || null, + serialNum: thing.info?.serialNum || null, + nominalHashrate: thing.info?.nominalHashrateMhs || 0 + }) + }) + }) + + if (search) { + const s = search.toLowerCase() + allMiners = allMiners.filter(m => + m.id.toLowerCase().includes(s) || + (m.code && m.code.toLowerCase().includes(s)) || + (m.serialNum && m.serialNum.toLowerCase().includes(s)) || + (m.ipAddress && m.ipAddress.includes(s)) + ) + } + + if (model) { + const m = model.toLowerCase() + allMiners = allMiners.filter(miner => + miner.model.toLowerCase().includes(m) || + miner.type.toLowerCase().includes(m) + ) + } + + const total = allMiners.length + const startIdx = (page - 1) * limit + const paginatedMiners = allMiners.slice(startIdx, startIdx + limit) + + return { + miners: paginatedMiners, + total, + page, + limit, + totalPages: Math.ceil(total / limit) + } +} + +const getUnitsWithPoolData = async (ctx) => { + const results = await requestRpcMapAllPages(ctx, LIST_THINGS, { + type: WORKER_TYPES.MINER, + query: {}, + fields: { id: 1, type: 1, info: 1 } + }) + + const unitsMap = new Map() + + results.forEach((clusterData) => { + if (!Array.isArray(clusterData)) return + + clusterData.forEach((thing) => { + if (!thing?.type?.startsWith('miner-')) return + + const container = thing.info?.container || 'unassigned' + + if (!unitsMap.has(container)) { + unitsMap.set(container, { + name: container, + miners: [], + totalNominalHashrate: 0 + }) + } + + const unitData = unitsMap.get(container) + unitData.miners.push(thing.id) + unitData.totalNominalHashrate += thing.info?.nominalHashrateMhs || 0 + }) + }) + + return Array.from(unitsMap.values()).map((unit) => ({ + name: unit.name, + minersCount: unit.miners.length, + nominalHashrate: unit.totalNominalHashrate + })) +} + +const getPoolAlerts = async (ctx, filters = {}) => { + const { limit = 50 } = filters + + const results = await requestRpcMapAllPages(ctx, LIST_THINGS, { + type: WORKER_TYPES.MINER, + query: {}, + fields: { id: 1, code: 1, type: 1, info: 1, alerts: 1 } + }) + + const alerts = [] + + results.forEach((clusterData) => { + if (!Array.isArray(clusterData)) return + + clusterData.forEach((thing) => { + const minerAlerts = thing?.alerts || {} + + POOL_ALERT_TYPES.forEach((alertType) => { + if (minerAlerts[alertType]) { + alerts.push({ + id: `${thing.id}-${alertType}`, + type: alertType, + minerId: thing.id, + code: thing.code, + container: thing.info?.container || null, + severity: _getAlertSeverity(alertType), + message: _getAlertMessage(alertType, thing.code || thing.id), + timestamp: minerAlerts[alertType]?.ts || Date.now() + }) + } + }) + }) + }) + + alerts.sort((a, b) => b.timestamp - a.timestamp) + return alerts.slice(0, limit) +} + +const assignPoolToMiners = async (ctx, minerIds, auditInfo = {}) => { + if (!Array.isArray(minerIds) || minerIds.length === 0) { + throw new Error('ERR_MINER_IDS_REQUIRED') + } + + if (ctx.logger && auditInfo.user) { + ctx.logger.info({ + action: 'pool_assignment', + user: auditInfo.user, + timestamp: auditInfo.timestamp, + minerCount: minerIds.length + }, 'Pool setup initiated') + } + + const params = { + type: WORKER_TYPES.MINER, + query: { + id: { $in: minerIds } + }, + action: 'setupPools' + } + + const results = await requestRpcEachLimit(ctx, APPLY_THINGS, params) + + let assigned = 0 + let failed = 0 + const details = [] + + results.forEach((clusterResult) => { + if (clusterResult?.success) { + assigned += clusterResult.affected || 0 + } else { + failed++ + } + + if (clusterResult?.details) { + details.push(...clusterResult.details) + } + }) + + if (ctx.logger && auditInfo.user) { + ctx.logger.info({ + action: 'pool_assignment_complete', + user: auditInfo.user, + timestamp: Date.now(), + assigned, + failed, + total: minerIds.length + }, 'Pool assignment completed') + } + + return { + success: failed === 0, + assigned, + failed, + total: minerIds.length, + details, + audit: { + user: auditInfo.user, + timestamp: auditInfo.timestamp + } + } +} + +const setPowerMode = async (ctx, minerIds, mode, auditInfo = {}) => { + if (!Array.isArray(minerIds) || minerIds.length === 0) { + throw new Error('ERR_MINER_IDS_REQUIRED') + } + + const validModes = Object.values(POWER_MODES) + if (!mode || !validModes.includes(mode)) { + throw new Error('ERR_INVALID_POWER_MODE') + } + + if (ctx.logger && auditInfo.user) { + ctx.logger.info({ + action: 'set_power_mode', + user: auditInfo.user, + timestamp: auditInfo.timestamp, + minerCount: minerIds.length, + mode + }, 'Power mode change initiated') + } + + const params = { + type: WORKER_TYPES.MINER, + query: { + id: { $in: minerIds } + }, + action: 'setPowerMode', + params: { mode } + } + + const results = await requestRpcEachLimit(ctx, APPLY_THINGS, params) + + let affected = 0 + let failed = 0 + const details = [] + + results.forEach((clusterResult) => { + if (clusterResult?.success) { + affected += clusterResult.affected || 0 + } else { + failed++ + } + + if (clusterResult?.details) { + details.push(...clusterResult.details) + } + }) + + if (ctx.logger && auditInfo.user) { + ctx.logger.info({ + action: 'set_power_mode_complete', + user: auditInfo.user, + timestamp: Date.now(), + affected, + failed, + total: minerIds.length, + mode + }, 'Power mode change completed') + } + + return { + success: failed === 0, + affected, + failed, + total: minerIds.length, + mode, + details, + audit: { + user: auditInfo.user, + timestamp: auditInfo.timestamp + } + } +} + +async function _fetchPoolStats (ctx) { + const results = await requestRpcMapLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: 'minerpool', + query: { key: MINERPOOL_EXT_DATA_KEYS.STATS } + }) + + const pools = [] + const seen = new Set() + + for (const orkResult of results) { + if (!orkResult || orkResult.error) continue + const items = Array.isArray(orkResult) ? orkResult : [] + + for (const item of items) { + if (!item) continue + const stats = item.stats || [] + 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 pools +} + +function _extractModelFromType (type) { + if (!type) return 'Unknown' + const models = { + 'miner-am': 'Antminer', + 'miner-wm': 'Whatsminer', + 'miner-av': 'Avalon' + } + + for (const [prefix, brand] of Object.entries(models)) { + if (type.startsWith(prefix)) { + const suffix = type.slice(prefix.length + 1).toUpperCase() + return suffix ? `${brand} ${suffix}` : brand + } + } + + return type +} + +function _getAlertSeverity (alertType) { + const severityMap = { + all_pools_dead: 'critical', + wrong_miner_pool: 'critical', + wrong_miner_subaccount: 'critical', + wrong_worker_name: 'medium', + ip_worker_name: 'medium' + } + return severityMap[alertType] || 'low' +} + +function _getAlertMessage (alertType, minerLabel) { + const messageMap = { + all_pools_dead: `All pools are dead - ${minerLabel}`, + wrong_miner_pool: `Pool URL mismatch - ${minerLabel}`, + wrong_miner_subaccount: `Wrong pool subaccount - ${minerLabel}`, + wrong_worker_name: `Incorrect worker name - ${minerLabel}`, + ip_worker_name: `Worker name uses IP address - ${minerLabel}` + } + return messageMap[alertType] || `Pool alert - ${minerLabel}` +} + +module.exports = { + getPoolStats, + getPoolConfigs, + getMinersWithPools, + getUnitsWithPoolData, + getPoolAlerts, + assignPoolToMiners, + setPowerMode +} diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 886ae5f..cc31980 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -1,7 +1,8 @@ 'use strict' const async = require('async') -const { RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT } = require('./constants') +const { RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, RPC_PAGE_LIMIT } = require('./constants') +const { getStartOfDay } = require('./period.utils') const dateNowSec = () => Math.floor(Date.now() / 1000) @@ -38,7 +39,7 @@ const isValidJsonObject = (data) => { } const isValidEmail = (email) => { - const emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/ + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/ return emailRegex.test(email) } @@ -128,6 +129,54 @@ const requestRpcMapLimit = async (ctx, method, payload) => { }) } +/** + * Paginates RPC requests across multiple orks, fetching all pages per ork + * @param {Object} ctx - Context object + * @param {string} method - RPC method name + * @param {Object} payload - RPC payload (limit/offset will be managed internally) + * @param {number} pageLimit - Items per page (default: RPC_PAGE_LIMIT) + * @returns {Promise} Array of results per ork (all pages concatenated) + */ +const requestRpcMapAllPages = async (ctx, method, payload, pageLimit = RPC_PAGE_LIMIT) => { + const concurrency = ctx.conf?.rpcConcurrencyLimit || RPC_CONCURRENCY_LIMIT + + return await async.mapLimit(ctx.conf.orks, concurrency, async (store) => { + const allItems = [] + let offset = 0 + + while (true) { + const batch = await ctx.net_r0.jRequest( + store.rpcPublicKey, + method, + { ...payload, limit: pageLimit, offset }, + { timeout: getRpcTimeout(ctx.conf) } + ) + + if (!Array.isArray(batch) || batch.length === 0) break + allItems.push(...batch) + if (batch.length < pageLimit) break + offset += pageLimit + } + + return allItems + }) +} + +const runParallel = (tasks) => + new Promise((resolve, reject) => { + async.parallel(tasks, (err, results) => { + if (err) reject(err) + else resolve(results) + }) + }) + +const safeDiv = (numerator, denominator) => + typeof numerator === 'number' && + typeof denominator === 'number' && + denominator !== 0 + ? numerator / denominator + : null + module.exports = { dateNowSec, extractIps, @@ -137,5 +186,9 @@ module.exports = { getAuthTokenFromHeaders, parseJsonQueryParam, requestRpcEachLimit, - requestRpcMapLimit + requestRpcMapLimit, + requestRpcMapAllPages, + getStartOfDay, + safeDiv, + runParallel } From 4d777f1bc4ab51ac3261d9d6a542ba573175a38f Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 19 Feb 2026 19:39:43 +0300 Subject: [PATCH 03/63] fix: Improves user and roles ux (#12) * return the created user * fix: require users:r permission for GET /auth/roles/permissions * fix: update getRolesPermissions route to require users:w permission * fix: enhance getRolesPermissions to filter roles based on user role * fix: update getRolesPermissions to exclude roleManagement for super admin * Cleanup --- tests/unit/handlers/users.handlers.test.js | 87 +++++++++++++++++-- tests/unit/lib/users.test.js | 27 ++++-- tests/unit/routes/users.routes.test.js | 4 + workers/lib/constants.js | 1 + workers/lib/server/handlers/users.handlers.js | 22 ++++- workers/lib/server/routes/users.routes.js | 17 ++-- workers/lib/users.js | 10 ++- 7 files changed, 148 insertions(+), 20 deletions(-) diff --git a/tests/unit/handlers/users.handlers.test.js b/tests/unit/handlers/users.handlers.test.js index c2f5c80..5c97b71 100644 --- a/tests/unit/handlers/users.handlers.test.js +++ b/tests/unit/handlers/users.handlers.test.js @@ -7,7 +7,8 @@ const { updateUser, deleteUser, saveUserSettings, - getUserSettings + getUserSettings, + getRolesPermissions } = require('../../../workers/lib/server/handlers/users.handlers') const { SUPER_ADMIN_ROLE, SUPER_ADMIN_ID } = require('../../../workers/lib/constants') const { createMockAuthCtxWithRoles, createMockReqWithUser, createMockUserCtx } = require('../helpers/mockHelpers') @@ -18,8 +19,9 @@ test('createUser - basic functionality', async (t) => { createUser: async (data) => { createUserCalled = true t.is(data.email, 'test@example.com', 'should pass email') + t.is(data.name, 'Test User', 'should pass name') t.is(data.role, 'admin', 'should pass role') - return { id: 123, email: 'test@example.com' } + return { id: 123, email: 'test@example.com', name: 'Test User', role: 'admin' } } }, createMockAuthCtxWithRoles({ admin: {} }, { admin: ['admin', 'user'] })) const originalLog = console.log @@ -43,7 +45,10 @@ test('createUser - basic functionality', async (t) => { console.log = originalLog t.ok(createUserCalled, 'should call userService.createUser') - t.ok(result.id, 'should return created user') + t.is(result.id, 123, 'should return created user id') + t.is(result.email, 'test@example.com', 'should return created user email') + t.is(result.name, 'Test User', 'should return created user name') + t.is(result.role, 'admin', 'should return created user role') t.pass() }) @@ -192,7 +197,7 @@ test('updateUser - basic functionality', async (t) => { }, updateUser: async (data) => { updateUserCalled = true - return { id: 123, ...data } + return { id: 123, email: data.email, name: data.name, role: data.role } } }, auth_a0: { @@ -229,7 +234,10 @@ test('updateUser - basic functionality', async (t) => { const result = await updateUser(mockCtx, mockReq, {}) t.ok(updateUserCalled, 'should call userService.updateUser') - t.ok(result.id, 'should return updated user') + t.is(result.id, 123, 'should return updated user id') + t.is(result.email, 'updated@example.com', 'should return updated user email') + t.is(result.name, 'Updated User', 'should return updated user name') + t.is(result.role, 'admin', 'should return updated user role') t.pass() }) @@ -586,3 +594,72 @@ test('getUserSettings - basic functionality', async (t) => { t.pass() }) + +test('getRolesPermissions - super admin returns all roles', (t) => { + const mockCtx = { + auth_a0: { + conf: { + roles: { + admin: ['miner:rw', 'users:rw'], + site_operator: ['miner:rw'] + }, + roleManagement: { + admin: ['site_operator'] + } + } + } + } + const mockReq = { + _info: { + user: { + metadata: { + roles: `["${SUPER_ADMIN_ROLE}"]` + } + } + } + } + + const result = getRolesPermissions(mockCtx, mockReq) + + t.ok(result.roles, 'should return roles') + t.ok(!result.roleManagement, 'should not return roleManagement') + t.alike(result.roles.admin, ['miner:rw', 'users:rw'], 'should return correct admin permissions') + + t.pass() +}) + +test('getRolesPermissions - non-super admin returns filtered roles', (t) => { + const mockCtx = { + auth_a0: { + conf: { + roles: { + admin: ['miner:rw', 'users:rw'], + site_operator: ['miner:rw'], + viewer: ['miner:r'] + }, + roleManagement: { + admin: ['site_operator', 'viewer'], + site_operator: ['viewer'] + } + } + } + } + const mockReq = { + _info: { + user: { + metadata: { + roles: '["admin"]' + } + } + } + } + + const result = getRolesPermissions(mockCtx, mockReq) + + t.ok(result.roles.site_operator, 'should include allowed role site_operator') + t.ok(result.roles.viewer, 'should include allowed role viewer') + t.ok(!result.roles.admin, 'should not include own role admin') + t.ok(!result.roleManagement, 'should not return roleManagement') + + t.pass() +}) diff --git a/tests/unit/lib/users.test.js b/tests/unit/lib/users.test.js index 61497ad..aafcf48 100644 --- a/tests/unit/lib/users.test.js +++ b/tests/unit/lib/users.test.js @@ -40,7 +40,9 @@ test('UserService - createUser', async (t) => { createUser: async (data) => { createUserCalled = true createUserArgs = data - return { id: 123, ...data } + }, + getUserByEmail: async (email) => { + return { id: 123, email, name: 'Test User', roles: '["admin"]' } } } const userService = new UserService({ sqlite: {}, auth: mockAuth }) @@ -56,7 +58,10 @@ test('UserService - createUser', async (t) => { t.is(createUserArgs.name, 'Test User', 'should pass name') t.ok(Array.isArray(createUserArgs.roles), 'should pass roles as array') t.is(createUserArgs.roles[0], 'admin', 'should pass correct role') - t.ok(result.id, 'should return created user') + t.is(result.id, 123, 'should return created user id') + t.is(result.email, 'test@example.com', 'should return created user email') + t.is(result.name, 'Test User', 'should return created user name') + t.is(result.role, 'admin', 'should return parsed role') t.pass() }) @@ -104,14 +109,16 @@ test('UserService - updateUser', async (t) => { let updateUserCalled = false let updateUserArgs = null const mockAuth = { - genToken: async (data) => { + genToken: async () => { genTokenCalled = true return 'mock-token' }, updateUser: async (data) => { updateUserCalled = true updateUserArgs = data - return { id: 123, ...data } + }, + getUserById: async (id) => { + return { id, email: 'updated@example.com', name: 'Updated User', roles: '["admin"]' } } } const userService = new UserService({ sqlite: {}, auth: mockAuth }) @@ -129,7 +136,10 @@ test('UserService - updateUser', async (t) => { t.is(updateUserArgs.name, 'Updated User', 'should pass name') t.ok(Array.isArray(updateUserArgs.roles), 'should pass roles as array') t.is(updateUserArgs.roles[0], 'admin', 'should pass correct role') - t.ok(result.id, 'should return updated user') + t.is(result.id, 123, 'should return updated user id') + t.is(result.email, 'updated@example.com', 'should return updated user email') + t.is(result.name, 'Updated User', 'should return updated user name') + t.is(result.role, 'admin', 'should return parsed role') t.pass() }) @@ -140,12 +150,14 @@ test('UserService - updateUser with null name', async (t) => { genToken: async () => 'mock-token', updateUser: async (data) => { updateUserArgs = data - return { id: 123 } + }, + getUserById: async (id) => { + return { id, email: 'test@example.com', name: null, roles: '["admin"]' } } } const userService = new UserService({ sqlite: {}, auth: mockAuth }) - await userService.updateUser({ + const result = await userService.updateUser({ id: 123, email: 'test@example.com', name: null, @@ -153,6 +165,7 @@ test('UserService - updateUser with null name', async (t) => { }) t.is(updateUserArgs.name, null, 'should pass null name') + t.is(result.name, null, 'should return null name') t.pass() }) diff --git a/tests/unit/routes/users.routes.test.js b/tests/unit/routes/users.routes.test.js index 08ff444..50f2ae7 100644 --- a/tests/unit/routes/users.routes.test.js +++ b/tests/unit/routes/users.routes.test.js @@ -15,6 +15,7 @@ test('users routes - route definitions', (t) => { const routeUrls = routes.map(route => route.url) t.ok(routeUrls.includes('/auth/users'), 'should have users route') t.ok(routeUrls.includes('/auth/users/delete'), 'should have users delete route') + t.ok(routeUrls.includes('/auth/roles/permissions'), 'should have roles permissions route') t.pass() }) @@ -34,6 +35,9 @@ test('users routes - HTTP methods', (t) => { const deleteRoute = routes.find(r => r.url === '/auth/users/delete' && r.method === 'POST') t.ok(deleteRoute, 'should have POST route for deleting users') + const rolesPermsRoute = routes.find(r => r.url === '/auth/roles/permissions' && r.method === 'GET') + t.ok(rolesPermsRoute, 'should have GET route for roles permissions') + t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index a621419..954b37e 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -76,6 +76,7 @@ const ENDPOINTS = { USERS: '/auth/users', USERS_DELETE: '/auth/users/delete', USER_SETTINGS: '/auth/user/settings', + ROLES_PERMISSIONS: '/auth/roles/permissions', // Global endpoints GLOBAL_CONFIG: '/auth/global-config', diff --git a/workers/lib/server/handlers/users.handlers.js b/workers/lib/server/handlers/users.handlers.js index b490c5b..a465d0b 100644 --- a/workers/lib/server/handlers/users.handlers.js +++ b/workers/lib/server/handlers/users.handlers.js @@ -149,11 +149,31 @@ async function getUserSettings (ctx, req, res) { return await ctx.globalDataLib.getUserSettings(userId) } +function getRolesPermissions (ctx, req) { + const { roles, roleManagement } = ctx.auth_a0.conf + const userRole = JSON.parse(req._info.user.metadata.roles)[0] + + if (userRole === SUPER_ADMIN_ROLE) { + return { roles } + } + + const allowedRoles = roleManagement[userRole] || [] + const filteredRoles = {} + for (const role of allowedRoles) { + if (roles[role]) { + filteredRoles[role] = roles[role] + } + } + + return { roles: filteredRoles } +} + module.exports = { createUser, deleteUser, listUsers, updateUser, saveUserSettings, - getUserSettings + getUserSettings, + getRolesPermissions } diff --git a/workers/lib/server/routes/users.routes.js b/workers/lib/server/routes/users.routes.js index 0695787..77d23b1 100644 --- a/workers/lib/server/routes/users.routes.js +++ b/workers/lib/server/routes/users.routes.js @@ -11,8 +11,10 @@ const { createUser, listUsers, updateUser, - deleteUser + deleteUser, + getRolesPermissions } = require('../handlers/users.handlers') +const { createAuthRoute } = require('../lib/routeHelpers') module.exports = (ctx) => [ { @@ -40,8 +42,8 @@ module.exports = (ctx) => [ await capCheck(ctx, req, rep, ['users:w']) }, handler: async (req, rep) => { - const success = await createUser(ctx, req, rep) - return send200(rep, { success }) + const user = await createUser(ctx, req, rep) + return send200(rep, { user }) } }, { @@ -82,8 +84,8 @@ module.exports = (ctx) => [ await capCheck(ctx, req, rep, ['users:w']) }, handler: async (req, rep) => { - const success = await updateUser(ctx, req, rep) - return send200(rep, { success }) + const user = await updateUser(ctx, req, rep) + return send200(rep, { user }) } }, { @@ -112,5 +114,10 @@ module.exports = (ctx) => [ const success = await deleteUser(ctx, req, rep) return send200(rep, { success }) } + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.ROLES_PERMISSIONS, + ...createAuthRoute(ctx, getRolesPermissions, ['users:w']) } ] diff --git a/workers/lib/users.js b/workers/lib/users.js index 37b9bb8..4c76e11 100644 --- a/workers/lib/users.js +++ b/workers/lib/users.js @@ -18,11 +18,14 @@ class UserService { } async createUser ({ email, name, role }) { - return await this._auth.createUser({ + await this._auth.createUser({ email, name, roles: [role] }) + + const user = await this._auth.getUserByEmail(email) + return this.parseUserRow(user) } async listUsers () { @@ -38,12 +41,15 @@ class UserService { roles: [] }) - return await this._auth.updateUser({ + await this._auth.updateUser({ token, email, name, roles: [role] }) + + const user = await this._auth.getUserById(id) + return this.parseUserRow(user) } deleteUser (id) { From a1ed2b7999ed9eb36f2f687efdc272f0bca3c6b3 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Fri, 20 Feb 2026 11:28:00 +0300 Subject: [PATCH 04/63] feat: add GET /auth/finance/subsidy-fees endpoint (#14) * feat: add GET /auth/finance/subsidy-fees endpoint Port Bitcoin network block data (subsidy rewards + transaction fees) from legacy dashboard to API v2. Fetches HISTORICAL_BLOCKSIZES from mempool worker, aggregates by period (daily/weekly/monthly). * fix: remove unused site parameter from getProductionCosts --- tests/unit/handlers/finance.handlers.test.js | 142 +++++++++++++++++- tests/unit/routes/finance.routes.test.js | 1 + workers/lib/constants.js | 1 + .../lib/server/handlers/finance.handlers.js | 112 +++++++++++++- workers/lib/server/routes/finance.routes.js | 21 ++- workers/lib/server/schemas/finance.schemas.js | 10 ++ 6 files changed, 279 insertions(+), 8 deletions(-) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 702ea38..409b1e2 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -16,7 +16,10 @@ const { extractEbitdaCurrentPrice, calculateEbitdaSummary, getCostSummary, - calculateCostSummary + calculateCostSummary, + getSubsidyFees, + processBlockData, + calculateSubsidyFeesSummary } = require('../../../workers/lib/server/handlers/finance.handlers') // ==================== Energy Balance Tests ==================== @@ -569,3 +572,140 @@ test('calculateCostSummary - handles empty log', (t) => { t.is(summary.avgAllInCostPerMWh, null, 'should be null') t.pass() }) + +// ==================== Subsidy Fees Tests ==================== + +test('getSubsidyFees - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'getWrkExtData') { + return [{ data: [{ ts: 1700006400000, blockReward: 6.25, blockTotalFees: 0.5 }] }] + } + return {} + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getSubsidyFees(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.pass() +}) + +test('getSubsidyFees - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getSubsidyFees(mockCtx, { query: { end: 1700100000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getSubsidyFees - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getSubsidyFees(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getSubsidyFees - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getSubsidyFees(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('processBlockData - processes valid block data', (t) => { + const results = [ + [{ data: [{ ts: 1700006400000, blockReward: 6.25, blockTotalFees: 0.5 }] }] + ] + + const daily = processBlockData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 6.25, 'should extract blockReward') + t.is(daily[key].blockTotalFees, 0.5, 'should extract blockTotalFees') + t.pass() +}) + +test('processBlockData - processes object-keyed data', (t) => { + const results = [ + [{ data: { 1700006400000: { blockReward: 6.25, blockTotalFees: 0.5 } } }] + ] + + const daily = processBlockData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 6.25, 'should extract blockReward') + t.pass() +}) + +test('processBlockData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processBlockData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processBlockData - handles empty results', (t) => { + const results = [] + const daily = processBlockData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty') + t.pass() +}) + +test('calculateSubsidyFeesSummary - calculates from log entries', (t) => { + const log = [ + { blockReward: 6.25, blockTotalFees: 0.5 }, + { blockReward: 6.25, blockTotalFees: 0.3 } + ] + + const summary = calculateSubsidyFeesSummary(log) + t.is(summary.totalBlockReward, 12.5, 'should sum block rewards') + t.is(summary.totalBlockTotalFees, 0.8, 'should sum block fees') + t.ok(summary.avgBlockReward !== null, 'should calculate avg block reward') + t.is(summary.avgBlockReward, 6.25, 'should calculate correct avg block reward') + t.ok(summary.avgBlockTotalFees !== null, 'should calculate avg block fees') + t.pass() +}) + +test('calculateSubsidyFeesSummary - handles empty log', (t) => { + const summary = calculateSubsidyFeesSummary([]) + t.is(summary.totalBlockReward, 0, 'should be zero') + t.is(summary.totalBlockTotalFees, 0, 'should be zero') + t.is(summary.avgBlockReward, null, 'should be null') + t.is(summary.avgBlockTotalFees, null, 'should be null') + t.pass() +}) diff --git a/tests/unit/routes/finance.routes.test.js b/tests/unit/routes/finance.routes.test.js index 07f2c9d..6ef74b9 100644 --- a/tests/unit/routes/finance.routes.test.js +++ b/tests/unit/routes/finance.routes.test.js @@ -18,6 +18,7 @@ test('finance routes - route definitions', (t) => { t.ok(routeUrls.includes('/auth/finance/energy-balance'), 'should have energy-balance route') t.ok(routeUrls.includes('/auth/finance/ebitda'), 'should have ebitda route') t.ok(routeUrls.includes('/auth/finance/cost-summary'), 'should have cost-summary route') + t.ok(routeUrls.includes('/auth/finance/subsidy-fees'), 'should have subsidy-fees route') t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 954b37e..400efe1 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -115,6 +115,7 @@ const ENDPOINTS = { FINANCE_ENERGY_BALANCE: '/auth/finance/energy-balance', FINANCE_EBITDA: '/auth/finance/ebitda', FINANCE_COST_SUMMARY: '/auth/finance/cost-summary', + FINANCE_SUBSIDY_FEES: '/auth/finance/subsidy-fees', // Pools endpoints POOLS: '/auth/pools', diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index b0c8948..3d29480 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -70,7 +70,7 @@ async function getEnergyBalance (ctx, req) { query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), - (cb) => getProductionCosts(ctx, null, start, end) + (cb) => getProductionCosts(ctx, start, end) .then(r => cb(null, r)).catch(cb), (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { @@ -412,7 +412,7 @@ async function getEbitda (ctx, req) { query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), - (cb) => getProductionCosts(ctx, null, start, end) + (cb) => getProductionCosts(ctx, start, end) .then(r => cb(null, r)).catch(cb) ]) @@ -620,7 +620,7 @@ async function getCostSummary (ctx, req) { const endDate = new Date(end).toISOString() const [productionCosts, priceResults, consumptionResults] = await runParallel([ - (cb) => getProductionCosts(ctx, null, start, end) + (cb) => getProductionCosts(ctx, start, end) .then(r => cb(null, r)).catch(cb), (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { @@ -715,9 +715,107 @@ function calculateCostSummary (log) { } } +// ==================== Subsidy Fees ==================== + +async function getSubsidyFees (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + const period = req.query.period || PERIOD_TYPES.DAILY + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const blockResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'HISTORICAL_BLOCKSIZES', start, end } + }) + + const dailyBlocks = processBlockData(blockResults) + + const log = [] + for (const dayTs of Object.keys(dailyBlocks).sort()) { + const ts = Number(dayTs) + const block = dailyBlocks[dayTs] + log.push({ + ts, + blockReward: block.blockReward, + blockTotalFees: block.blockTotalFees + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateSubsidyFeesSummary(aggregated) + + return { log: aggregated, summary } +} + +function processBlockData (results) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.blocks || entry + if (Array.isArray(items)) { + for (const item of items) { + if (!item) continue + const rawTs = item.ts || item.timestamp || item.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + if (!ts) continue + if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } + daily[ts].blockReward += (item.blockReward || item.block_reward || item.subsidy || 0) + daily[ts].blockTotalFees += (item.blockTotalFees || item.block_total_fees || item.totalFees || item.total_fees || 0) + } + } else if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!ts) continue + if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } + if (typeof val === 'object') { + daily[ts].blockReward += (val.blockReward || val.block_reward || val.subsidy || 0) + daily[ts].blockTotalFees += (val.blockTotalFees || val.block_total_fees || val.totalFees || val.total_fees || 0) + } + } + } + } + } + return daily +} + +function calculateSubsidyFeesSummary (log) { + if (!log.length) { + return { + totalBlockReward: 0, + totalBlockTotalFees: 0, + avgBlockReward: null, + avgBlockTotalFees: null + } + } + + const totals = log.reduce((acc, entry) => { + acc.blockReward += entry.blockReward || 0 + acc.blockTotalFees += entry.blockTotalFees || 0 + return acc + }, { blockReward: 0, blockTotalFees: 0 }) + + return { + totalBlockReward: totals.blockReward, + totalBlockTotalFees: totals.blockTotalFees, + avgBlockReward: safeDiv(totals.blockReward, log.length), + avgBlockTotalFees: safeDiv(totals.blockTotalFees, log.length) + } +} + // ==================== Shared ==================== -async function getProductionCosts (ctx, site, start, end) { +async function getProductionCosts (ctx, start, end) { if (!ctx.globalDataLib) return [] const costs = await ctx.globalDataLib.getGlobalData({ type: GLOBAL_DATA_TYPES.PRODUCTION_COSTS @@ -728,7 +826,6 @@ async function getProductionCosts (ctx, site, start, end) { const endDate = new Date(end) return costs.filter(entry => { if (!entry || !entry.year || !entry.month) return false - if (site && entry.site !== site) return false const entryDate = new Date(entry.year, entry.month - 1, 1) return entryDate >= startDate && entryDate <= endDate }) @@ -753,6 +850,7 @@ module.exports = { getEnergyBalance, getEbitda, getCostSummary, + getSubsidyFees, getProductionCosts, processConsumptionData, processTransactionData, @@ -767,5 +865,7 @@ module.exports = { processEbitdaPrices, extractEbitdaCurrentPrice, calculateEbitdaSummary, - calculateCostSummary + calculateCostSummary, + processBlockData, + calculateSubsidyFeesSummary } diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js index 65970b0..7b05e02 100644 --- a/workers/lib/server/routes/finance.routes.js +++ b/workers/lib/server/routes/finance.routes.js @@ -7,7 +7,8 @@ const { const { getEnergyBalance, getEbitda, - getCostSummary + getCostSummary, + getSubsidyFees } = require('../handlers/finance.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') @@ -68,6 +69,24 @@ module.exports = (ctx) => { ENDPOINTS.FINANCE_COST_SUMMARY, getCostSummary ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_SUBSIDY_FEES, + schema: { + querystring: schemas.query.subsidyFees + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/subsidy-fees', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_SUBSIDY_FEES, + getSubsidyFees + ) } ] } diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index f30bd07..b7d3fc2 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -31,6 +31,16 @@ const schemas = { overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] + }, + subsidyFees: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'weekly', 'monthly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] } } } From eb351606fbb46030d3451a20ece52b9dae0056a1 Mon Sep 17 00:00:00 2001 From: andretetherio Date: Thu, 26 Feb 2026 02:30:34 -0300 Subject: [PATCH 05/63] Adding CI (#23) --- .github/CODEOWNERS | 1 + .github/actions/node-restore-cache/action.yml | 36 +++ .github/actions/node-setup-cache/action.yml | 73 +++++ .github/workflows/ci.yml | 288 ++++++++++++++++++ 4 files changed, 398 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/actions/node-restore-cache/action.yml create mode 100644 .github/actions/node-setup-cache/action.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fd1614d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @tetherto/miningos-bk-merge diff --git a/.github/actions/node-restore-cache/action.yml b/.github/actions/node-restore-cache/action.yml new file mode 100644 index 0000000..0784abf --- /dev/null +++ b/.github/actions/node-restore-cache/action.yml @@ -0,0 +1,36 @@ +name: 'Node Restore Cache' +description: 'Restore Node.js dependencies from cache' + +inputs: + cache-key: + description: 'Cache key from setup-node step' + required: true + +runs: + using: composite + steps: + # Restore-only: avoids "another job may be creating this cache" when setup and downstream jobs race on save + - name: Restore node modules cache + id: cache-node-modules + uses: actions/cache/restore@v4 + with: + path: node_modules + key: ${{ inputs.cache-key }} + restore-keys: | + node-modules-${{ runner.os }}-20.x- + + - name: Setup Node.js + if: steps.cache-node-modules.outputs.cache-hit != 'true' + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Install Dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + shell: bash + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm install + fi diff --git a/.github/actions/node-setup-cache/action.yml b/.github/actions/node-setup-cache/action.yml new file mode 100644 index 0000000..c260c9b --- /dev/null +++ b/.github/actions/node-setup-cache/action.yml @@ -0,0 +1,73 @@ +name: 'Node.js Setup with Cache' +description: 'Sets up Node.js with dependency caching for the initial setup job' +author: 'miningos-devops' + +inputs: + node-version: + description: 'Node.js version to use' + required: false + default: '20.x' + install-dependencies: + description: 'Whether to install dependencies on cache miss' + required: false + default: 'true' + additional-packages: + description: 'Additional system packages to install (space-separated)' + required: false + default: '' + +outputs: + cache-key: + description: 'The generated cache key for node_modules' + value: ${{ steps.cache-keys.outputs.cache-key }} + cache-hit: + description: 'Whether the cache was hit' + value: ${{ steps.cache-node-modules.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Generate cache keys + id: cache-keys + shell: bash + run: | + NODE_VERSION="${{ inputs.node-version }}" + BASE_KEY="node-modules-${{ runner.os }}-${NODE_VERSION}" + # Stable key per deps so downstream jobs and future runs get cache hits + HASH_KEY="${BASE_KEY}-${{ hashFiles('package-lock.json', 'package.json') }}" + echo "cache-key=${HASH_KEY}" >> $GITHUB_OUTPUT + echo "base-key=${BASE_KEY}" >> $GITHUB_OUTPUT + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + + - name: Install additional system packages + if: inputs.additional-packages != '' + shell: bash + run: | + echo "Installing additional packages: ${{ inputs.additional-packages }}" + sudo apt update + sudo apt install -y ${{ inputs.additional-packages }} + + - name: Install dependencies + if: inputs.install-dependencies == 'true' + shell: bash + run: | + if [ -f package-lock.json ]; then + echo "Found package-lock.json, using npm ci for deterministic installs" + npm ci + else + echo "No package-lock.json found, using npm install" + npm install + fi + + - name: Cache node modules + id: cache-node-modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ steps.cache-keys.outputs.cache-key }} + restore-keys: | + ${{ steps.cache-keys.outputs.base-key }}- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f17fa4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,288 @@ +name: PR - Test & Validate (Local) + +on: + push: + branches: [main, staging, dev, develop] + pull_request_target: + branches: [main, staging, dev, develop] + types: [opened, reopened, synchronize] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: '20.x' + +jobs: + dependency-review: + name: 📋 Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request_target' || github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Dependency Review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + vulnerability-check: true + license-check: false + + setup: + name: 📦 Setup Dependencies + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + cache-key: ${{ steps.setup-node.outputs.cache-key }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: true + ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Check package-lock.json consistency + continue-on-error: true + run: | + if [ -f package-lock.json ]; then + npm install --package-lock-only --no-audit --no-fund + git diff --exit-code package-lock.json || ( + echo "::error::package-lock.json is out of sync with package.json. Run 'npm install' and commit the updated lockfile." + exit 1 + ) + fi + + - name: Setup Node.js with cache + id: setup-node + uses: ./.github/actions/node-setup-cache + with: + node-version: ${{ env.NODE_VERSION }} + + supply-chain: + name: 🔐 Supply Chain Security + runs-on: ubuntu-latest + timeout-minutes: 6 + needs: setup + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: true + ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + + - name: Restore Node.js and Cache + uses: ./.github/actions/node-restore-cache + with: + cache-key: ${{ needs.setup.outputs.cache-key }} + + - name: Verify package integrity (npm audit signatures) + run: | + echo "🔐 Verifying npm package signatures..." + npm audit signatures 2>&1 || echo "::warning::Some packages may not have verified signatures (npm 10.7+ for full support)" + continue-on-error: true + + - name: Check for known vulnerabilities (npm audit) + run: | + echo "🛡️ Checking for known vulnerabilities..." + npm audit --audit-level=high 2>&1 || echo "::warning::High severity vulnerabilities detected. Consider 'npm audit fix' or updating dependencies." + continue-on-error: true + + lint: + name: ✨ Lint Code + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: setup + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: true + ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + + - name: Restore Node.js and Cache + uses: ./.github/actions/node-restore-cache + with: + cache-key: ${{ needs.setup.outputs.cache-key }} + + - name: Run linting + continue-on-error: true + run: | + echo "Running linting..." + npm run lint --if-present || echo "::warning::Linting failed but continuing workflow" + + - name: Run typecheck (optional) + continue-on-error: true + run: | + npm run typecheck --if-present || true + + test-and-sonarqube: + name: 🧪 Test Coverage & SonarQube Analysis + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: setup + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: true + fetch-depth: 0 + ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + + - name: Restore Node.js and Cache + uses: ./.github/actions/node-restore-cache + with: + cache-key: ${{ needs.setup.outputs.cache-key }} + + - name: Setup project configuration + run: | + echo "Setting up project configuration..." + if [ -f ./setup-config.sh ]; then + chmod +x ./setup-config.sh + ./setup-config.sh + fi + + - name: Run tests with coverage + continue-on-error: true + run: | + echo "Running tests with coverage..." + npm run test:coverage --if-present || npm test --if-present || echo "::warning::Tests failed but continuing workflow" + + - name: Run build (optional) + continue-on-error: true + run: npm run build --if-present || true + + - name: Tailscale + id: tailscale + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + uses: tailscale/github-action@v4 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_CLIENT_SECRET }} + tags: tag:ci-sast + version: latest + ping: dev-sonarcube-0.tail8a2a3f.ts.net + + - name: Generate ESLint JSON report + if: steps.tailscale.outcome == 'success' + continue-on-error: true + run: | + if [ -f eslint.config.cjs ]; then + npx eslint . --format json --output-file eslint-report.json + fi + + - name: Prepare SonarQube Configuration + if: steps.tailscale.outcome == 'success' + run: | + echo "Creating sonar-project.properties..." + + # Base configuration + cat > sonar-project.properties << 'SONAR_EOF' + sonar.projectKey=${{ github.event.repository.name }} + sonar.projectName=${{ github.event.repository.name }} + sonar.projectVersion=${{ github.sha }} + sonar.sources=. + sonar.language=js + sonar.sourceEncoding=UTF-8 + sonar.javascript.lcov.reportPaths=coverage/lcov.info + sonar.javascript.node.maxspace=4096 + sonar.javascript.environments=node + sonar.qualitygate.wait=true + sonar.test.inclusions=**/*.test.js,**/*.spec.js + SONAR_EOF + + # Add ESLint report if it exists + if [ -f eslint-report.json ]; then + echo "sonar.eslint.reportPaths=eslint-report.json" >> sonar-project.properties + fi + + # Base exclusions + BASE_EXCLUSIONS="**/node_modules/**,**/coverage/**,**/dist/**,**/build/**,.github/**,**/*.test.js,**/*.spec.js,**/*.json,**/*.md,**/*.yml,**/*.yaml" + + # Add tests configuration if folder exists + if [ -d "tests" ]; then + echo "Tests folder found - including test configuration" + echo "sonar.tests=tests" >> sonar-project.properties + echo "sonar.exclusions=${BASE_EXCLUSIONS},**/tests/**" >> sonar-project.properties + echo "sonar.cpd.exclusions=**/tests/**,**/node_modules/**" >> sonar-project.properties + else + echo "No tests folder found - skipping test configuration" + echo "sonar.exclusions=${BASE_EXCLUSIONS}" >> sonar-project.properties + fi + + echo "Configuration file created" + + - name: SonarQube Scan + if: steps.tailscale.outcome == 'success' + continue-on-error: true + uses: SonarSource/sonarqube-scan-action@v7.0.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + + summary: + name: 📋 CI Summary + runs-on: ubuntu-latest + needs: [setup, supply-chain, lint, test-and-sonarqube] + if: always() && !cancelled() + permissions: + contents: read + + steps: + - name: 📥 Checkout for summary + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: 📊 Print summary + env: + R_SETUP: ${{ needs.setup.result || 'skipped' }} + R_SUPPLY: ${{ needs.supply-chain.result || 'skipped' }} + R_LINT: ${{ needs.lint.result || 'skipped' }} + R_TEST: ${{ needs.test-and-sonarqube.result || 'skipped' }} + run: | + { + echo "## 📊 CI Pipeline Summary" + echo "" + echo "### 📋 Commit information" + echo "- **Commit:** $(git rev-parse HEAD)" + echo "- **Message:** $(git log -1 --pretty=format:'%s')" + echo "- **Author:** $(git log -1 --pretty=format:'%an <%ae>')" + echo "- **Date:** $(git log -1 --pretty=format:'%ad' --date=short)" + echo "" + echo "### 🎯 Job results" + [ "${R_SETUP}" != "skipped" ] && echo "- 📦 Setup: ${R_SETUP:-?}" + [ "${R_SUPPLY}" != "skipped" ] && echo "- 🔐 Supply Chain: ${R_SUPPLY:-?}" + [ "${R_LINT}" != "skipped" ] && echo "- ✨ Lint: ${R_LINT:-?}" + [ "${R_TEST}" != "skipped" ] && echo "- 🧪 Test & SonarQube: ${R_TEST:-?}" + echo "" + echo "### 🔧 Pipeline" + echo "- ✅ Dependency review (PR only)" + echo "- ✅ Lockfile check, npm audit, lint, tests" + echo "- ✅ SonarQube (push to main or manual run)" + } | tee -a "$GITHUB_STEP_SUMMARY" From 8e003f018b0f3c096ab075822e19bf15f18b5223 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Tue, 3 Mar 2026 22:50:28 +0300 Subject: [PATCH 06/63] feat: add alerts API v2 endpoints (#24) * resolve conflicts * Feature: Alerts * refactor: use RPC constants and explicit field mapping in alerts handlers * Fix code smells * fix failing tests * Remove metrics * Address comments --- tests/integration/api.security.test.js | 20 +- tests/unit/handlers/alerts.handlers.test.js | 523 ++++++++++++++++++ tests/unit/routes/alerts.routes.test.js | 55 ++ workers/lib/constants.js | 29 +- .../lib/server/handlers/alerts.handlers.js | 169 ++++++ workers/lib/server/index.js | 4 +- workers/lib/server/routes/alerts.routes.js | 63 +++ workers/lib/server/schemas/alerts.schemas.js | 33 ++ workers/lib/utils.js | 33 +- 9 files changed, 921 insertions(+), 8 deletions(-) create mode 100644 tests/unit/handlers/alerts.handlers.test.js create mode 100644 tests/unit/routes/alerts.routes.test.js create mode 100644 workers/lib/server/handlers/alerts.handlers.js create mode 100644 workers/lib/server/routes/alerts.routes.js create mode 100644 workers/lib/server/schemas/alerts.schemas.js diff --git a/tests/integration/api.security.test.js b/tests/integration/api.security.test.js index 931513c..cca11c7 100644 --- a/tests/integration/api.security.test.js +++ b/tests/integration/api.security.test.js @@ -596,8 +596,15 @@ test('Api security', { timeout: 90000 }, async (main) => { }) await n.test('api should succeed for valid permissions', async (t) => { + const headers = await createAuthHeaders(superadminUser) + const { body: list } = await httpClient.get(api, { headers, encoding }) + const target = list.users.find(u => u.email === 'dev@test.test') + if (!target) { + t.fail('test user dev@test.test not found') + return + } await testEndpointWithAuth(t, httpClient, 'put', api, superadminUser, { - body: { data: { id: 2, email: readonlyUser, role: 'admin' } }, + body: { data: { id: target.id, email: 'dev@test.test', role: 'admin' } }, encoding }) }) @@ -612,24 +619,29 @@ test('Api security', { timeout: 90000 }, async (main) => { await main.test('Api: post users/delete', async (n) => { const api = `${appNodeBaseUrl}${ENDPOINTS.USERS_DELETE}` + const usersApi = `${appNodeBaseUrl}${ENDPOINTS.USERS}` + const headers = await createAuthHeaders(superadminUser) + const { body: list } = await httpClient.get(usersApi, { headers, encoding }) + const deleteTarget = list.users.find(u => u.email === 'dev@test.test') + const permTarget = list.users.find(u => u.email !== 'dev@test.test') await n.test('api should fail due to invalid permissions', async (t) => { await testEndpointWithAuthAndError(t, httpClient, 'post', api, readonlyUser, 'ERR_AUTH_FAIL_NO_PERMS', { - body: { data: { id: 5 } }, + body: { data: { id: deleteTarget.id } }, encoding }) }) await n.test('api should succeed for valid permissions', async (t) => { await testEndpointWithAuth(t, httpClient, 'post', api, superadminUser, { - body: { data: { id: 5 } }, + body: { data: { id: deleteTarget.id } }, encoding }) }) await n.test('api should fail for missing permissions', async (t) => { await testEndpointWithAuthAndError(t, httpClient, 'post', api, siteOperatorUser, 'ERR_AUTH_FAIL_NO_PERMS', { - body: { data: { id: 2 } }, + body: { data: { id: permTarget.id } }, encoding }) }) diff --git a/tests/unit/handlers/alerts.handlers.test.js b/tests/unit/handlers/alerts.handlers.test.js new file mode 100644 index 0000000..f6560c5 --- /dev/null +++ b/tests/unit/handlers/alerts.handlers.test.js @@ -0,0 +1,523 @@ +'use strict' + +const test = require('brittle') +const { + getSiteAlerts, + getAlertsHistory, + extractAlertsFromThings, + matchesSearch, + applySort, + buildSeveritySummary, + flattenHistoryAlert +} = require('../../../workers/lib/server/handlers/alerts.handlers') +const { matchesFilter, deduplicateAlerts } = require('../../../workers/lib/utils') +const { createMockCtxWithOrks } = require('../helpers/mockHelpers') + +// ==================== extractAlertsFromThings Tests ==================== + +test('extractAlertsFromThings - extracts alerts with device info', (t) => { + const things = [ + { + id: 'miner-1', + type: 'miner', + code: 'S19', + info: { container: 'container-A' }, + last: { + alerts: [ + { severity: 'high', name: 'Fan failure' }, + { severity: 'low', name: 'Temp warning' } + ] + } + } + ] + + const result = extractAlertsFromThings(things) + t.is(result.length, 2, 'should extract 2 alerts') + t.is(result[0].id, 'miner-1', 'should enrich with device id') + 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].severity, 'high', 'should preserve alert severity') +}) + +test('extractAlertsFromThings - skips things without alerts', (t) => { + const things = [ + { id: 'miner-1', last: {} }, + { id: 'miner-2', last: { alerts: null } }, + { id: 'miner-3' } + ] + + const result = extractAlertsFromThings(things) + t.is(result.length, 0, 'should return empty array') +}) + +test('extractAlertsFromThings - skips invalid alert entries', (t) => { + const things = [ + { + id: 'miner-1', + type: 'miner', + code: 'S19', + last: { + alerts: [null, 'string', [], { severity: 'high' }] + } + } + ] + + const result = extractAlertsFromThings(things) + t.is(result.length, 1, 'should only include valid object alerts') +}) + +// ==================== matchesFilter 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('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('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('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') +}) + +// ==================== matchesSearch Tests ==================== + +test('matchesSearch - returns true when no search', (t) => { + t.ok(matchesSearch({ id: 'test' }, '', ['id']), 'empty search should match') + t.ok(matchesSearch({ id: 'test' }, null, ['id']), 'null search should match') +}) + +test('matchesSearch - case-insensitive substring match', (t) => { + const item = { id: 'Miner-ABC-123', code: 'S19' } + t.ok(matchesSearch(item, 'abc', ['id', 'code']), 'should match case-insensitive') + t.ok(matchesSearch(item, 'S19', ['id', 'code']), 'should match code field') + t.ok(!matchesSearch(item, 'xyz', ['id', 'code']), 'should not match') +}) + +test('matchesSearch - handles null/undefined fields', (t) => { + const item = { id: 'test', code: null } + t.ok(matchesSearch(item, 'test', ['id', 'code', 'missing']), 'should handle null fields') +}) + +// ==================== applySort Tests ==================== + +test('applySort - returns items when no sort', (t) => { + const items = [{ a: 2 }, { a: 1 }] + const result = applySort(items, null) + t.is(result[0].a, 2, 'should preserve order') +}) + +test('applySort - sorts ascending', (t) => { + const items = [{ ts: 3 }, { ts: 1 }, { ts: 2 }] + const result = applySort(items, { ts: 1 }) + t.is(result[0].ts, 1, 'first should be smallest') + t.is(result[2].ts, 3, 'last should be largest') +}) + +test('applySort - sorts descending', (t) => { + const items = [{ ts: 1 }, { ts: 3 }, { ts: 2 }] + const result = applySort(items, { ts: -1 }) + t.is(result[0].ts, 3, 'first should be largest') + t.is(result[2].ts, 1, 'last should be smallest') +}) + +test('applySort - does not mutate original', (t) => { + const items = [{ ts: 2 }, { ts: 1 }] + applySort(items, { ts: 1 }) + t.is(items[0].ts, 2, 'original should be unchanged') +}) + +// ==================== buildSeveritySummary Tests ==================== + +test('buildSeveritySummary - counts by severity', (t) => { + const alerts = [ + { severity: 'critical' }, + { severity: 'high' }, + { severity: 'high' }, + { severity: 'medium' }, + { severity: 'low' }, + { severity: 'low' }, + { severity: 'low' } + ] + + const result = buildSeveritySummary(alerts) + t.is(result.critical, 1, 'should count critical') + t.is(result.high, 2, 'should count high') + t.is(result.medium, 1, 'should count medium') + t.is(result.low, 3, 'should count low') + t.is(result.total, 7, 'should count total') +}) + +test('buildSeveritySummary - empty alerts', (t) => { + const result = buildSeveritySummary([]) + t.is(result.total, 0, 'total should be 0') + t.is(result.critical, 0, 'critical should be 0') +}) + +// ==================== deduplicateAlerts Tests ==================== + +test('deduplicateAlerts - removes duplicates by uuid', (t) => { + const alerts = [ + { uuid: 'a', name: 'first' }, + { uuid: 'b', name: 'second' }, + { uuid: 'a', name: 'duplicate' } + ] + + const result = deduplicateAlerts(alerts) + t.is(result.length, 2, 'should remove duplicate') + t.is(result[0].name, 'first', 'should keep first occurrence') +}) + +test('deduplicateAlerts - keeps alerts without uuid', (t) => { + const alerts = [ + { name: 'no-uuid-1' }, + { name: 'no-uuid-2' } + ] + + const result = deduplicateAlerts(alerts) + t.is(result.length, 2, 'should keep all without uuid') +}) + +test('deduplicateAlerts - empty array', (t) => { + const result = deduplicateAlerts([]) + t.is(result.length, 0, 'should return empty array') +}) + +// ==================== getSiteAlerts Tests ==================== + +test('getSiteAlerts - happy path', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + { + id: 'miner-1', + type: 'miner', + code: 'S19', + info: { container: 'cont-A' }, + last: { + alerts: [ + { severity: 'high', name: 'Fan failure' }, + { severity: 'low', name: 'Temp warning' } + ] + } + }, + { + id: 'miner-2', + type: 'miner', + code: 'S21', + info: { container: 'cont-B' }, + last: { + alerts: [ + { severity: 'critical', name: 'Overheat' } + ] + } + } + ] + ) + + const mockReq = { query: {} } + const result = await getSiteAlerts(mockCtx, mockReq) + + t.ok(result.alerts, 'should return alerts') + t.ok(result.summary, 'should return summary') + t.ok(typeof result.total === 'number', 'should return total') + t.is(result.total, 3, 'should have 3 total alerts') + t.is(result.summary.critical, 1, 'should count 1 critical') + t.is(result.summary.high, 1, 'should count 1 high') + t.is(result.summary.low, 1, 'should count 1 low') +}) + +test('getSiteAlerts - empty results', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [] + ) + + const mockReq = { query: {} } + const result = await getSiteAlerts(mockCtx, mockReq) + + t.is(result.total, 0, 'should have 0 total') + t.is(result.alerts.length, 0, 'should have empty alerts') + t.is(result.summary.total, 0, 'summary total should be 0') +}) + +test('getSiteAlerts - applies filter', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + { + id: 'miner-1', + type: 'miner', + code: 'S19', + info: { container: 'cont-A' }, + last: { alerts: [{ severity: 'high', name: 'Alert 1' }] } + }, + { + id: 'miner-2', + type: 'miner', + code: 'S21', + info: { container: 'cont-B' }, + last: { alerts: [{ severity: 'low', name: 'Alert 2' }] } + } + ] + ) + + const mockReq = { query: { filter: JSON.stringify({ severity: 'high' }) } } + const result = await getSiteAlerts(mockCtx, mockReq) + + t.is(result.total, 1, 'should filter to 1 alert') + t.is(result.alerts[0].severity, 'high', 'should only include high severity') +}) + +test('getSiteAlerts - applies text search', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + { + id: 'miner-ABC', + type: 'miner', + code: 'S19', + info: { container: 'cont-A' }, + last: { alerts: [{ severity: 'high' }] } + }, + { + id: 'miner-XYZ', + type: 'miner', + code: 'S21', + info: { container: 'cont-B' }, + last: { alerts: [{ severity: 'low' }] } + } + ] + ) + + const mockReq = { query: { search: 'ABC' } } + const result = await getSiteAlerts(mockCtx, mockReq) + + t.is(result.total, 1, 'should find 1 match') + t.is(result.alerts[0].id, 'miner-ABC', 'should match by id') +}) + +test('getSiteAlerts - applies pagination', async (t) => { + const things = [] + for (let i = 0; i < 5; i++) { + things.push({ + id: `miner-${i}`, + type: 'miner', + code: 'S19', + info: { container: 'cont-A' }, + last: { alerts: [{ severity: 'high', name: `Alert ${i}` }] } + }) + } + + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => things + ) + + const mockReq = { query: { offset: '2', limit: '2' } } + const result = await getSiteAlerts(mockCtx, mockReq) + + t.is(result.total, 5, 'total should be all alerts') + t.is(result.alerts.length, 2, 'should return limited alerts') +}) + +// ==================== flattenHistoryAlert Tests ==================== + +test('flattenHistoryAlert - flattens nested thing structure', (t) => { + const alert = { + name: 'hashrate_low', + severity: 'medium', + uuid: 'abc', + createdAt: 1000, + thing: { + id: 'miner-1', + type: 'miner-am-s19xp', + code: 'AM-S19XP-0104', + tags: ['t-miner'], + info: { container: 'cont-A', pos: '1-2_c3' } + } + } + + 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.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') + t.ok(Array.isArray(result.tags), 'should flatten thing.tags to tags') + t.is(result.severity, 'medium', 'should preserve top-level fields') + t.ok(!result.thing, 'should remove nested thing object') +}) + +test('flattenHistoryAlert - handles missing thing', (t) => { + const alert = { name: 'test', severity: 'low', uuid: 'x' } + const result = flattenHistoryAlert(alert) + t.is(result.deviceId, undefined, 'deviceId should be undefined') + t.is(result.container, undefined, 'container should be undefined') + t.is(result.severity, 'low', 'should preserve severity') +}) + +// ==================== getAlertsHistory Tests ==================== + +const makeHistoryAlert = (uuid, createdAt, severity, thingOverrides = {}) => ({ + uuid, + createdAt, + severity, + name: `alert-${uuid}`, + description: 'Test alert', + thing: { + id: `thing-${uuid}`, + type: 'miner-am-s19xp', + code: `CODE-${uuid}`, + tags: ['t-miner'], + info: { container: 'cont-A', pos: '1-1' }, + ...thingOverrides + } +}) + +test('getAlertsHistory - happy path', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + makeHistoryAlert('a', 1000, 'high'), + makeHistoryAlert('b', 2000, 'low') + ] + ) + + const mockReq = { + query: { start: 1, end: 3000 } + } + + const result = await getAlertsHistory(mockCtx, mockReq) + + t.ok(result.alerts, 'should return alerts') + t.ok(typeof result.total === 'number', 'should return total') + t.is(result.total, 2, 'should have 2 alerts') + t.ok(result.alerts[0].deviceId, 'should have flattened deviceId') + t.ok(result.alerts[0].code, 'should have flattened code') + t.ok(!result.alerts[0].thing, 'should not have nested thing') +}) + +test('getAlertsHistory - deduplicates by uuid', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + makeHistoryAlert('a', 1000, 'high'), + makeHistoryAlert('a', 2000, 'high'), + makeHistoryAlert('b', 3000, 'low') + ] + ) + + const mockReq = { + query: { start: 1, end: 5000 } + } + + const result = await getAlertsHistory(mockCtx, mockReq) + + t.is(result.total, 2, 'should deduplicate to 2 alerts') +}) + +test('getAlertsHistory - default sort newest first', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + makeHistoryAlert('a', 1000, 'high'), + makeHistoryAlert('b', 3000, 'high'), + makeHistoryAlert('c', 2000, 'high') + ] + ) + + const mockReq = { + query: { start: 1, end: 5000 } + } + + const result = await getAlertsHistory(mockCtx, mockReq) + + t.is(result.alerts[0].createdAt, 3000, 'newest should be first') + t.is(result.alerts[2].createdAt, 1000, 'oldest should be last') +}) + +test('getAlertsHistory - applies filter on flattened fields', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + makeHistoryAlert('a', 1000, 'high'), + makeHistoryAlert('b', 2000, 'low') + ] + ) + + const mockReq = { + query: { + start: 1, + end: 5000, + filter: JSON.stringify({ severity: 'high' }) + } + } + + const result = await getAlertsHistory(mockCtx, mockReq) + + t.is(result.total, 1, 'should filter to 1 alert') + t.is(result.alerts[0].severity, 'high', 'should only include high severity') +}) + +test('getAlertsHistory - applies pagination', async (t) => { + const alerts = [] + for (let i = 0; i < 5; i++) { + alerts.push(makeHistoryAlert(`u${i}`, i * 1000, 'medium')) + } + + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => alerts + ) + + const mockReq = { + query: { start: 1, end: 10000, offset: '1', limit: '2' } + } + + const result = await getAlertsHistory(mockCtx, mockReq) + + t.is(result.total, 5, 'total should be all alerts') + t.is(result.alerts.length, 2, 'should return limited alerts') +}) + +test('getAlertsHistory - empty results', async (t) => { + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [] + ) + + const mockReq = { + query: { start: 1, end: 5000 } + } + + const result = await getAlertsHistory(mockCtx, mockReq) + + t.is(result.total, 0, 'should have 0 total') + t.is(result.alerts.length, 0, 'should have empty alerts') +}) + +test('getAlertsHistory - throws on invalid date range', async (t) => { + const mockCtx = createMockCtxWithOrks() + const mockReq = { + query: { start: 5000, end: 1000 } + } + + try { + await getAlertsHistory(mockCtx, mockReq) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw date range error') + } +}) diff --git a/tests/unit/routes/alerts.routes.test.js b/tests/unit/routes/alerts.routes.test.js new file mode 100644 index 0000000..93e2732 --- /dev/null +++ b/tests/unit/routes/alerts.routes.test.js @@ -0,0 +1,55 @@ +'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/alerts.routes.js' + +test('alerts routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'alerts') + t.pass() +}) + +test('alerts routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/alerts/site'), 'should have site alerts route') + t.ok(routeUrls.includes('/auth/alerts/history'), 'should have alerts history route') + + t.pass() +}) + +test('alerts 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('alerts routes - schema integration', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.ok(route.schema, `route ${route.url} should have schema`) + t.ok(typeof route.schema.querystring === 'object', `route ${route.url} querystring should be object`) + }) + + t.pass() +}) + +test('alerts routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'alerts') + t.pass() +}) + +test('alerts routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'alerts') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 400efe1..f64cf05 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -133,7 +133,11 @@ const ENDPOINTS = { POOL_MANAGER_ASSIGN: '/auth/pool-manager/miners/assign', POOL_MANAGER_POWER_MODE: '/auth/pool-manager/miners/power-mode', - SITE_STATUS_LIVE: '/auth/site/status/live' + SITE_STATUS_LIVE: '/auth/site/status/live', + + // Alerts endpoints + ALERTS_SITE: '/auth/alerts/site', + ALERTS_HISTORY: '/auth/alerts/history' } const HTTP_METHODS = { @@ -186,6 +190,7 @@ const RPC_METHODS = { TAIL_LOG_RANGE_AGGR: 'tailLogCustomRangeAggr', GET_WRK_EXT_DATA: 'getWrkExtData', LIST_THINGS: 'listThings', + GET_HISTORICAL_LOGS: 'getHistoricalLogs', TAIL_LOG: 'tailLog', GLOBAL_CONFIG: 'getGlobalConfig' } @@ -207,6 +212,18 @@ const CACHE_KEYS = { POOL_MANAGER_ALERTS: 'pool-manager/alerts' } +const SEVERITY_LEVELS = new Set(['critical', 'high', 'medium', 'low']) + +const ALERTS_DEFAULT_LIMIT = 100 +const ALERTS_MAX_SITE_LIMIT = 200 +const ALERTS_MAX_HISTORY_LIMIT = 1000 + +const SITE_ALERTS_FILTER_FIELDS = ['severity', 'type', 'container', 'deviceId'] +const SITE_ALERTS_SEARCH_FIELDS = ['id', 'code', 'container'] + +const HISTORY_FILTER_FIELDS = ['severity', 'code', 'deviceType', 'container', 'deviceId', 'tags'] +const HISTORY_SEARCH_FIELDS = ['name', 'description', 'position', 'code'] + const POOL_ALERT_TYPES = [ 'all_pools_dead', 'wrong_miner_pool', @@ -301,5 +318,13 @@ module.exports = { MINERPOOL_EXT_DATA_KEYS, NON_METRIC_KEYS, BTC_SATS, - RANGE_BUCKETS + RANGE_BUCKETS, + SEVERITY_LEVELS, + ALERTS_DEFAULT_LIMIT, + ALERTS_MAX_SITE_LIMIT, + ALERTS_MAX_HISTORY_LIMIT, + SITE_ALERTS_FILTER_FIELDS, + SITE_ALERTS_SEARCH_FIELDS, + HISTORY_FILTER_FIELDS, + HISTORY_SEARCH_FIELDS } diff --git a/workers/lib/server/handlers/alerts.handlers.js b/workers/lib/server/handlers/alerts.handlers.js new file mode 100644 index 0000000..dc66059 --- /dev/null +++ b/workers/lib/server/handlers/alerts.handlers.js @@ -0,0 +1,169 @@ +'use strict' + +const { + RPC_METHODS, + SEVERITY_LEVELS, + ALERTS_DEFAULT_LIMIT, + ALERTS_MAX_SITE_LIMIT, + ALERTS_MAX_HISTORY_LIMIT, + SITE_ALERTS_FILTER_FIELDS, + SITE_ALERTS_SEARCH_FIELDS, + HISTORY_FILTER_FIELDS, + HISTORY_SEARCH_FIELDS +} = require('../../constants') +const { requestRpcMapLimit, parseJsonQueryParam, matchesFilter, deduplicateAlerts } = require('../../utils') + +function extractAlertsFromThings (things) { + const alerts = [] + for (const thing of things) { + if (Array.isArray(thing?.last?.alerts)) { + for (const alert of thing.last.alerts) { + if (alert && typeof alert === 'object' && !Array.isArray(alert)) { + alerts.push({ + ...alert, + id: thing.id, + type: thing.type, + code: thing.code, + container: thing.info?.container + }) + } + } + } + } + return alerts +} + +function matchesSearch (item, search, fields) { + if (!search) return true + const lowerSearch = search.toLowerCase() + for (const field of fields) { + const val = item[field] + if (val != null && String(val).toLowerCase().includes(lowerSearch)) { + return true + } + } + return false +} + +function applySort (items, sort) { + if (!sort) return items + const entries = Object.entries(sort) + if (!entries.length) return items + + return items.slice().sort((a, b) => { + for (const [field, dir] of entries) { + const aVal = a[field] + const bVal = b[field] + if (aVal < bVal) return dir === 1 ? -1 : 1 + if (aVal > bVal) return dir === 1 ? 1 : -1 + } + return 0 + }) +} + +function buildSeveritySummary (alerts) { + const summary = { critical: 0, high: 0, medium: 0, low: 0, total: alerts.length } + for (const alert of alerts) { + if (SEVERITY_LEVELS.has(alert.severity)) { + summary[alert.severity]++ + } + } + return summary +} + +function flattenHistoryAlert (entry) { + const thing = entry.thing || {} + return { + name: entry.name, + description: entry.description, + severity: entry.severity, + createdAt: entry.createdAt, + uuid: entry.uuid, + deviceId: thing.id, + deviceType: thing.type, + code: thing.code, + container: thing.info?.container, + position: thing.info?.pos, + tags: thing.tags + } +} + +async function getSiteAlerts (ctx, req) { + const filter = parseJsonQueryParam(req.query.filter, 'ERR_INVALID_FILTER') + 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 results = await requestRpcMapLimit(ctx, RPC_METHODS.LIST_THINGS, { + status: 1, + query: { 'last.alerts': { $ne: null } }, + fields: { + 'last.alerts': 1, + 'info.container': 1, + type: 1, + id: 1, + code: 1 + } + }) + + 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) + ) + + const summary = buildSeveritySummary(alerts) + alerts = applySort(alerts, sort) + const total = alerts.length + alerts = alerts.slice(offset, offset + limit) + + return { alerts, summary, total } +} + +async function getAlertsHistory (ctx, req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const filter = parseJsonQueryParam(req.query.filter, 'ERR_INVALID_FILTER') + 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) + + const results = await requestRpcMapLimit(ctx, RPC_METHODS.GET_HISTORICAL_LOGS, { + start, + end, + logType: 'alerts' + }) + + 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) + ) + + alerts = applySort(alerts, sort) + const total = alerts.length + alerts = alerts.slice(offset, offset + limit) + + return { alerts, total } +} + +module.exports = { + getSiteAlerts, + getAlertsHistory, + extractAlertsFromThings, + matchesSearch, + applySort, + buildSeveritySummary, + flattenHistoryAlert +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 840e405..c6a7409 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -12,6 +12,7 @@ const financeRoutes = require('./routes/finance.routes') const poolsRoutes = require('./routes/pools.routes') const poolManagerRoutes = require('./routes/poolManager.routes') const siteRoutes = require('./routes/site.routes') +const alertsRoutes = require('./routes/alerts.routes') /** * Collect all routes into a flat array for server injection. @@ -30,7 +31,8 @@ function routes (ctx) { ...financeRoutes(ctx), ...poolsRoutes(ctx), ...poolManagerRoutes(ctx), - ...siteRoutes(ctx) + ...siteRoutes(ctx), + ...alertsRoutes(ctx) ] } diff --git a/workers/lib/server/routes/alerts.routes.js b/workers/lib/server/routes/alerts.routes.js new file mode 100644 index 0000000..f56ad61 --- /dev/null +++ b/workers/lib/server/routes/alerts.routes.js @@ -0,0 +1,63 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_PERMISSIONS +} = require('../../constants') +const { + getSiteAlerts, + getAlertsHistory +} = require('../handlers/alerts.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/alerts.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.ALERTS_SITE, + schema: { + querystring: schemas.query.siteAlerts + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'alerts/site', + req.query.filter, + req.query.sort, + req.query.search, + req.query.offset, + req.query.limit + ], + ENDPOINTS.ALERTS_SITE, + getSiteAlerts, + [AUTH_PERMISSIONS.ALERTS] + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.ALERTS_HISTORY, + schema: { + querystring: schemas.query.alertsHistory + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'alerts/history', + req.query.start, + req.query.end, + req.query.filter, + req.query.search, + req.query.sort, + req.query.offset, + req.query.limit + ], + ENDPOINTS.ALERTS_HISTORY, + getAlertsHistory, + [AUTH_PERMISSIONS.ALERTS] + ) + } + ] +} diff --git a/workers/lib/server/schemas/alerts.schemas.js b/workers/lib/server/schemas/alerts.schemas.js new file mode 100644 index 0000000..5173c31 --- /dev/null +++ b/workers/lib/server/schemas/alerts.schemas.js @@ -0,0 +1,33 @@ +'use strict' + +const schemas = { + query: { + siteAlerts: { + type: 'object', + properties: { + filter: { type: 'string' }, + sort: { type: 'string' }, + search: { type: 'string' }, + offset: { type: 'integer' }, + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + } + }, + alertsHistory: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + filter: { type: 'string' }, + search: { type: 'string' }, + sort: { type: 'string' }, + offset: { type: 'integer' }, + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + } +} + +module.exports = schemas diff --git a/workers/lib/utils.js b/workers/lib/utils.js index cc31980..069bd99 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -177,6 +177,35 @@ const safeDiv = (numerator, denominator) => ? numerator / denominator : null +function deduplicateAlerts (alerts) { + const seen = new Set() + const result = [] + for (const alert of alerts) { + if (!alert.uuid) { + result.push(alert) + } else if (!seen.has(alert.uuid)) { + seen.add(alert.uuid) + result.push(alert) + } + } + return result +} + +function matchesFilter (item, filter, allowedFields) { + if (!filter) return true + for (const key of allowedFields) { + if (filter[key] === undefined) continue + const filterVal = filter[key] + const itemVal = item[key] + if (Array.isArray(filterVal)) { + if (!filterVal.includes(itemVal)) return false + } else if (itemVal !== filterVal) { + return false + } + } + return true +} + module.exports = { dateNowSec, extractIps, @@ -190,5 +219,7 @@ module.exports = { requestRpcMapAllPages, getStartOfDay, safeDiv, - runParallel + runParallel, + deduplicateAlerts, + matchesFilter } From d02a78c8d2eb82b69c1e3f14d9bb2b7219705d4e Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 5 Mar 2026 11:29:03 +0300 Subject: [PATCH 07/63] feat: add metrics API v2 endpoints (#21) --- tests/unit/handlers/metrics.handlers.test.js | 1599 +++++++++++++++++ tests/unit/routes/metrics.routes.test.js | 65 + workers/lib/constants.js | 69 +- workers/lib/metrics.utils.js | 112 ++ .../lib/server/handlers/metrics.handlers.js | 724 ++++++++ workers/lib/server/index.js | 2 + workers/lib/server/routes/metrics.routes.js | 184 ++ workers/lib/server/schemas/metrics.schemas.js | 89 + 8 files changed, 2842 insertions(+), 2 deletions(-) create mode 100644 tests/unit/handlers/metrics.handlers.test.js create mode 100644 tests/unit/routes/metrics.routes.test.js create mode 100644 workers/lib/metrics.utils.js create mode 100644 workers/lib/server/handlers/metrics.handlers.js create mode 100644 workers/lib/server/routes/metrics.routes.js create mode 100644 workers/lib/server/schemas/metrics.schemas.js diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js new file mode 100644 index 0000000..9a6355b --- /dev/null +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -0,0 +1,1599 @@ +'use strict' + +const test = require('brittle') +const { + getHashrate, + processHashrateData, + calculateHashrateSummary, + getConsumption, + processConsumptionData, + calculateConsumptionSummary, + getEfficiency, + processEfficiencyData, + calculateEfficiencySummary, + getMinerStatus, + processMinerStatusData, + calculateMinerStatusSummary, + sumObjectValues, + parseEntryTs, + resolveInterval, + getIntervalConfig, + getPowerMode, + processPowerModeData, + calculatePowerModeSummary, + categorizeMiner, + getPowerModeTimeline, + processPowerModeTimelineData, + getTemperature, + processTemperatureData, + calculateTemperatureSummary, + forEachRangeAggrItem, + getContainerTelemetry, + processContainerMiners, + processContainerSensorSnapshot, + getContainerHistory, + processContainerHistoryData +} = require('../../../workers/lib/server/handlers/metrics.handlers') + +// ==================== Hashrate Tests ==================== + +test('getHashrate - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ type: 'miner', data: [{ ts: dayTs, val: { hashrate_mhs_5m_sum_aggr: 100000 } }], error: null }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000 } + } + + const result = await getHashrate(mockCtx, mockReq) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'log should have entries') + t.is(result.log[0].hashrateMhs, 100000, 'should have hashrate value') + t.ok(result.summary.avgHashrateMhs !== null, 'should have avg hashrate') + t.is(result.summary.totalHashrateMhs, 100000, 'should have total hashrate') + t.pass() +}) + +test('getHashrate - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getHashrate(mockCtx, { query: { end: 1700100000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getHashrate - missing end throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getHashrate(mockCtx, { query: { start: 1700000000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getHashrate - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getHashrate(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getHashrate - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getHashrate(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.is(result.log.length, 0, 'log should be empty with no data') + t.is(result.summary.totalHashrateMhs, 0, 'total should be zero') + t.is(result.summary.avgHashrateMhs, null, 'avg should be null') + t.pass() +}) + +test('processHashrateData - processes array data from ORK', (t) => { + const results = [ + [{ type: 'miner', data: [{ ts: 1700006400000, val: { hashrate_mhs_5m_sum_aggr: 100000 } }], error: null }] + ] + + const daily = processHashrateData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key], 100000, 'should extract hashrate from val') + t.pass() +}) + +test('processHashrateData - processes object-keyed data', (t) => { + const results = [ + [{ data: { 1700006400000: { hashrate_mhs_5m_sum_aggr: 100000 } } }] + ] + + const daily = processHashrateData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + t.pass() +}) + +test('processHashrateData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processHashrateData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processHashrateData - aggregates multiple orks', (t) => { + const results = [ + [{ data: { 1700006400000: { hashrate_mhs_5m_sum_aggr: 50000 } } }], + [{ data: { 1700006400000: { hashrate_mhs_5m_sum_aggr: 30000 } } }] + ] + + const daily = processHashrateData(results) + const key = Object.keys(daily)[0] + t.is(daily[key], 80000, 'should sum hashrate from multiple orks') + t.pass() +}) + +test('calculateHashrateSummary - calculates from log entries', (t) => { + const log = [ + { ts: 1700006400000, hashrateMhs: 100000 }, + { ts: 1700092800000, hashrateMhs: 120000 } + ] + + const summary = calculateHashrateSummary(log) + t.is(summary.totalHashrateMhs, 220000, 'should sum hashrate') + t.is(summary.avgHashrateMhs, 110000, 'should average hashrate') + t.pass() +}) + +test('calculateHashrateSummary - handles empty log', (t) => { + const summary = calculateHashrateSummary([]) + t.is(summary.totalHashrateMhs, 0, 'should be zero') + t.is(summary.avgHashrateMhs, null, 'should be null') + t.pass() +}) + +// ==================== Consumption Tests ==================== + +test('getConsumption - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ type: 'powermeter', data: [{ ts: dayTs, val: { site_power_w: 5000000 } }], error: null }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000 } + } + + const result = await getConsumption(mockCtx, mockReq) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'log should have entries') + t.is(result.log[0].powerW, 5000000, 'should have power value') + t.is(result.log[0].consumptionMWh, (5000000 * 24) / 1000000, 'should convert to MWh') + t.ok(result.summary.avgPowerW !== null, 'should have avg power') + t.ok(result.summary.totalConsumptionMWh > 0, 'should have total consumption') + t.pass() +}) + +test('getConsumption - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getConsumption(mockCtx, { query: { end: 1700100000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getConsumption - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getConsumption(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getConsumption - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getConsumption(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty with no data') + t.is(result.summary.totalConsumptionMWh, 0, 'total should be zero') + t.is(result.summary.avgPowerW, null, 'avg should be null') + t.pass() +}) + +test('processConsumptionData - processes array data from ORK', (t) => { + const results = [ + [{ type: 'powermeter', data: [{ ts: 1700006400000, val: { site_power_w: 5000 } }], error: null }] + ] + + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key], 5000, 'should extract power from val') + t.pass() +}) + +test('processConsumptionData - processes object-keyed data', (t) => { + const results = [ + [{ data: { 1700006400000: { site_power_w: 5000 } } }] + ] + + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + t.pass() +}) + +test('processConsumptionData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processConsumptionData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processConsumptionData - aggregates multiple orks', (t) => { + const results = [ + [{ data: { 1700006400000: { site_power_w: 3000 } } }], + [{ data: { 1700006400000: { site_power_w: 2000 } } }] + ] + + const daily = processConsumptionData(results) + const key = Object.keys(daily)[0] + t.is(daily[key], 5000, 'should sum power from multiple orks') + t.pass() +}) + +test('calculateConsumptionSummary - calculates from log entries', (t) => { + const log = [ + { ts: 1700006400000, powerW: 5000000, consumptionMWh: 120 }, + { ts: 1700092800000, powerW: 4000000, consumptionMWh: 96 } + ] + + const summary = calculateConsumptionSummary(log) + t.is(summary.totalConsumptionMWh, 216, 'should sum consumption') + t.is(summary.avgPowerW, 4500000, 'should average power') + t.pass() +}) + +test('calculateConsumptionSummary - handles empty log', (t) => { + const summary = calculateConsumptionSummary([]) + t.is(summary.totalConsumptionMWh, 0, 'should be zero') + t.is(summary.avgPowerW, null, 'should be null') + t.pass() +}) + +// ==================== Efficiency Tests ==================== + +test('getEfficiency - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ type: 'miner', data: [{ ts: dayTs, val: { efficiency_w_ths_avg_aggr: 25.5 } }], error: null }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000 } + } + + const result = await getEfficiency(mockCtx, mockReq) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'log should have entries') + t.is(result.log[0].efficiencyWThs, 25.5, 'should have efficiency value') + t.ok(result.summary.avgEfficiencyWThs !== null, 'should have avg efficiency') + t.pass() +}) + +test('getEfficiency - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getEfficiency(mockCtx, { query: { end: 1700100000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getEfficiency - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getEfficiency(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getEfficiency - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getEfficiency(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty with no data') + t.is(result.summary.avgEfficiencyWThs, null, 'avg should be null') + t.pass() +}) + +test('processEfficiencyData - processes array data from ORK', (t) => { + const results = [ + [{ type: 'miner', data: [{ ts: 1700006400000, val: { efficiency_w_ths_avg_aggr: 25.5 } }], error: null }] + ] + + const daily = processEfficiencyData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key].total, 25.5, 'should extract efficiency total') + t.is(daily[key].count, 1, 'should track count') + t.pass() +}) + +test('processEfficiencyData - processes object-keyed data', (t) => { + const results = [ + [{ data: { 1700006400000: { efficiency_w_ths_avg_aggr: 25.5 } } }] + ] + + const daily = processEfficiencyData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + t.pass() +}) + +test('processEfficiencyData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processEfficiencyData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processEfficiencyData - averages across multiple orks', (t) => { + const results = [ + [{ data: { 1700006400000: { efficiency_w_ths_avg_aggr: 20 } } }], + [{ data: { 1700006400000: { efficiency_w_ths_avg_aggr: 30 } } }] + ] + + const daily = processEfficiencyData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].total, 50, 'should sum efficiency totals') + t.is(daily[key].count, 2, 'should track count from multiple orks') + t.pass() +}) + +test('calculateEfficiencySummary - calculates from log entries', (t) => { + const log = [ + { ts: 1700006400000, efficiencyWThs: 25 }, + { ts: 1700092800000, efficiencyWThs: 27 } + ] + + const summary = calculateEfficiencySummary(log) + t.is(summary.avgEfficiencyWThs, 26, 'should average efficiency') + t.pass() +}) + +test('calculateEfficiencySummary - handles empty log', (t) => { + const summary = calculateEfficiencySummary([]) + t.is(summary.avgEfficiencyWThs, null, 'should be null') + t.pass() +}) + +// ==================== Miner Status Tests ==================== + +test('sumObjectValues - sums keyed object values', (t) => { + t.is(sumObjectValues({ a: 5, b: 3, c: 2 }), 10, 'should sum all values') + t.is(sumObjectValues({}), 0, 'should return 0 for empty object') + t.is(sumObjectValues(null), 0, 'should return 0 for null') + t.is(sumObjectValues(undefined), 0, 'should return 0 for undefined') + t.is(sumObjectValues({ a: 'not_a_number', b: 5 }), 5, 'should skip non-numeric values') + t.pass() +}) + +test('getMinerStatus - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ + ts: dayTs, + type_cnt: { 'miner-am-s19xp': 60, 'miner-wm-m30sp': 40 }, + offline_cnt: { offl_hashboard: 5, offl_fan: 3 }, + power_mode_sleep_cnt: { sleep: 10 }, + maintenance_type_cnt: { repair: 2 } + }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000 } + } + + const result = await getMinerStatus(mockCtx, mockReq) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'log should have entries') + t.is(result.log[0].offline, 8, 'should sum offline counts (5+3)') + t.is(result.log[0].sleep, 10, 'should sum sleep counts') + t.is(result.log[0].maintenance, 2, 'should sum maintenance counts') + t.is(result.log[0].online, 80, 'should derive online (100-8-10-2)') + t.pass() +}) + +test('getMinerStatus - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getMinerStatus(mockCtx, { query: { end: 1700100000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getMinerStatus - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getMinerStatus(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getMinerStatus - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getMinerStatus(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty with no data') + t.is(result.summary.avgOnline, null, 'avg online should be null') + t.is(result.summary.avgOffline, null, 'avg offline should be null') + t.pass() +}) + +test('processMinerStatusData - processes daily entries', (t) => { + const results = [[ + { + ts: 1700006400000, + type_cnt: { 'miner-am-s19xp': 60, 'miner-wm-m30sp': 40 }, + offline_cnt: { offl_hashboard: 5 }, + power_mode_sleep_cnt: { sleep: 10 }, + maintenance_type_cnt: { repair: 2 } + } + ]] + + const daily = processMinerStatusData(results) + t.ok(typeof daily === 'object', 'should return object') + const key = Object.keys(daily)[0] + t.is(daily[key].offline, 5, 'should extract offline count') + t.is(daily[key].sleep, 10, 'should extract sleep count') + t.is(daily[key].maintenance, 2, 'should extract maintenance count') + t.is(daily[key].online, 83, 'should derive online count (100-5-10-2)') + t.pass() +}) + +test('processMinerStatusData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processMinerStatusData(results) + t.ok(typeof daily === 'object', 'should return object') + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processMinerStatusData - aggregates multiple orks same day', (t) => { + const results = [ + [{ + ts: 1700006400000, + type_cnt: { 'miner-am-s19xp': 30, 'miner-wm-m30sp': 20 }, + offline_cnt: { offl_fan: 3 }, + power_mode_sleep_cnt: { sleep: 5 }, + maintenance_type_cnt: {} + }], + [{ + ts: 1700006400000, + type_cnt: { 'miner-am-s19xp': 30, 'miner-wm-m30sp': 20 }, + offline_cnt: { offl_hashboard: 2 }, + power_mode_sleep_cnt: {}, + maintenance_type_cnt: { repair: 1 } + }] + ] + + const daily = processMinerStatusData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].offline, 5, 'should sum offline across orks (3+2)') + t.is(daily[key].sleep, 5, 'should sum sleep across orks') + t.is(daily[key].maintenance, 1, 'should sum maintenance across orks') + t.is(daily[key].online, 89, 'should derive total online (47+42)') + t.pass() +}) + +test('processMinerStatusData - handles entries with aggrFields wrapper', (t) => { + const results = [[ + { + ts: 1700006400000, + type_cnt: { 'miner-am-s19xp': 60, 'miner-wm-m30sp': 40 }, + aggrFields: { + offline_cnt: { offl_hashboard: 10 }, + power_mode_sleep_cnt: { sleep: 5 }, + maintenance_type_cnt: { repair: 3 } + } + } + ]] + + const daily = processMinerStatusData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].offline, 10, 'should extract from aggrFields wrapper') + t.is(daily[key].sleep, 5, 'should extract sleep from aggrFields') + t.is(daily[key].maintenance, 3, 'should extract maintenance from aggrFields') + t.pass() +}) + +test('calculateMinerStatusSummary - calculates from log entries', (t) => { + const log = [ + { ts: 1700006400000, online: 80, offline: 10, sleep: 5, maintenance: 5 }, + { ts: 1700092800000, online: 85, offline: 8, sleep: 4, maintenance: 3 } + ] + + const summary = calculateMinerStatusSummary(log) + t.is(summary.avgOnline, 82.5, 'should average online') + t.is(summary.avgOffline, 9, 'should average offline') + t.is(summary.avgSleep, 4.5, 'should average sleep') + t.is(summary.avgMaintenance, 4, 'should average maintenance') + t.pass() +}) + +test('calculateMinerStatusSummary - handles empty log', (t) => { + const summary = calculateMinerStatusSummary([]) + t.is(summary.avgOnline, null, 'should be null') + t.is(summary.avgOffline, null, 'should be null') + t.is(summary.avgSleep, null, 'should be null') + t.is(summary.avgMaintenance, null, 'should be null') + t.pass() +}) + +// ==================== Interval Utils Tests ==================== + +test('resolveInterval - auto-selects 1h for <= 2 days', (t) => { + const twoDays = 2 * 24 * 60 * 60 * 1000 + t.is(resolveInterval(0, twoDays, null), '1h', 'should select 1h for 2 day range') + t.is(resolveInterval(0, twoDays - 1, null), '1h', 'should select 1h for < 2 day range') + t.pass() +}) + +test('resolveInterval - auto-selects 1d for <= 90 days', (t) => { + const threeDays = 3 * 24 * 60 * 60 * 1000 + const ninetyDays = 90 * 24 * 60 * 60 * 1000 + t.is(resolveInterval(0, threeDays, null), '1d', 'should select 1d for 3 day range') + t.is(resolveInterval(0, ninetyDays, null), '1d', 'should select 1d for 90 day range') + t.pass() +}) + +test('resolveInterval - auto-selects 1w for > 90 days', (t) => { + const ninetyOneDays = 91 * 24 * 60 * 60 * 1000 + t.is(resolveInterval(0, ninetyOneDays, null), '1w', 'should select 1w for > 90 day range') + t.pass() +}) + +test('resolveInterval - uses requested interval when provided', (t) => { + t.is(resolveInterval(0, 1000, '1w'), '1w', 'should use requested interval') + t.is(resolveInterval(0, 999999999999, '1h'), '1h', 'should override auto with requested') + t.pass() +}) + +test('getIntervalConfig - returns correct configs', (t) => { + const h = getIntervalConfig('1h') + t.is(h.key, 'stat-3h', '1h key should be stat-3h') + t.is(h.groupRange, null, '1h should have no groupRange') + + const d = getIntervalConfig('1d') + t.is(d.key, 'stat-3h', '1d key should be stat-3h') + t.is(d.groupRange, '1D', '1d groupRange should be 1D') + + const w = getIntervalConfig('1w') + t.is(w.key, 'stat-3h', '1w key should be stat-3h') + t.is(w.groupRange, '1W', '1w groupRange should be 1W') + + t.pass() +}) + +// ==================== forEachRangeAggrItem Tests ==================== + +test('forEachRangeAggrItem - handles null entry without crashing', (t) => { + let called = false + forEachRangeAggrItem(null, () => { called = true }) + t.is(called, false, 'callback should not be called for null entry') + forEachRangeAggrItem(undefined, () => { called = true }) + t.is(called, false, 'callback should not be called for undefined entry') + t.pass() +}) + +// ==================== parseEntryTs Tests ==================== + +test('parseEntryTs - handles numeric ts', (t) => { + t.is(parseEntryTs(1700006400000), 1700006400000, 'should return number as-is') + t.pass() +}) + +test('parseEntryTs - handles range string ts', (t) => { + t.is(parseEntryTs('1770854400000-1771459199999'), 1770854400000, 'should extract start of range') + t.is(parseEntryTs('1771459200000-1771545599999'), 1771459200000, 'should extract start of range') + t.pass() +}) + +test('parseEntryTs - handles plain numeric string', (t) => { + t.is(parseEntryTs('1700006400000'), 1700006400000, 'should parse numeric string') + t.pass() +}) + +test('parseEntryTs - returns null for invalid input', (t) => { + t.is(parseEntryTs(null), null, 'null returns null') + t.is(parseEntryTs(undefined), null, 'undefined returns null') + t.pass() +}) + +// ==================== Power Mode Tests ==================== + +test('processPowerModeData - handles range string ts with groupRange', (t) => { + const results = [[{ + ts: '1700006400000-1700092799999', + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + }]] + + const points = processPowerModeData(results, '1D') + t.ok(Object.keys(points).length > 0, 'should have entries despite range string ts') + const key = Object.keys(points)[0] + t.is(points[key].normal, 1, 'should count normal') + t.pass() +}) + +test('processTemperatureData - handles range string ts with groupRange', (t) => { + const results = [[{ + ts: '1700006400000-1700092799999', + temperature_c_group_max_aggr: { cont1: 65 }, + temperature_c_group_avg_aggr: { cont1: 55 } + }]] + + const points = processTemperatureData(results, '1D', null) + t.ok(Object.keys(points).length > 0, 'should have entries despite range string ts') + const key = Object.keys(points)[0] + t.is(points[key].containers.cont1.maxC, 65, 'should have temp data') + t.pass() +}) + +test('getPowerMode - happy path', async (t) => { + const ts = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ + ts, + power_mode_group_aggr: { 'cont1-miner1': 'normal', 'cont1-miner2': 'low' }, + status_group_aggr: { 'cont1-miner1': 'mining', 'cont1-miner2': 'mining' } + }] + } + } + } + + const result = await getPowerMode(mockCtx, { + query: { start: 1700000000000, end: 1700100000000 } + }) + + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'log should have entries') + t.is(result.log[0].normal, 1, 'should count normal miners') + t.is(result.log[0].low, 1, 'should count low miners') + t.pass() +}) + +test('getPowerMode - missing start/end throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPowerMode(mockCtx, { query: { end: 1700100000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getPowerMode - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPowerMode(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getPowerMode - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getPowerMode(mockCtx, { + query: { start: 1700000000000, end: 1700100000000 } + }) + + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.is(result.summary.avgNormal, null, 'avg should be null') + t.pass() +}) + +test('categorizeMiner - status overrides power mode', (t) => { + t.is(categorizeMiner('normal', 'offline'), 'offline', 'offline status should override') + t.is(categorizeMiner('high', 'error'), 'error', 'error status should override') + t.is(categorizeMiner('normal', 'maintenance'), 'maintenance', 'maintenance should override') + t.is(categorizeMiner('high', 'idle'), 'notMining', 'idle should map to notMining') + t.is(categorizeMiner('high', 'stopped'), 'notMining', 'stopped should map to notMining') + t.pass() +}) + +test('categorizeMiner - power mode categories', (t) => { + t.is(categorizeMiner('low', 'mining'), 'low', 'low mode with mining status') + t.is(categorizeMiner('high', 'mining'), 'high', 'high mode with mining status') + t.is(categorizeMiner('sleep', 'mining'), 'sleep', 'sleep mode with mining status') + t.is(categorizeMiner('normal', 'mining'), 'normal', 'normal mode with mining status') + t.is(categorizeMiner('normal', ''), 'normal', 'normal mode with empty status') + t.pass() +}) + +test('categorizeMiner - unknown power mode passes through raw value', (t) => { + t.is(categorizeMiner('turbo', 'mining'), 'turbo', 'unknown mode should pass through') + t.is(categorizeMiner('eco', ''), 'eco', 'unknown mode with empty status should pass through') + t.pass() +}) + +test('categorizeMiner - null/undefined power mode defaults to normal', (t) => { + t.is(categorizeMiner(null, 'mining'), 'normal', 'null mode should default to normal') + t.is(categorizeMiner(undefined, 'mining'), 'normal', 'undefined mode should default to normal') + t.is(categorizeMiner('', 'mining'), 'normal', 'empty string mode should default to normal') + t.pass() +}) + +test('processPowerModeData - counts modes correctly', (t) => { + const results = [[{ + ts: 1700006400000, + power_mode_group_aggr: { + 'cont1-miner1': 'normal', + 'cont1-miner2': 'low', + 'cont1-miner3': 'high' + }, + status_group_aggr: { + 'cont1-miner1': 'mining', + 'cont1-miner2': 'mining', + 'cont1-miner3': 'offline' + } + }]] + + const points = processPowerModeData(results, '1D') + const key = Object.keys(points)[0] + t.is(points[key].normal, 1, 'should count 1 normal') + t.is(points[key].low, 1, 'should count 1 low') + t.is(points[key].offline, 1, 'miner3 offline overrides high') + t.is(points[key].high, 0, 'miner3 classified as offline, not high') + t.pass() +}) + +test('processPowerModeData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const points = processPowerModeData(results, '1D') + t.is(Object.keys(points).length, 0, 'should be empty') + t.pass() +}) + +test('processPowerModeData - merges across multiple orks', (t) => { + const results = [ + [{ + ts: 1700006400000, + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + }], + [{ + ts: 1700006400000, + power_mode_group_aggr: { 'cont2-miner1': 'low' }, + status_group_aggr: { 'cont2-miner1': 'mining' } + }] + ] + + const points = processPowerModeData(results, '1D') + const key = Object.keys(points)[0] + t.is(points[key].normal, 1, 'should count ork1 normal') + t.is(points[key].low, 1, 'should count ork2 low') + t.pass() +}) + +test('calculatePowerModeSummary - calculates averages', (t) => { + const log = [ + { ts: 1, low: 2, normal: 8, high: 0, sleep: 0, offline: 0, notMining: 0, maintenance: 0, error: 0 }, + { ts: 2, low: 4, normal: 6, high: 0, sleep: 0, offline: 0, notMining: 0, maintenance: 0, error: 0 } + ] + + const summary = calculatePowerModeSummary(log) + t.is(summary.avgLow, 3, 'should average low') + t.is(summary.avgNormal, 7, 'should average normal') + t.pass() +}) + +test('calculatePowerModeSummary - handles empty log', (t) => { + const summary = calculatePowerModeSummary([]) + t.is(summary.avgNormal, null, 'should be null') + t.is(summary.avgLow, null, 'should be null') + t.is(summary.avgOffline, null, 'should be null') + t.pass() +}) + +// ==================== Power Mode Timeline Tests ==================== + +test('getPowerModeTimeline - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [ + { + ts: 1700000000000, + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + }, + { + ts: 1700010800000, + power_mode_group_aggr: { 'cont1-miner1': 'low' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + } + ] + } + } + } + + const result = await getPowerModeTimeline(mockCtx, { + query: { start: 1700000000000, end: 1700100000000 } + }) + + t.ok(result.log, 'should return log array') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'log should have entries') + t.is(result.log[0].minerId, 'cont1-miner1', 'should have miner ID') + t.ok(result.log[0].segments.length > 0, 'should have segments') + t.pass() +}) + +test('getPowerModeTimeline - default start/end', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ([]) } + } + + const result = await getPowerModeTimeline(mockCtx, { query: {} }) + t.ok(result.log, 'should return log with defaults') + t.ok(Array.isArray(result.log), 'should be array') + t.pass() +}) + +test('getPowerModeTimeline - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getPowerModeTimeline(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getPowerModeTimeline - empty results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getPowerModeTimeline(mockCtx, { + query: { start: 1700000000000, end: 1700100000000 } + }) + + t.is(result.log.length, 0, 'should be empty') + t.pass() +}) + +test('processPowerModeTimelineData - groups by miner and sorts by ts', (t) => { + const results = [[ + { + ts: 1700010800000, + power_mode_group_aggr: { 'cont1-miner1': 'low' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + }, + { + ts: 1700000000000, + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + } + ]] + + const log = processPowerModeTimelineData(results, null) + t.is(log.length, 1, 'should group into 1 miner') + t.is(log[0].minerId, 'cont1-miner1', 'should have correct miner id') + t.is(log[0].segments[0].powerMode, 'normal', 'first segment should be earlier entry (normal)') + t.is(log[0].segments[1].powerMode, 'low', 'second segment should be later entry (low)') + t.pass() +}) + +test('processPowerModeTimelineData - merges consecutive same-mode segments', (t) => { + const results = [[ + { + ts: 1700000000000, + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + }, + { + ts: 1700010800000, + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + }, + { + ts: 1700021600000, + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + } + ]] + + const log = processPowerModeTimelineData(results, null) + t.is(log[0].segments.length, 1, 'should merge 3 entries into 1 segment') + t.is(log[0].segments[0].from, 1700000000000, 'segment should start at first entry') + t.is(log[0].segments[0].to, 1700021600000, 'segment should end at last entry') + t.pass() +}) + +test('processPowerModeTimelineData - mode changes create new segments', (t) => { + const results = [[ + { + ts: 1700000000000, + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + }, + { + ts: 1700010800000, + power_mode_group_aggr: { 'cont1-miner1': 'low' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + }, + { + ts: 1700021600000, + power_mode_group_aggr: { 'cont1-miner1': 'normal' }, + status_group_aggr: { 'cont1-miner1': 'mining' } + } + ]] + + const log = processPowerModeTimelineData(results, null) + t.is(log[0].segments.length, 3, 'should create 3 separate segments') + t.is(log[0].segments[0].powerMode, 'normal', 'first segment normal') + t.is(log[0].segments[1].powerMode, 'low', 'second segment low') + t.is(log[0].segments[2].powerMode, 'normal', 'third segment normal') + t.pass() +}) + +test('processPowerModeTimelineData - extracts container from miner id', (t) => { + const results = [[ + { + ts: 1700000000000, + power_mode_group_aggr: { 'container-a-pos1-miner1': 'normal' }, + status_group_aggr: { 'container-a-pos1-miner1': 'mining' } + } + ]] + + const log = processPowerModeTimelineData(results, null) + t.is(log[0].container, 'container-a-pos1', 'should extract container from miner id') + t.pass() +}) + +test('getPowerModeTimeline - always uses t-miner tag', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [] + } + } + } + + await getPowerModeTimeline(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, container: 'my-container' } + }) + + t.is(capturedPayload.tag, 't-miner', 'should always use t-miner tag for RPC') + t.pass() +}) + +test('processPowerModeTimelineData - filters by container post-RPC', (t) => { + const results = [[ + { + ts: 1700000000000, + power_mode_group_aggr: { 'cont1-miner1': 'normal', 'cont2-miner1': 'low' }, + status_group_aggr: { 'cont1-miner1': 'mining', 'cont2-miner1': 'mining' } + } + ]] + + const log = processPowerModeTimelineData(results, 'cont1') + t.is(log.length, 1, 'should only include miners from cont1') + t.is(log[0].container, 'cont1', 'should be cont1') + t.pass() +}) + +// ==================== Temperature Tests ==================== + +test('getTemperature - happy path', async (t) => { + const ts = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ + ts, + temperature_c_group_max_aggr: { container1: 65, container2: 72 }, + temperature_c_group_avg_aggr: { container1: 55, container2: 60 } + }] + } + } + } + + const result = await getTemperature(mockCtx, { + query: { start: 1700000000000, end: 1700100000000 } + }) + + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.log.length > 0, 'log should have entries') + t.ok(result.log[0].containers, 'should have containers object') + t.is(result.log[0].containers.container1.maxC, 65, 'should have container1 max temp') + t.is(result.log[0].containers.container2.avgC, 60, 'should have container2 avg temp') + t.is(result.log[0].siteMaxC, 72, 'should have site max temp') + t.ok(result.summary.peakTemp !== null, 'should have peak temp') + t.pass() +}) + +test('getTemperature - missing start/end throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getTemperature(mockCtx, { query: { end: 1700100000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getTemperature - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getTemperature(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getTemperature - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getTemperature(mockCtx, { + query: { start: 1700000000000, end: 1700100000000 } + }) + + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.is(result.summary.avgMaxTemp, null, 'avg max should be null') + t.is(result.summary.avgAvgTemp, null, 'avg avg should be null') + t.is(result.summary.peakTemp, null, 'peak should be null') + t.pass() +}) + +test('processTemperatureData - extracts per-container temps', (t) => { + const results = [[{ + ts: 1700006400000, + temperature_c_group_max_aggr: { cont1: 65, cont2: 72 }, + temperature_c_group_avg_aggr: { cont1: 55, cont2: 60 } + }]] + + const points = processTemperatureData(results, '1D', null) + const key = Object.keys(points)[0] + t.is(points[key].containers.cont1.maxC, 65, 'should have cont1 max') + t.is(points[key].containers.cont2.maxC, 72, 'should have cont2 max') + t.is(points[key].containers.cont1.avgC, 55, 'should have cont1 avg') + t.is(points[key].containers.cont2.avgC, 60, 'should have cont2 avg') + t.pass() +}) + +test('processTemperatureData - calculates site-wide aggregates', (t) => { + const results = [[{ + ts: 1700006400000, + temperature_c_group_max_aggr: { cont1: 65, cont2: 72 }, + temperature_c_group_avg_aggr: { cont1: 55, cont2: 60 } + }]] + + const points = processTemperatureData(results, '1D', null) + const key = Object.keys(points)[0] + t.is(points[key].siteMaxC, 72, 'site max should be highest container max') + t.is(points[key].siteAvgC, 57.5, 'site avg should average container avgs') + t.pass() +}) + +test('processTemperatureData - filters by container', (t) => { + const results = [[{ + ts: 1700006400000, + temperature_c_group_max_aggr: { cont1: 65, cont2: 72 }, + temperature_c_group_avg_aggr: { cont1: 55, cont2: 60 } + }]] + + const points = processTemperatureData(results, '1D', 'cont1') + const key = Object.keys(points)[0] + t.ok(points[key].containers.cont1, 'should have cont1') + t.ok(!points[key].containers.cont2, 'should not have cont2') + t.is(points[key].siteMaxC, 65, 'site max should be cont1 max') + t.pass() +}) + +test('processTemperatureData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const points = processTemperatureData(results, '1D', null) + t.is(Object.keys(points).length, 0, 'should be empty') + t.pass() +}) + +test('calculateTemperatureSummary - calculates averages and peak', (t) => { + const log = [ + { ts: 1, containers: {}, siteMaxC: 70, siteAvgC: 55 }, + { ts: 2, containers: {}, siteMaxC: 75, siteAvgC: 60 } + ] + + const summary = calculateTemperatureSummary(log) + t.is(summary.avgMaxTemp, 72.5, 'should average max temps') + t.is(summary.avgAvgTemp, 57.5, 'should average avg temps') + t.is(summary.peakTemp, 75, 'should find peak temp') + t.pass() +}) + +test('calculateTemperatureSummary - handles empty log', (t) => { + const summary = calculateTemperatureSummary([]) + t.is(summary.avgMaxTemp, null, 'should be null') + t.is(summary.avgAvgTemp, null, 'should be null') + t.is(summary.peakTemp, null, 'should be null') + t.pass() +}) + +test('getTemperature - always uses t-miner tag with container post-filter', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [] + } + } + } + + await getTemperature(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, container: 'my-container' } + }) + + t.is(capturedPayload.tag, 't-miner', 'should always use t-miner tag for RPC') + t.pass() +}) + +// ==================== Container Telemetry Tests ==================== + +test('getContainerTelemetry - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'listThings') { + return [{ id: 'miner-1', tags: ['container-bitdeer-9a'] }] + } + if (method === 'tailLog') { + return [{ + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'bitdeer-9a': { hot_temp_c_w_1_group: 35, tank1_bar_group: 1.2 } + } + }] + } + return {} + } + } + } + + const mockReq = { + params: { id: 'bitdeer-9a' }, + query: {} + } + + const result = await getContainerTelemetry(mockCtx, mockReq) + t.is(result.id, 'bitdeer-9a', 'should return container id') + t.ok(Array.isArray(result.miners), 'should return miners array') + t.is(result.miners.length, 1, 'should have one miner') + t.ok(result.telemetry, 'should return telemetry data') + t.is(result.telemetry.hot_temp_c_w_1_group, 35, 'should have sensor values') + t.pass() +}) + +test('getContainerTelemetry - missing id throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getContainerTelemetry(mockCtx, { params: {}, query: {} }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_CONTAINER_ID', 'should throw missing id error') + } + t.pass() +}) + +test('getContainerTelemetry - no sensor data returns null telemetry', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => [] } + } + + const result = await getContainerTelemetry(mockCtx, { + params: { id: 'bitdeer-9a' }, + query: {} + }) + t.is(result.telemetry, null, 'telemetry should be null when no data') + t.ok(Array.isArray(result.miners), 'miners should be array') + t.is(result.miners.length, 0, 'miners array should be empty') + t.pass() +}) + +test('processContainerMiners - extracts miners from results', (t) => { + const results = [ + [{ id: 'miner-1', tags: ['container-bitdeer-9a'] }], + [{ id: 'miner-2', tags: ['container-bitdeer-9a'] }] + ] + const miners = processContainerMiners(results) + t.is(miners.length, 2, 'should extract miners from all orks') + t.pass() +}) + +test('processContainerMiners - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const miners = processContainerMiners(results) + t.is(miners.length, 0, 'should return empty array for errors') + t.pass() +}) + +test('processContainerSensorSnapshot - extracts matching container', (t) => { + const results = [[{ + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'bitdeer-9a': { hot_temp_c_w_1_group: 35 }, + 'antspace-2b': { supply_liquid_temp_group: 40 } + } + }]] + const telemetry = processContainerSensorSnapshot(results, 'bitdeer-9a') + t.ok(telemetry, 'should find matching container') + t.is(telemetry.hot_temp_c_w_1_group, 35, 'should return correct container data') + t.pass() +}) + +test('processContainerSensorSnapshot - returns null when no match', (t) => { + const results = [[{ + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'antspace-2b': { supply_liquid_temp_group: 40 } + } + }]] + const telemetry = processContainerSensorSnapshot(results, 'bitdeer-9a') + t.is(telemetry, null, 'should return null when no matching container') + t.pass() +}) + +test('processContainerSensorSnapshot - prefix match fallback', (t) => { + const results = [[{ + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'bitdeer-9a-combo': { hot_temp_c_w_1_group: 35 } + } + }]] + const telemetry = processContainerSensorSnapshot(results, 'bitdeer-9a') + t.ok(telemetry, 'should find via prefix match') + t.is(telemetry.hot_temp_c_w_1_group, 35, 'should return correct data') + t.pass() +}) + +// ==================== Container History Tests ==================== + +test('getContainerHistory - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [{ + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'bitdeer-9a': { hot_temp_c_w_1_group: 35, tank1_bar_group: 1.2 } + } + }, { + ts: 1700006700000, + container_specific_stats_group_aggr: { + 'bitdeer-9a': { hot_temp_c_w_1_group: 36, tank1_bar_group: 1.3 } + } + }] + } + } + } + + const mockReq = { + params: { id: 'bitdeer-9a' }, + query: { start: 1700000000000, end: 1700100000000 } + } + + const result = await getContainerHistory(mockCtx, mockReq) + t.ok(result.log, 'should return log array') + t.ok(Array.isArray(result.log), 'log should be array') + t.is(result.log.length, 2, 'should have 2 entries') + t.is(result.log[0].hot_temp_c_w_1_group, 35, 'should have sensor values') + t.ok(result.log[0].ts < result.log[1].ts, 'should be sorted by ts') + t.pass() +}) + +test('getContainerHistory - missing id throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getContainerHistory(mockCtx, { params: {}, query: {} }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_CONTAINER_ID', 'should throw missing id error') + } + t.pass() +}) + +test('getContainerHistory - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getContainerHistory(mockCtx, { + params: { id: 'bitdeer-9a' }, + query: { start: 1700100000000, end: 1700000000000 } + }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getContainerHistory - uses defaults when no start/end', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [] + } + } + } + + const result = await getContainerHistory(mockCtx, { + params: { id: 'bitdeer-9a' }, + query: {} + }) + t.ok(capturedPayload.start > 0, 'should have default start') + t.ok(capturedPayload.end > capturedPayload.start, 'end should be after start') + t.is(capturedPayload.limit, 10080, 'should use default limit') + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty with no data') + t.pass() +}) + +test('getContainerHistory - empty results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => [] } + } + + const result = await getContainerHistory(mockCtx, { + params: { id: 'bitdeer-9a' }, + query: { start: 1700000000000, end: 1700100000000 } + }) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('processContainerHistoryData - filters by container id', (t) => { + const results = [[ + { + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'bitdeer-9a': { hot_temp_c_w_1_group: 35 }, + 'antspace-2b': { supply_liquid_temp_group: 40 } + } + } + ]] + const log = processContainerHistoryData(results, 'bitdeer-9a') + t.is(log.length, 1, 'should have one entry') + t.is(log[0].hot_temp_c_w_1_group, 35, 'should have correct container data') + t.ok(!log[0].supply_liquid_temp_group, 'should not include other container data') + t.pass() +}) + +test('processContainerHistoryData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const log = processContainerHistoryData(results, 'bitdeer-9a') + t.is(log.length, 0, 'should be empty for error results') + t.pass() +}) + +test('processContainerHistoryData - sorts by timestamp', (t) => { + const results = [[ + { + ts: 1700006700000, + container_specific_stats_group_aggr: { + 'bitdeer-9a': { hot_temp_c_w_1_group: 36 } + } + }, + { + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'bitdeer-9a': { hot_temp_c_w_1_group: 35 } + } + } + ]] + const log = processContainerHistoryData(results, 'bitdeer-9a') + t.ok(log[0].ts < log[1].ts, 'entries should be sorted ascending') + t.pass() +}) diff --git a/tests/unit/routes/metrics.routes.test.js b/tests/unit/routes/metrics.routes.test.js new file mode 100644 index 0000000..e275bac --- /dev/null +++ b/tests/unit/routes/metrics.routes.test.js @@ -0,0 +1,65 @@ +'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/metrics.routes.js' + +test('metrics routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'metrics') + t.pass() +}) + +test('metrics routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/metrics/hashrate'), 'should have hashrate route') + t.ok(routeUrls.includes('/auth/metrics/consumption'), 'should have consumption route') + t.ok(routeUrls.includes('/auth/metrics/efficiency'), 'should have efficiency route') + t.ok(routeUrls.includes('/auth/metrics/miner-status'), 'should have miner-status route') + t.ok(routeUrls.includes('/auth/metrics/power-mode'), 'should have power-mode route') + t.ok(routeUrls.includes('/auth/metrics/power-mode/timeline'), 'should have power-mode/timeline route') + t.ok(routeUrls.includes('/auth/metrics/temperature'), 'should have temperature route') + t.ok(routeUrls.includes('/auth/metrics/containers/:id'), 'should have container telemetry route') + t.ok(routeUrls.includes('/auth/metrics/containers/:id/history'), 'should have container history route') + + t.pass() +}) + +test('metrics 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('metrics routes - schema integration', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routesWithSchemas = routes.filter(route => route.schema) + routesWithSchemas.forEach(route => { + t.ok(route.schema, `route ${route.url} should have schema`) + if (route.schema.querystring) { + t.ok(typeof route.schema.querystring === 'object', `route ${route.url} querystring should be object`) + } + }) + + t.pass() +}) + +test('metrics routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'metrics') + t.pass() +}) + +test('metrics routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'metrics') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f64cf05..1dc31b1 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -135,6 +135,17 @@ const ENDPOINTS = { SITE_STATUS_LIVE: '/auth/site/status/live', + // Metrics endpoints + METRICS_HASHRATE: '/auth/metrics/hashrate', + METRICS_CONSUMPTION: '/auth/metrics/consumption', + METRICS_EFFICIENCY: '/auth/metrics/efficiency', + METRICS_MINER_STATUS: '/auth/metrics/miner-status', + METRICS_POWER_MODE: '/auth/metrics/power-mode', + METRICS_POWER_MODE_TIMELINE: '/auth/metrics/power-mode/timeline', + METRICS_TEMPERATURE: '/auth/metrics/temperature', + METRICS_CONTAINER_TELEMETRY: '/auth/metrics/containers/:id', + METRICS_CONTAINER_HISTORY: '/auth/metrics/containers/:id/history', + // Alerts endpoints ALERTS_SITE: '/auth/alerts/site', ALERTS_HISTORY: '/auth/alerts/history' @@ -245,12 +256,60 @@ const POWER_MODES = { SLEEP: 'sleep' } +const METRICS_TIME = { + ONE_DAY_MS: 24 * 60 * 60 * 1000, + TWO_DAYS_MS: 2 * 24 * 60 * 60 * 1000, + NINETY_DAYS_MS: 90 * 24 * 60 * 60 * 1000, + THREE_HOURS_MS: 3 * 60 * 60 * 1000, + ONE_MONTH_MS: 30 * 24 * 60 * 60 * 1000 +} + +const METRICS_DEFAULTS = { + TIMELINE_LIMIT: 10080, + CONTAINER_HISTORY_LIMIT: 10080 +} + +const MINER_CATEGORIES = { + LOW: 'low', + NORMAL: 'normal', + HIGH: 'high', + SLEEP: 'sleep', + OFFLINE: 'offline', + ERROR: 'error', + NOT_MINING: 'notMining', + MAINTENANCE: 'maintenance' +} + +const LOG_KEYS = { + STAT_3H: 'stat-3h', + STAT_5M: 'stat-5m' +} + +const WORKER_TAGS = { + MINER: 't-miner', + CONTAINER: 't-container' +} + +const DEVICE_LIST_FIELDS = { + id: 1, type: 1, code: 1, ip: 1, tags: 1, info: 1, rack: 1 +} + const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', SITE_POWER: 'site_power_w', ENERGY_AGGR: 'energy_aggr', ACTIVE_ENERGY_IN: 'active_energy_in_aggr', - UTE_ENERGY: 'ute_energy_aggr' + UTE_ENERGY: 'ute_energy_aggr', + EFFICIENCY: 'efficiency_w_ths_avg_aggr', + POWER_MODE_GROUP: 'power_mode_group_aggr', + STATUS_GROUP: 'status_group_aggr', + TEMP_MAX: 'temperature_c_group_max_aggr', + TEMP_AVG: 'temperature_c_group_avg_aggr', + TYPE_CNT: 'type_cnt', + OFFLINE_CNT: 'offline_cnt', + SLEEP_CNT: 'power_mode_sleep_cnt', + MAINTENANCE_CNT: 'maintenance_type_cnt', + CONTAINER_SPECIFIC_STATS: 'container_specific_stats_group_aggr' } const PERIOD_TYPES = { @@ -319,6 +378,11 @@ module.exports = { NON_METRIC_KEYS, BTC_SATS, RANGE_BUCKETS, + METRICS_TIME, + METRICS_DEFAULTS, + MINER_CATEGORIES, + LOG_KEYS, + WORKER_TAGS, SEVERITY_LEVELS, ALERTS_DEFAULT_LIMIT, ALERTS_MAX_SITE_LIMIT, @@ -326,5 +390,6 @@ module.exports = { SITE_ALERTS_FILTER_FIELDS, SITE_ALERTS_SEARCH_FIELDS, HISTORY_FILTER_FIELDS, - HISTORY_SEARCH_FIELDS + HISTORY_SEARCH_FIELDS, + DEVICE_LIST_FIELDS } diff --git a/workers/lib/metrics.utils.js b/workers/lib/metrics.utils.js new file mode 100644 index 0000000..92f334b --- /dev/null +++ b/workers/lib/metrics.utils.js @@ -0,0 +1,112 @@ +'use strict' + +const { getStartOfDay } = require('./period.utils') +const { METRICS_TIME, LOG_KEYS } = require('./constants') + +/** + * Parse timestamp from RPC entry. + * With groupRange, ts may be a range string like "1770854400000-1771459199999". + * Extracts the start of the range in that case. + */ +function parseEntryTs (ts) { + if (typeof ts === 'number') return ts + if (typeof ts === 'string') { + const dashIdx = ts.indexOf('-') + if (dashIdx > 0) return Number(ts.slice(0, dashIdx)) + return Number(ts) + } + return null +} + +function validateStartEnd (req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + return { start, end } +} + +function * iterateRpcEntries (results) { + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry || entry.error) continue + yield entry + } + } +} + +function forEachRangeAggrItem (entry, callback) { + if (!entry) return + const items = entry.data || entry.items || entry + if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(parseEntryTs(item.ts || item.timestamp)) + if (!ts) continue + callback(ts, item.val || item) + } + } else if (typeof items === 'object') { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(parseEntryTs(Number(key))) + if (!ts) continue + callback(ts, val) + } + } +} + +function sumObjectValues (obj) { + if (!obj || typeof obj !== 'object') return 0 + return Object.values(obj).reduce((sum, val) => sum + (Number(val) || 0), 0) +} + +/** + * Extract container name from a device key. + * Strips the last dash-separated segment (assumed to be position/index). + * e.g. "bitdeer-9a-miner1" -> "bitdeer-9a" + * NOTE: This is a heuristic based on naming convention in power_mode_group_aggr data. + * Device keys are identifiers from aggregated data, not auto-generated IDs. + */ +function extractContainerFromMinerKey (deviceKey) { + const lastDash = deviceKey.lastIndexOf('-') + return lastDash > 0 ? deviceKey.slice(0, lastDash) : deviceKey +} + +function resolveInterval (start, end, requested) { + if (requested) return requested + const range = end - start + if (range <= METRICS_TIME.TWO_DAYS_MS) return '1h' + if (range <= METRICS_TIME.NINETY_DAYS_MS) return '1d' + return '1w' +} + +function getIntervalConfig (interval) { + switch (interval) { + case '1h': + return { key: LOG_KEYS.STAT_3H, groupRange: null } + case '1w': + return { key: LOG_KEYS.STAT_3H, groupRange: '1W' } + case '1d': + default: + return { key: LOG_KEYS.STAT_3H, groupRange: '1D' } + } +} + +module.exports = { + parseEntryTs, + validateStartEnd, + iterateRpcEntries, + forEachRangeAggrItem, + sumObjectValues, + extractContainerFromMinerKey, + resolveInterval, + getIntervalConfig +} diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js new file mode 100644 index 0000000..42a4be9 --- /dev/null +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -0,0 +1,724 @@ +'use strict' + +const { + WORKER_TYPES, + AGGR_FIELDS, + RPC_METHODS, + METRICS_TIME, + METRICS_DEFAULTS, + MINER_CATEGORIES, + LOG_KEYS, + WORKER_TAGS, + DEVICE_LIST_FIELDS +} = require('../../constants') +const { + requestRpcEachLimit, + requestRpcMapAllPages, + getStartOfDay, + safeDiv +} = require('../../utils') +const { + parseEntryTs, + validateStartEnd, + iterateRpcEntries, + forEachRangeAggrItem, + sumObjectValues, + extractContainerFromMinerKey, + resolveInterval, + getIntervalConfig +} = require('../../metrics.utils') + +async function getHashrate (ctx, req) { + const { start, end } = validateStartEnd(req) + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [{ + type: WORKER_TYPES.MINER, + startDate, + endDate, + fields: { [AGGR_FIELDS.HASHRATE_SUM]: 1 }, + shouldReturnDailyData: 1 + }] + }) + + const daily = processHashrateData(results) + const log = Object.keys(daily).sort().map(dayTs => ({ + ts: Number(dayTs), + hashrateMhs: daily[dayTs] + })) + + const summary = calculateHashrateSummary(log) + + return { log, summary } +} + +function processHashrateData (results) { + const daily = {} + for (const entry of iterateRpcEntries(results)) { + forEachRangeAggrItem(entry, (ts, val) => { + const v = typeof val === 'object' ? (val[AGGR_FIELDS.HASHRATE_SUM] || 0) : (Number(val) || 0) + daily[ts] = (daily[ts] || 0) + v + }) + } + return daily +} + +function calculateHashrateSummary (log) { + if (!log.length) { + return { + avgHashrateMhs: null, + totalHashrateMhs: 0 + } + } + + const total = log.reduce((sum, entry) => sum + (entry.hashrateMhs || 0), 0) + + return { + avgHashrateMhs: safeDiv(total, log.length), + totalHashrateMhs: total + } +} + +async function getConsumption (ctx, req) { + const { start, end } = validateStartEnd(req) + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [{ + type: WORKER_TYPES.POWERMETER, + startDate, + endDate, + fields: { [AGGR_FIELDS.SITE_POWER]: 1 }, + shouldReturnDailyData: 1 + }] + }) + + const daily = processConsumptionData(results) + const log = Object.keys(daily).sort().map(dayTs => { + const powerW = daily[dayTs] + return { + ts: Number(dayTs), + powerW, + // powerW is avg watts for the day; W * 24h / 1,000,000 converts to daily MWh + consumptionMWh: (powerW * 24) / 1000000 + } + }) + + const summary = calculateConsumptionSummary(log) + + return { log, summary } +} + +function processConsumptionData (results) { + const daily = {} + for (const entry of iterateRpcEntries(results)) { + forEachRangeAggrItem(entry, (ts, val) => { + const v = typeof val === 'object' ? (val[AGGR_FIELDS.SITE_POWER] || 0) : (Number(val) || 0) + daily[ts] = (daily[ts] || 0) + v + }) + } + return daily +} + +function calculateConsumptionSummary (log) { + if (!log.length) { + return { + avgPowerW: null, + totalConsumptionMWh: 0 + } + } + + const totalPower = log.reduce((sum, entry) => sum + (entry.powerW || 0), 0) + const totalConsumption = log.reduce((sum, entry) => sum + (entry.consumptionMWh || 0), 0) + + return { + avgPowerW: safeDiv(totalPower, log.length), + totalConsumptionMWh: totalConsumption + } +} + +async function getEfficiency (ctx, req) { + const { start, end } = validateStartEnd(req) + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [{ + type: WORKER_TYPES.MINER, + startDate, + endDate, + fields: { [AGGR_FIELDS.EFFICIENCY]: 1 }, + shouldReturnDailyData: 1 + }] + }) + + const daily = processEfficiencyData(results) + const log = Object.keys(daily).sort().map(dayTs => ({ + ts: Number(dayTs), + efficiencyWThs: daily[dayTs].total / daily[dayTs].count + })) + + const summary = calculateEfficiencySummary(log) + + return { log, summary } +} + +function processEfficiencyData (results) { + const daily = {} + for (const entry of iterateRpcEntries(results)) { + forEachRangeAggrItem(entry, (ts, val) => { + const eff = typeof val === 'object' ? (val[AGGR_FIELDS.EFFICIENCY] || 0) : (Number(val) || 0) + if (!eff) return + if (!daily[ts]) daily[ts] = { total: 0, count: 0 } + daily[ts].total += eff + daily[ts].count += 1 + }) + } + return daily +} + +function calculateEfficiencySummary (log) { + if (!log.length) { + return { + avgEfficiencyWThs: null + } + } + + const total = log.reduce((sum, entry) => sum + (entry.efficiencyWThs || 0), 0) + + return { + avgEfficiencyWThs: safeDiv(total, log.length) + } +} + +async function getMinerStatus (ctx, req) { + const { start, end } = validateStartEnd(req) + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, { + key: LOG_KEYS.STAT_3H, + type: WORKER_TYPES.MINER, + tag: WORKER_TAGS.MINER, + aggrFields: { + [AGGR_FIELDS.TYPE_CNT]: 1, + [AGGR_FIELDS.OFFLINE_CNT]: 1, + [AGGR_FIELDS.SLEEP_CNT]: 1, + [AGGR_FIELDS.MAINTENANCE_CNT]: 1 + }, + groupRange: '1D', + shouldCalculateAvg: true, + start, + end + }) + + const daily = processMinerStatusData(results) + const log = Object.keys(daily).sort().map(dayTs => ({ + ts: Number(dayTs), + ...daily[dayTs] + })) + + const summary = calculateMinerStatusSummary(log) + + return { log, summary } +} + +function processMinerStatusData (results) { + const daily = {} + for (const entry of iterateRpcEntries(results)) { + const rawTs = parseEntryTs(entry.ts || entry.timestamp) + const ts = rawTs ? getStartOfDay(rawTs) : null + if (!ts) continue + if (!daily[ts]) { + daily[ts] = { online: 0, offline: 0, sleep: 0, maintenance: 0 } + } + + const offlineCnt = sumObjectValues(entry[AGGR_FIELDS.OFFLINE_CNT] || entry.aggrFields?.[AGGR_FIELDS.OFFLINE_CNT]) + const sleepCnt = sumObjectValues(entry[AGGR_FIELDS.SLEEP_CNT] || entry.aggrFields?.[AGGR_FIELDS.SLEEP_CNT]) + const maintenanceCnt = sumObjectValues(entry[AGGR_FIELDS.MAINTENANCE_CNT] || entry.aggrFields?.[AGGR_FIELDS.MAINTENANCE_CNT]) + + daily[ts].offline += offlineCnt + daily[ts].sleep += sleepCnt + daily[ts].maintenance += maintenanceCnt + + const totalCount = sumObjectValues(entry[AGGR_FIELDS.TYPE_CNT]) || entry.total_cnt || entry.count || 0 + if (totalCount > 0) { + daily[ts].online += Math.max(0, totalCount - offlineCnt - sleepCnt - maintenanceCnt) + } + } + return daily +} + +function calculateMinerStatusSummary (log) { + if (!log.length) { + return { + avgOnline: null, + avgOffline: null, + avgSleep: null, + avgMaintenance: null + } + } + + const totals = log.reduce((acc, entry) => { + acc.online += entry.online || 0 + acc.offline += entry.offline || 0 + acc.sleep += entry.sleep || 0 + acc.maintenance += entry.maintenance || 0 + return acc + }, { online: 0, offline: 0, sleep: 0, maintenance: 0 }) + + return { + avgOnline: safeDiv(totals.online, log.length), + avgOffline: safeDiv(totals.offline, log.length), + avgSleep: safeDiv(totals.sleep, log.length), + avgMaintenance: safeDiv(totals.maintenance, log.length) + } +} + +async function getPowerMode (ctx, req) { + const { start, end } = validateStartEnd(req) + + const interval = resolveInterval(start, end, req.query.interval) + const config = getIntervalConfig(interval) + + const rpcPayload = { + key: config.key, + type: WORKER_TYPES.MINER, + tag: WORKER_TAGS.MINER, + aggrFields: { + [AGGR_FIELDS.POWER_MODE_GROUP]: 1, + [AGGR_FIELDS.STATUS_GROUP]: 1 + }, + start, + end + } + + if (config.groupRange) { + rpcPayload.groupRange = config.groupRange + } + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, rpcPayload) + + const timePoints = processPowerModeData(results, config.groupRange) + const log = Object.keys(timePoints).sort().map(ts => ({ + ts: Number(ts), + ...timePoints[ts] + })) + + const summary = calculatePowerModeSummary(log) + + return { log, summary } +} + +function categorizeMiner (powerMode, status) { + if (status === 'offline') return MINER_CATEGORIES.OFFLINE + if (status === 'error') return MINER_CATEGORIES.ERROR + if (status === 'maintenance') return MINER_CATEGORIES.MAINTENANCE + if (status === 'idle' || status === 'stopped') return MINER_CATEGORIES.NOT_MINING + if (powerMode === 'low') return MINER_CATEGORIES.LOW + if (powerMode === 'high') return MINER_CATEGORIES.HIGH + if (powerMode === 'sleep') return MINER_CATEGORIES.SLEEP + return powerMode || MINER_CATEGORIES.NORMAL +} + +function processPowerModeData (results, groupRange) { + const timePoints = {} + const emptyPoint = () => ({ low: 0, normal: 0, high: 0, sleep: 0, offline: 0, notMining: 0, maintenance: 0, error: 0 }) + + for (const entry of iterateRpcEntries(results)) { + const rawTs = parseEntryTs(entry.ts || entry.timestamp) + const ts = groupRange && rawTs ? getStartOfDay(rawTs) : rawTs + if (!ts) continue + + if (!timePoints[ts]) timePoints[ts] = emptyPoint() + + const powerModeObj = entry[AGGR_FIELDS.POWER_MODE_GROUP] || entry.aggrFields?.[AGGR_FIELDS.POWER_MODE_GROUP] || {} + const statusObj = entry[AGGR_FIELDS.STATUS_GROUP] || entry.aggrFields?.[AGGR_FIELDS.STATUS_GROUP] || {} + + if (typeof powerModeObj === 'object' && powerModeObj !== null) { + for (const [minerId, mode] of Object.entries(powerModeObj)) { + const minerStatus = statusObj[minerId] || '' + const category = categorizeMiner(mode, minerStatus) + timePoints[ts][category] = (timePoints[ts][category] || 0) + 1 + } + } + } + return timePoints +} + +function calculatePowerModeSummary (log) { + const categories = ['low', 'normal', 'high', 'sleep', 'offline', 'notMining', 'maintenance', 'error'] + if (!log.length) { + const summary = {} + for (const cat of categories) { + summary['avg' + cat.charAt(0).toUpperCase() + cat.slice(1)] = null + } + return summary + } + + const totals = {} + for (const cat of categories) totals[cat] = 0 + for (const entry of log) { + for (const cat of categories) { + totals[cat] += entry[cat] || 0 + } + } + + const summary = {} + for (const cat of categories) { + summary['avg' + cat.charAt(0).toUpperCase() + cat.slice(1)] = safeDiv(totals[cat], log.length) + } + return summary +} + +async function getPowerModeTimeline (ctx, req) { + const now = Date.now() + const start = Number(req.query.start) || (now - METRICS_TIME.ONE_MONTH_MS) + const end = Number(req.query.end) || now + const container = req.query.container || null + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const rpcPayload = { + key: LOG_KEYS.STAT_3H, + type: WORKER_TYPES.MINER, + tag: WORKER_TAGS.MINER, + aggrFields: { + [AGGR_FIELDS.POWER_MODE_GROUP]: 1, + [AGGR_FIELDS.STATUS_GROUP]: 1 + }, + start, + end + } + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, rpcPayload) + + const log = processPowerModeTimelineData(results, container) + + return { log } +} + +function processPowerModeTimelineData (results, containerFilter) { + const minerTimelines = {} + + for (const entry of iterateRpcEntries(results)) { + const ts = parseEntryTs(entry.ts || entry.timestamp) + if (!ts) continue + + const powerModeObj = entry[AGGR_FIELDS.POWER_MODE_GROUP] || entry.aggrFields?.[AGGR_FIELDS.POWER_MODE_GROUP] || {} + const statusObj = entry[AGGR_FIELDS.STATUS_GROUP] || entry.aggrFields?.[AGGR_FIELDS.STATUS_GROUP] || {} + + if (typeof powerModeObj === 'object' && powerModeObj !== null) { + for (const [minerId, powerMode] of Object.entries(powerModeObj)) { + if (!minerTimelines[minerId]) minerTimelines[minerId] = [] + minerTimelines[minerId].push({ + ts, + powerMode: powerMode || 'unknown', + status: statusObj[minerId] || 'unknown' + }) + } + } + } + + const log = [] + for (const [minerId, entries] of Object.entries(minerTimelines)) { + entries.sort((a, b) => a.ts - b.ts) + + const container = extractContainerFromMinerKey(minerId) + + if (containerFilter && container !== containerFilter) continue + + const segments = [] + let current = null + + for (const entry of entries) { + if (!current || current.powerMode !== entry.powerMode || current.status !== entry.status) { + if (current) { + current.to = entry.ts + segments.push(current) + } + current = { from: entry.ts, to: entry.ts, powerMode: entry.powerMode, status: entry.status } + } else { + current.to = entry.ts + } + } + if (current) segments.push(current) + + log.push({ minerId, container, segments }) + } + + return log +} + +async function getTemperature (ctx, req) { + const { start, end } = validateStartEnd(req) + + const interval = resolveInterval(start, end, req.query.interval) + const config = getIntervalConfig(interval) + const container = req.query.container || null + + const rpcPayload = { + key: config.key, + type: WORKER_TYPES.MINER, + tag: WORKER_TAGS.MINER, + aggrFields: { + [AGGR_FIELDS.TEMP_MAX]: 1, + [AGGR_FIELDS.TEMP_AVG]: 1 + }, + shouldCalculateAvg: true, + start, + end + } + + if (config.groupRange) { + rpcPayload.groupRange = config.groupRange + } + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, rpcPayload) + + const timePoints = processTemperatureData(results, config.groupRange, container) + const log = Object.keys(timePoints).sort().map(ts => ({ + ts: Number(ts), + ...timePoints[ts] + })) + + const summary = calculateTemperatureSummary(log) + + return { log, summary } +} + +function processTemperatureData (results, groupRange, containerFilter) { + const timePoints = {} + const avgCounts = {} + + for (const entry of iterateRpcEntries(results)) { + const rawTs = parseEntryTs(entry.ts || entry.timestamp) + const ts = groupRange && rawTs ? getStartOfDay(rawTs) : rawTs + if (!ts) continue + + const maxObj = entry[AGGR_FIELDS.TEMP_MAX] || entry.aggrFields?.[AGGR_FIELDS.TEMP_MAX] || {} + const avgObj = entry[AGGR_FIELDS.TEMP_AVG] || entry.aggrFields?.[AGGR_FIELDS.TEMP_AVG] || {} + + if (!timePoints[ts]) { + timePoints[ts] = { containers: {}, siteMaxC: null, siteAvgC: null } + avgCounts[ts] = {} + } + + const point = timePoints[ts] + + if (typeof maxObj === 'object' && maxObj !== null) { + for (const [name, maxVal] of Object.entries(maxObj)) { + if (containerFilter && name !== containerFilter) continue + const numMax = Number(maxVal) || 0 + const numAvg = Number(avgObj[name]) || 0 + + if (!point.containers[name]) { + point.containers[name] = { maxC: numMax, avgC: numAvg } + avgCounts[ts][name] = 1 + } else { + point.containers[name].maxC = Math.max(point.containers[name].maxC, numMax) + const count = avgCounts[ts][name] + point.containers[name].avgC = (point.containers[name].avgC * count + numAvg) / (count + 1) + avgCounts[ts][name] = count + 1 + } + } + } + + const containerVals = Object.values(point.containers) + if (containerVals.length) { + point.siteMaxC = Math.max(...containerVals.map(c => c.maxC)) + const avgSum = containerVals.reduce((sum, c) => sum + c.avgC, 0) + point.siteAvgC = safeDiv(avgSum, containerVals.length) + } + } + return timePoints +} + +function calculateTemperatureSummary (log) { + if (!log.length) { + return { + avgMaxTemp: null, + avgAvgTemp: null, + peakTemp: null + } + } + + const maxTemps = log.filter(e => e.siteMaxC !== null).map(e => e.siteMaxC) + const avgTemps = log.filter(e => e.siteAvgC !== null).map(e => e.siteAvgC) + + return { + avgMaxTemp: maxTemps.length ? safeDiv(maxTemps.reduce((a, b) => a + b, 0), maxTemps.length) : null, + avgAvgTemp: avgTemps.length ? safeDiv(avgTemps.reduce((a, b) => a + b, 0), avgTemps.length) : null, + peakTemp: maxTemps.length ? Math.max(...maxTemps) : null + } +} + +async function getContainerTelemetry (ctx, req) { + const containerId = req.params.id + + if (!containerId) { + throw new Error('ERR_MISSING_CONTAINER_ID') + } + + const containerTag = `container-${containerId}` + + const [minersResults, sensorResults] = await Promise.all([ + requestRpcMapAllPages(ctx, RPC_METHODS.LIST_THINGS, { + query: { tags: { $in: [containerTag] } }, + fields: DEVICE_LIST_FIELDS + }), + requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, { + key: LOG_KEYS.STAT_5M, + type: WORKER_TYPES.CONTAINER, + tag: WORKER_TAGS.CONTAINER, + aggrFields: { + [AGGR_FIELDS.CONTAINER_SPECIFIC_STATS]: 1 + }, + limit: 1 + }) + ]) + + const miners = processContainerMiners(minersResults) + const telemetry = processContainerSensorSnapshot(sensorResults, containerId) + + return { + id: containerId, + miners, + telemetry + } +} + +function processContainerMiners (results) { + const miners = [] + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const thing of data) { + if (!thing || thing.error) continue + miners.push(thing) + } + } + return miners +} + +function processContainerSensorSnapshot (results, containerId) { + for (const entry of iterateRpcEntries(results)) { + const aggrData = entry[AGGR_FIELDS.CONTAINER_SPECIFIC_STATS] || + entry.aggrFields?.[AGGR_FIELDS.CONTAINER_SPECIFIC_STATS] || {} + + if (typeof aggrData !== 'object' || aggrData === null) continue + + if (aggrData[containerId]) { + return aggrData[containerId] + } + + for (const [key, val] of Object.entries(aggrData)) { + if (key.startsWith(containerId)) { + return val + } + } + } + return null +} + +async function getContainerHistory (ctx, req) { + const containerId = req.params.id + + if (!containerId) { + throw new Error('ERR_MISSING_CONTAINER_ID') + } + + const now = Date.now() + const start = Number(req.query.start) || (now - METRICS_TIME.ONE_DAY_MS) + const end = Number(req.query.end) || now + const limit = Number(req.query.limit) || METRICS_DEFAULTS.CONTAINER_HISTORY_LIMIT + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, { + key: LOG_KEYS.STAT_5M, + type: WORKER_TYPES.CONTAINER, + tag: WORKER_TAGS.CONTAINER, + aggrFields: { + [AGGR_FIELDS.CONTAINER_SPECIFIC_STATS]: 1 + }, + start, + end, + limit + }) + + const log = processContainerHistoryData(results, containerId) + + return { log } +} + +function processContainerHistoryData (results, containerId) { + const log = [] + + for (const entry of iterateRpcEntries(results)) { + const ts = parseEntryTs(entry.ts || entry.timestamp) + if (!ts) continue + + const aggrData = entry[AGGR_FIELDS.CONTAINER_SPECIFIC_STATS] || + entry.aggrFields?.[AGGR_FIELDS.CONTAINER_SPECIFIC_STATS] || {} + + if (typeof aggrData !== 'object' || aggrData === null) continue + + let containerData = aggrData[containerId] || null + + if (!containerData) { + for (const [key, val] of Object.entries(aggrData)) { + if (key.startsWith(containerId)) { + containerData = val + break + } + } + } + + if (containerData) { + log.push({ ts, ...containerData }) + } + } + + log.sort((a, b) => a.ts - b.ts) + return log +} + +module.exports = { + ...require('../../metrics.utils'), + getHashrate, + processHashrateData, + calculateHashrateSummary, + getConsumption, + processConsumptionData, + calculateConsumptionSummary, + getEfficiency, + processEfficiencyData, + calculateEfficiencySummary, + getMinerStatus, + processMinerStatusData, + calculateMinerStatusSummary, + getPowerMode, + processPowerModeData, + calculatePowerModeSummary, + categorizeMiner, + getPowerModeTimeline, + processPowerModeTimelineData, + getTemperature, + processTemperatureData, + calculateTemperatureSummary, + getContainerTelemetry, + processContainerMiners, + processContainerSensorSnapshot, + getContainerHistory, + processContainerHistoryData +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index c6a7409..462d8f3 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -12,6 +12,7 @@ const financeRoutes = require('./routes/finance.routes') const poolsRoutes = require('./routes/pools.routes') const poolManagerRoutes = require('./routes/poolManager.routes') const siteRoutes = require('./routes/site.routes') +const metricsRoutes = require('./routes/metrics.routes') const alertsRoutes = require('./routes/alerts.routes') /** @@ -32,6 +33,7 @@ function routes (ctx) { ...poolsRoutes(ctx), ...poolManagerRoutes(ctx), ...siteRoutes(ctx), + ...metricsRoutes(ctx), ...alertsRoutes(ctx) ] } diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js new file mode 100644 index 0000000..f2f27d4 --- /dev/null +++ b/workers/lib/server/routes/metrics.routes.js @@ -0,0 +1,184 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getHashrate, + getConsumption, + getEfficiency, + getMinerStatus, + getPowerMode, + getPowerModeTimeline, + getTemperature, + getContainerTelemetry, + getContainerHistory +} = require('../handlers/metrics.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/metrics.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_HASHRATE, + schema: { + querystring: schemas.query.hashrate + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/hashrate', + req.query.start, + req.query.end + ], + ENDPOINTS.METRICS_HASHRATE, + getHashrate + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_CONSUMPTION, + schema: { + querystring: schemas.query.consumption + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/consumption', + req.query.start, + req.query.end + ], + ENDPOINTS.METRICS_CONSUMPTION, + getConsumption + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_EFFICIENCY, + schema: { + querystring: schemas.query.efficiency + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/efficiency', + req.query.start, + req.query.end + ], + ENDPOINTS.METRICS_EFFICIENCY, + getEfficiency + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_MINER_STATUS, + schema: { + querystring: schemas.query.minerStatus + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/miner-status', + req.query.start, + req.query.end + ], + ENDPOINTS.METRICS_MINER_STATUS, + getMinerStatus + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_POWER_MODE, + schema: { + querystring: schemas.query.powerMode + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/power-mode', + req.query.start, + req.query.end, + req.query.interval + ], + ENDPOINTS.METRICS_POWER_MODE, + getPowerMode + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_POWER_MODE_TIMELINE, + schema: { + querystring: schemas.query.powerModeTimeline + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/power-mode/timeline', + req.query.start, + req.query.end, + req.query.container, + req.query.limit + ], + ENDPOINTS.METRICS_POWER_MODE_TIMELINE, + getPowerModeTimeline + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_TEMPERATURE, + schema: { + querystring: schemas.query.temperature + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/temperature', + req.query.start, + req.query.end, + req.query.interval, + req.query.container + ], + ENDPOINTS.METRICS_TEMPERATURE, + getTemperature + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_CONTAINER_HISTORY, + schema: { + querystring: schemas.query.containerHistory + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/containers/history', + req.params.id, + req.query.start, + req.query.end, + req.query.limit + ], + ENDPOINTS.METRICS_CONTAINER_HISTORY, + getContainerHistory + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_CONTAINER_TELEMETRY, + schema: { + querystring: schemas.query.containerTelemetry + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/containers/telemetry', + req.params.id + ], + ENDPOINTS.METRICS_CONTAINER_TELEMETRY, + getContainerTelemetry + ) + } + ] +} diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js new file mode 100644 index 0000000..2a149f8 --- /dev/null +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -0,0 +1,89 @@ +'use strict' + +const schemas = { + query: { + hashrate: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + consumption: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + efficiency: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + minerStatus: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + powerMode: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + interval: { type: 'string', enum: ['1h', '1d', '1w'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + powerModeTimeline: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + container: { type: 'string' }, + overwriteCache: { type: 'boolean' } + } + }, + temperature: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + interval: { type: 'string', enum: ['1h', '1d', '1w'] }, + container: { type: 'string' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + containerTelemetry: { + type: 'object', + properties: { + overwriteCache: { type: 'boolean' } + } + }, + containerHistory: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + } + } + } +} + +module.exports = schemas From c454b8ae5ac9dc541417b916ce45b1e51ad05c83 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 5 Mar 2026 13:23:23 +0300 Subject: [PATCH 08/63] feat: add finance revenue and revenue-summary endpoints (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add GET /auth/finance/revenue endpoint Port pool transaction revenue data from legacy dashboard to API v2. Separates revenue from pool fees, supporting both F2Pool (changed_balance + tx_fee) and Ocean (satoshis_net_earned + fees_colected_satoshis) formats. Aggregates by period with optional pool filter. * feat: add GET /auth/finance/revenue-summary endpoint Most impactful endpoint — replaces 9 API calls + ~1500 LOC of frontend processing. Merges transaction data, BTC prices, costs, power, hashrate, block data, and electricity data into comprehensive daily rows with EBITDA, production cost, hash revenue, curtailment, and power utilization metrics. * fix: include pool name in worker type for revenue endpoint * refactor: extract shared finance utils and consolidate duplicated logic - Create finance.utils.js with shared utilities: - validateStartEnd: replaces duplicated validation in every handler - normalizeTimestampMs: moved from finance.handlers.js - processTransactions: consolidates processTransactionData and processEbitdaTransactions (fixes missing normalizeTimestampMs and incorrect BTC_SATS division bugs), supports trackFees option - extractCurrentPrice: merges flat and nested EBITDA price formats - processBlockData: extracted for reuse across revenue endpoints - Refactor finance.handlers.js to use shared utils - Add comprehensive unit tests for all utils (24 tests) - Update handler tests to remove tests for deleted functions * Remove spec doc --- tests/unit/handlers/finance.handlers.test.js | 409 ++++++++++----- tests/unit/handlers/finance.utils.test.js | 251 +++++++++ tests/unit/routes/finance.routes.test.js | 2 + workers/lib/constants.js | 2 + .../lib/server/handlers/finance.handlers.js | 496 ++++++++++++------ workers/lib/server/handlers/finance.utils.js | 130 +++++ workers/lib/server/routes/finance.routes.js | 41 +- workers/lib/server/schemas/finance.schemas.js | 21 + 8 files changed, 1057 insertions(+), 295 deletions(-) create mode 100644 tests/unit/handlers/finance.utils.test.js create mode 100644 workers/lib/server/handlers/finance.utils.js diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 409b1e2..71549c5 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -4,22 +4,21 @@ const test = require('brittle') const { getEnergyBalance, processConsumptionData, - processTransactionData, processPriceData, - extractCurrentPrice, processCostsData, calculateSummary, getEbitda, processTailLogData, - processEbitdaTransactions, processEbitdaPrices, - extractEbitdaCurrentPrice, calculateEbitdaSummary, getCostSummary, calculateCostSummary, getSubsidyFees, - processBlockData, - calculateSubsidyFeesSummary + calculateSubsidyFeesSummary, + getRevenue, + calculateRevenueSummary, + getRevenueSummary, + calculateDetailedRevenueSummary } = require('../../../workers/lib/server/handlers/finance.handlers') // ==================== Energy Balance Tests ==================== @@ -184,40 +183,6 @@ test('processConsumptionData - handles error results', (t) => { t.pass() }) -test('processTransactionData - processes F2Pool data', (t) => { - const results = [ - [{ ts: 1700006400000, transactions: [{ created_at: 1700006400, changed_balance: 0.001 }] }] - ] - - const daily = processTransactionData(results) - t.ok(typeof daily === 'object', 'should return object') - t.ok(Object.keys(daily).length > 0, 'should have entries') - const key = Object.keys(daily)[0] - t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly as BTC') - t.pass() -}) - -test('processTransactionData - processes Ocean data', (t) => { - const results = [ - [{ ts: 1700006400000, transactions: [{ ts: 1700006400, satoshis_net_earned: 50000000 }] }] - ] - - const daily = processTransactionData(results) - t.ok(typeof daily === 'object', 'should return object') - t.ok(Object.keys(daily).length > 0, 'should have entries') - const key = Object.keys(daily)[0] - t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') - t.pass() -}) - -test('processTransactionData - handles error results', (t) => { - const results = [{ error: 'timeout' }] - const daily = processTransactionData(results) - t.ok(typeof daily === 'object', 'should return object') - t.is(Object.keys(daily).length, 0, 'should be empty for error results') - t.pass() -}) - test('processPriceData - processes mempool price data', (t) => { const results = [ [{ ts: 1700006400000, priceUSD: 40000 }] @@ -231,31 +196,6 @@ test('processPriceData - processes mempool price data', (t) => { t.pass() }) -test('extractCurrentPrice - extracts currentPrice from mempool data', (t) => { - const results = [ - [{ currentPrice: 42000, blockHeight: 900000 }] - ] - const price = extractCurrentPrice(results) - t.is(price, 42000, 'should extract currentPrice') - t.pass() -}) - -test('extractCurrentPrice - extracts priceUSD', (t) => { - const results = [ - [{ ts: 1700006400000, priceUSD: 42000 }] - ] - const price = extractCurrentPrice(results) - t.is(price, 42000, 'should extract priceUSD') - t.pass() -}) - -test('extractCurrentPrice - handles error results', (t) => { - const results = [{ error: 'timeout' }] - const price = extractCurrentPrice(results) - t.is(price, 0, 'should return 0 for error results') - t.pass() -}) - test('processCostsData - processes dashboard format (energyCostsUSD)', (t) => { const costs = [ { region: 'site1', year: 2023, month: 11, energyCostsUSD: 30000, operationalCostsUSD: 6000 } @@ -417,15 +357,6 @@ test('processTailLogData - handles error results', (t) => { t.pass() }) -test('processEbitdaTransactions - processes valid data', (t) => { - const results = [ - [{ transactions: [{ ts: 1700006400000, changed_balance: 100000000 }] }] - ] - const daily = processEbitdaTransactions(results) - t.ok(typeof daily === 'object', 'should return object') - t.pass() -}) - test('processEbitdaPrices - processes valid data', (t) => { const results = [ [{ prices: [{ ts: 1700006400000, price: 40000 }] }] @@ -435,18 +366,6 @@ test('processEbitdaPrices - processes valid data', (t) => { t.pass() }) -test('extractEbitdaCurrentPrice - extracts numeric price', (t) => { - const results = [{ data: 42000 }] - t.is(extractEbitdaCurrentPrice(results), 42000, 'should extract numeric price') - t.pass() -}) - -test('extractEbitdaCurrentPrice - extracts object price', (t) => { - const results = [{ data: { USD: 42000 } }] - t.is(extractEbitdaCurrentPrice(results), 42000, 'should extract USD') - t.pass() -}) - test('calculateEbitdaSummary - calculates from log entries', (t) => { const log = [ { revenueBTC: 0.5, revenueUSD: 20000, totalCostsUSD: 5000, ebitdaSelling: 15000, ebitdaHodl: 15000 }, @@ -643,69 +562,303 @@ test('getSubsidyFees - empty ork results', async (t) => { t.pass() }) -test('processBlockData - processes valid block data', (t) => { - const results = [ - [{ data: [{ ts: 1700006400000, blockReward: 6.25, blockTotalFees: 0.5 }] }] +test('calculateSubsidyFeesSummary - calculates from log entries', (t) => { + const log = [ + { blockReward: 6.25, blockTotalFees: 0.5 }, + { blockReward: 6.25, blockTotalFees: 0.3 } ] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.ok(Object.keys(daily).length > 0, 'should have entries') - const key = Object.keys(daily)[0] - t.is(daily[key].blockReward, 6.25, 'should extract blockReward') - t.is(daily[key].blockTotalFees, 0.5, 'should extract blockTotalFees') + const summary = calculateSubsidyFeesSummary(log) + t.is(summary.totalBlockReward, 12.5, 'should sum block rewards') + t.is(summary.totalBlockTotalFees, 0.8, 'should sum block fees') + t.ok(summary.avgBlockReward !== null, 'should calculate avg block reward') + t.is(summary.avgBlockReward, 6.25, 'should calculate correct avg block reward') + t.ok(summary.avgBlockTotalFees !== null, 'should calculate avg block fees') t.pass() }) -test('processBlockData - processes object-keyed data', (t) => { - const results = [ - [{ data: { 1700006400000: { blockReward: 6.25, blockTotalFees: 0.5 } } }] +test('calculateSubsidyFeesSummary - handles empty log', (t) => { + const summary = calculateSubsidyFeesSummary([]) + t.is(summary.totalBlockReward, 0, 'should be zero') + t.is(summary.totalBlockTotalFees, 0, 'should be zero') + t.is(summary.avgBlockReward, null, 'should be null') + t.is(summary.avgBlockTotalFees, null, 'should be null') + t.pass() +}) + +// ==================== Revenue Tests ==================== + +test('getRevenue - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'getWrkExtData') { + return [{ transactions: [{ ts: 1700006400000, changed_balance: 0.5, mining_extra: { tx_fee: 0.001 } }] }] + } + return {} + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getRevenue(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.pass() +}) + +test('getRevenue - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getRevenue(mockCtx, { query: { end: 1700100000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getRevenue - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getRevenue(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getRevenue - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getRevenue(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('getRevenue - pool filter', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ transactions: [{ ts: 1700006400000, changed_balance: 0.5 }] }] + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, pool: 'f2pool' } + } + + await getRevenue(mockCtx, mockReq, {}) + t.is(capturedPayload.type, 'minerpool-f2pool', 'should include pool in worker type') + t.pass() +}) + +test('calculateRevenueSummary - calculates from log entries', (t) => { + const log = [ + { revenueBTC: 0.5, feesBTC: 0.01, netRevenueBTC: 0.49 }, + { revenueBTC: 0.3, feesBTC: 0.005, netRevenueBTC: 0.295 } ] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.ok(Object.keys(daily).length > 0, 'should have entries') - const key = Object.keys(daily)[0] - t.is(daily[key].blockReward, 6.25, 'should extract blockReward') + const summary = calculateRevenueSummary(log) + t.is(summary.totalRevenueBTC, 0.8, 'should sum revenue') + t.is(summary.totalFeesBTC, 0.015, 'should sum fees') + t.ok(Math.abs(summary.totalNetRevenueBTC - 0.785) < 1e-10, 'should sum net revenue') t.pass() }) -test('processBlockData - handles error results', (t) => { - const results = [{ error: 'timeout' }] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.is(Object.keys(daily).length, 0, 'should be empty for error results') +test('calculateRevenueSummary - handles empty log', (t) => { + const summary = calculateRevenueSummary([]) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.totalFeesBTC, 0, 'should be zero') + t.is(summary.totalNetRevenueBTC, 0, 'should be zero') t.pass() }) -test('processBlockData - handles empty results', (t) => { - const results = [] - const daily = processBlockData(results) - t.ok(typeof daily === 'object', 'should return object') - t.is(Object.keys(daily).length, 0, 'should be empty') +// ==================== Revenue Summary Tests ==================== + +test('getRevenueSummary - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ data: { [dayTs]: { site_power_w: 5000, hashrate_mhs_5m_sum_aggr: 100000 } } }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return [{ transactions: [{ ts: dayTs, changed_balance: 0.5, mining_extra: { tx_fee: 0.001 } }] }] + } + if (payload.query && payload.query.key === 'HISTORICAL_PRICES') { + return [{ data: [{ ts: dayTs, priceUSD: 40000 }] }] + } + if (payload.query && payload.query.key === 'current_price') { + return { data: { USD: 40000 } } + } + if (payload.query && payload.query.key === 'HISTORICAL_BLOCKSIZES') { + return [{ data: [{ ts: dayTs, blockReward: 6.25, blockTotalFees: 0.5 }] }] + } + if (payload.query && payload.query.key === 'stats-history') { + return [] + } + } + if (method === 'getGlobalConfig') { + return { nominalPowerAvailability_MW: 10 } + } + return {} + } + }, + globalDataLib: { + getGlobalData: async () => [] + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getRevenueSummary(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + t.ok(result.summary.currentBtcPrice !== undefined, 'summary should have currentBtcPrice') + if (result.log.length > 0) { + const entry = result.log[0] + t.ok(entry.revenueBTC !== undefined, 'entry should have revenueBTC') + t.ok(entry.feesBTC !== undefined, 'entry should have feesBTC') + t.ok(entry.revenueUSD !== undefined, 'entry should have revenueUSD') + t.ok(entry.ebitdaSelling !== undefined, 'entry should have ebitdaSelling') + t.ok(entry.ebitdaHodl !== undefined, 'entry should have ebitdaHodl') + t.ok(entry.blockReward !== undefined, 'entry should have blockReward') + } t.pass() }) -test('calculateSubsidyFeesSummary - calculates from log entries', (t) => { +test('getRevenueSummary - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getRevenueSummary(mockCtx, { query: { end: 1700100000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getRevenueSummary - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + try { + await getRevenueSummary(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getRevenueSummary - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) }, + globalDataLib: { getGlobalData: async () => [] } + } + + const result = await getRevenueSummary(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('calculateDetailedRevenueSummary - calculates from log entries', (t) => { const log = [ - { blockReward: 6.25, blockTotalFees: 0.5 }, - { blockReward: 6.25, blockTotalFees: 0.3 } + { + revenueBTC: 0.5, + revenueUSD: 20000, + feesBTC: 0.01, + feesUSD: 400, + totalCostsUSD: 5000, + consumptionMWh: 100, + ebitdaSelling: 15000, + ebitdaHodl: 15000, + btcPrice: 40000, + curtailmentRate: 0.1, + powerUtilization: 0.8 + }, + { + revenueBTC: 0.3, + revenueUSD: 12600, + feesBTC: 0.005, + feesUSD: 210, + totalCostsUSD: 3000, + consumptionMWh: 60, + ebitdaSelling: 9600, + ebitdaHodl: 9600, + btcPrice: 42000, + curtailmentRate: 0.15, + powerUtilization: 0.85 + } ] - const summary = calculateSubsidyFeesSummary(log) - t.is(summary.totalBlockReward, 12.5, 'should sum block rewards') - t.is(summary.totalBlockTotalFees, 0.8, 'should sum block fees') - t.ok(summary.avgBlockReward !== null, 'should calculate avg block reward') - t.is(summary.avgBlockReward, 6.25, 'should calculate correct avg block reward') - t.ok(summary.avgBlockTotalFees !== null, 'should calculate avg block fees') + const summary = calculateDetailedRevenueSummary(log, 42000) + t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue') + t.is(summary.totalRevenueUSD, 32600, 'should sum USD revenue') + t.is(summary.totalFeesBTC, 0.015, 'should sum fees BTC') + t.is(summary.totalCostsUSD, 8000, 'should sum costs') + t.is(summary.totalConsumptionMWh, 160, 'should sum consumption') + t.is(summary.totalEbitdaSelling, 24600, 'should sum selling EBITDA') + t.ok(summary.avgCostPerMWh !== null, 'should calculate avg cost per MWh') + t.ok(summary.avgRevenuePerMWh !== null, 'should calculate avg revenue per MWh') + t.ok(summary.avgBtcPrice !== null, 'should calculate avg BTC price') + t.ok(summary.avgCurtailmentRate !== null, 'should calculate avg curtailment rate') + t.ok(summary.avgPowerUtilization !== null, 'should calculate avg power utilization') + t.is(summary.currentBtcPrice, 42000, 'should include current BTC price') t.pass() }) -test('calculateSubsidyFeesSummary - handles empty log', (t) => { - const summary = calculateSubsidyFeesSummary([]) - t.is(summary.totalBlockReward, 0, 'should be zero') - t.is(summary.totalBlockTotalFees, 0, 'should be zero') - t.is(summary.avgBlockReward, null, 'should be null') - t.is(summary.avgBlockTotalFees, null, 'should be null') +test('calculateDetailedRevenueSummary - handles empty log', (t) => { + const summary = calculateDetailedRevenueSummary([], 42000) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.totalRevenueUSD, 0, 'should be zero') + t.is(summary.totalFeesBTC, 0, 'should be zero') + t.is(summary.avgCostPerMWh, null, 'should be null') + t.is(summary.currentBtcPrice, 42000, 'should include current price') t.pass() }) diff --git a/tests/unit/handlers/finance.utils.test.js b/tests/unit/handlers/finance.utils.test.js new file mode 100644 index 0000000..b82f793 --- /dev/null +++ b/tests/unit/handlers/finance.utils.test.js @@ -0,0 +1,251 @@ +'use strict' + +const test = require('brittle') +const { + validateStartEnd, + normalizeTimestampMs, + processTransactions, + extractCurrentPrice, + processBlockData +} = require('../../../workers/lib/server/handlers/finance.utils') + +// ==================== validateStartEnd ==================== + +test('validateStartEnd - valid params', (t) => { + const req = { query: { start: 1700000000000, end: 1700100000000 } } + const { start, end } = validateStartEnd(req) + t.is(start, 1700000000000, 'should return start') + t.is(end, 1700100000000, 'should return end') + t.pass() +}) + +test('validateStartEnd - missing start throws', (t) => { + const req = { query: { end: 1700100000000 } } + try { + validateStartEnd(req) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END') + } + t.pass() +}) + +test('validateStartEnd - missing end throws', (t) => { + const req = { query: { start: 1700000000000 } } + try { + validateStartEnd(req) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END') + } + t.pass() +}) + +test('validateStartEnd - invalid range throws', (t) => { + const req = { query: { start: 1700100000000, end: 1700000000000 } } + try { + validateStartEnd(req) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE') + } + t.pass() +}) + +// ==================== normalizeTimestampMs ==================== + +test('normalizeTimestampMs - falsy input returns 0', (t) => { + t.is(normalizeTimestampMs(0), 0) + t.is(normalizeTimestampMs(null), 0) + t.is(normalizeTimestampMs(undefined), 0) + t.pass() +}) + +test('normalizeTimestampMs - seconds to ms conversion', (t) => { + const ts = normalizeTimestampMs(1700006400) + t.is(ts, 1700006400000, 'should multiply by 1000') + t.pass() +}) + +test('normalizeTimestampMs - ms passthrough', (t) => { + const ts = normalizeTimestampMs(1700006400000) + t.is(ts, 1700006400000, 'should leave ms unchanged') + t.pass() +}) + +// ==================== processTransactions ==================== + +test('processTransactions - Ocean data (sats)', (t) => { + const results = [ + [{ transactions: [{ ts: 1700006400000, satoshis_net_earned: 50000000 }] }] + ] + const daily = processTransactions(results) + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') + t.is(daily[key].feesBTC, undefined, 'should not track fees by default') + t.pass() +}) + +test('processTransactions - F2Pool data (BTC)', (t) => { + const results = [ + [{ transactions: [{ created_at: 1700006400, changed_balance: 0.001 }] }] + ] + const daily = processTransactions(results) + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly as BTC') + t.pass() +}) + +test('processTransactions - with trackFees (Ocean data)', (t) => { + const results = [ + [{ + transactions: [{ + ts: 1700006400000, + satoshis_net_earned: 50000000, + fees_colected_satoshis: 1000000 + }] + }] + ] + const daily = processTransactions(results, { trackFees: true }) + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC') + t.is(daily[key].feesBTC, 0.01, 'should track fees in BTC') + t.pass() +}) + +test('processTransactions - with trackFees (F2Pool data)', (t) => { + const results = [ + [{ + transactions: [{ + created_at: 1700006400, + changed_balance: 0.001, + mining_extra: { tx_fee: 0.0001 } + }] + }] + ] + const daily = processTransactions(results, { trackFees: true }) + const key = Object.keys(daily)[0] + t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly') + t.is(daily[key].feesBTC, 0.0001, 'should extract tx_fee') + t.pass() +}) + +test('processTransactions - seconds timestamps normalized', (t) => { + const results = [ + [{ transactions: [{ ts: 1700006400, changed_balance: 0.001 }] }] + ] + const daily = processTransactions(results) + t.ok(Object.keys(daily).length > 0, 'should have entries from seconds timestamps') + t.pass() +}) + +test('processTransactions - error results skipped', (t) => { + const results = [{ error: 'timeout' }] + const daily = processTransactions(results) + t.is(Object.keys(daily).length, 0, 'should be empty for error results') + t.pass() +}) + +test('processTransactions - null entries skipped', (t) => { + const results = [ + [{ transactions: [null, undefined] }] + ] + const daily = processTransactions(results) + t.is(Object.keys(daily).length, 0, 'should be empty for null entries') + t.pass() +}) + +test('processTransactions - empty results', (t) => { + const daily = processTransactions([]) + t.is(Object.keys(daily).length, 0, 'should be empty') + t.pass() +}) + +// ==================== extractCurrentPrice ==================== + +test('extractCurrentPrice - flat entry format (currentPrice)', (t) => { + const results = [ + [{ currentPrice: 42000, blockHeight: 900000 }] + ] + t.is(extractCurrentPrice(results), 42000, 'should extract currentPrice') + t.pass() +}) + +test('extractCurrentPrice - flat entry format (priceUSD)', (t) => { + const results = [ + [{ priceUSD: 42000 }] + ] + t.is(extractCurrentPrice(results), 42000, 'should extract priceUSD') + t.pass() +}) + +test('extractCurrentPrice - nested EBITDA format (numeric)', (t) => { + const results = [{ data: 42000 }] + t.is(extractCurrentPrice(results), 42000, 'should extract numeric nested price') + t.pass() +}) + +test('extractCurrentPrice - nested EBITDA format (object)', (t) => { + const results = [{ data: { USD: 42000 } }] + t.is(extractCurrentPrice(results), 42000, 'should extract USD from nested object') + t.pass() +}) + +test('extractCurrentPrice - error results return 0', (t) => { + const results = [{ error: 'timeout' }] + t.is(extractCurrentPrice(results), 0, 'should return 0 for error results') + t.pass() +}) + +// ==================== processBlockData ==================== + +test('processBlockData - array items', (t) => { + const results = [ + [{ + blocks: [{ + ts: 1700006400000, + blockReward: 6.25, + blockTotalFees: 0.5 + }] + }] + ] + const daily = processBlockData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 6.25, 'should extract blockReward') + t.is(daily[key].blockTotalFees, 0.5, 'should extract blockTotalFees') + t.pass() +}) + +test('processBlockData - object-keyed items', (t) => { + const results = [ + [{ data: { 1700006400000: { blockReward: 6.25, blockTotalFees: 0.5 } } }] + ] + const daily = processBlockData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 6.25, 'should extract from object keys') + t.is(daily[key].blockTotalFees, 0.5, 'should extract fees from object keys') + t.pass() +}) + +test('processBlockData - alt field names', (t) => { + const results = [ + [{ + blocks: [{ + ts: 1700006400000, + block_reward: 6.25, + total_fees: 0.5 + }] + }] + ] + const daily = processBlockData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 6.25, 'should handle snake_case field') + t.is(daily[key].blockTotalFees, 0.5, 'should handle total_fees field') + t.pass() +}) + +test('processBlockData - error/empty results', (t) => { + t.is(Object.keys(processBlockData([{ error: 'timeout' }])).length, 0, 'error results empty') + t.is(Object.keys(processBlockData([])).length, 0, 'empty results empty') + t.pass() +}) diff --git a/tests/unit/routes/finance.routes.test.js b/tests/unit/routes/finance.routes.test.js index 6ef74b9..08505a4 100644 --- a/tests/unit/routes/finance.routes.test.js +++ b/tests/unit/routes/finance.routes.test.js @@ -19,6 +19,8 @@ test('finance routes - route definitions', (t) => { t.ok(routeUrls.includes('/auth/finance/ebitda'), 'should have ebitda route') t.ok(routeUrls.includes('/auth/finance/cost-summary'), 'should have cost-summary route') t.ok(routeUrls.includes('/auth/finance/subsidy-fees'), 'should have subsidy-fees route') + t.ok(routeUrls.includes('/auth/finance/revenue'), 'should have revenue route') + t.ok(routeUrls.includes('/auth/finance/revenue-summary'), 'should have revenue-summary route') t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 1dc31b1..1148a61 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -116,6 +116,8 @@ const ENDPOINTS = { FINANCE_EBITDA: '/auth/finance/ebitda', FINANCE_COST_SUMMARY: '/auth/finance/cost-summary', FINANCE_SUBSIDY_FEES: '/auth/finance/subsidy-fees', + FINANCE_REVENUE: '/auth/finance/revenue', + FINANCE_REVENUE_SUMMARY: '/auth/finance/revenue-summary', // Pools endpoints POOLS: '/auth/pools', diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index 3d29480..20eb291 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -6,7 +6,6 @@ const { PERIOD_TYPES, MINERPOOL_EXT_DATA_KEYS, RPC_METHODS, - BTC_SATS, GLOBAL_DATA_TYPES } = require('../../constants') const { @@ -16,22 +15,20 @@ const { runParallel } = require('../../utils') const { aggregateByPeriod } = require('../../period.utils') +const { + validateStartEnd, + normalizeTimestampMs, + processTransactions, + extractCurrentPrice, + processBlockData +} = require('./finance.utils') // ==================== Energy Balance ==================== async function getEnergyBalance (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) + const { start, end } = validateStartEnd(req) const period = req.query.period || PERIOD_TYPES.DAILY - if (!start || !end) { - throw new Error('ERR_MISSING_START_END') - } - - if (start >= end) { - throw new Error('ERR_INVALID_DATE_RANGE') - } - const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() @@ -88,7 +85,7 @@ async function getEnergyBalance (ctx, req) { ]) const dailyConsumption = processConsumptionData(consumptionResults) - const dailyTransactions = processTransactionData(transactionResults) + const dailyTransactions = processTransactions(transactionResults) const dailyPrices = processPriceData(priceResults) const currentBtcPrice = extractCurrentPrice(currentPriceResults) const costsByMonth = processCostsData(productionCosts) @@ -194,38 +191,6 @@ function processConsumptionData (results) { return daily } -function normalizeTimestampMs (ts) { - if (!ts) return 0 - return ts < 1e12 ? ts * 1000 : ts -} - -function processTransactionData (results) { - const daily = {} - for (const res of results) { - if (!res || res.error) continue - const data = Array.isArray(res) ? res : (res.data || res.result || []) - if (!Array.isArray(data)) continue - for (const tx of data) { - if (!tx) continue - const txList = tx.data || tx.transactions || tx - if (!Array.isArray(txList)) continue - for (const t of txList) { - if (!t) continue - const rawTs = t.ts || t.created_at || t.timestamp || t.time - const ts = getStartOfDay(normalizeTimestampMs(rawTs)) - if (!ts) continue - const day = daily[ts] ??= { revenueBTC: 0 } - if (t.satoshis_net_earned) { - day.revenueBTC += Math.abs(t.satoshis_net_earned) / BTC_SATS - } else { - day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) - } - } - } - } - return daily -} - function processPriceData (results) { const daily = {} for (const res of results) { @@ -245,20 +210,6 @@ function processPriceData (results) { return daily } -function extractCurrentPrice (results) { - for (const res of results) { - if (res.error || !res) continue - const data = Array.isArray(res) ? res : [res] - for (const entry of data) { - if (!entry) continue - if (entry.currentPrice) return entry.currentPrice - if (entry.priceUSD) return entry.priceUSD - if (entry.price) return entry.price - } - } - return 0 -} - function processEnergyData (results, aggrField) { const daily = {} for (const res of results) { @@ -362,18 +313,9 @@ function calculateSummary (log) { // ==================== EBITDA ==================== async function getEbitda (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) + const { start, end } = validateStartEnd(req) const period = req.query.period || PERIOD_TYPES.MONTHLY - if (!start || !end) { - throw new Error('ERR_MISSING_START_END') - } - - if (start >= end) { - throw new Error('ERR_INVALID_DATE_RANGE') - } - const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() @@ -416,10 +358,10 @@ async function getEbitda (ctx, req) { .then(r => cb(null, r)).catch(cb) ]) - const dailyTransactions = processEbitdaTransactions(transactionResults) + const dailyTransactions = processTransactions(transactionResults) const dailyTailLog = processTailLogData(tailLogResults) const dailyPrices = processEbitdaPrices(priceResults) - const currentBtcPrice = extractEbitdaCurrentPrice(currentPriceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) const costsByMonth = processCostsData(productionCosts) const allDays = new Set([ @@ -504,29 +446,6 @@ function processTailLogData (results) { return daily } -function processEbitdaTransactions (results) { - const daily = {} - for (const res of results) { - if (res.error || !res) continue - const data = Array.isArray(res) ? res : (res.data || res.result || []) - if (!Array.isArray(data)) continue - for (const tx of data) { - if (!tx) continue - const txList = tx.data || tx.transactions || tx - if (!Array.isArray(txList)) continue - for (const t of txList) { - if (!t) continue - const ts = getStartOfDay(t.ts || t.timestamp || t.time) - if (!ts) continue - if (!daily[ts]) daily[ts] = { revenueBTC: 0 } - const amount = t.changed_balance || t.amount || t.value || 0 - daily[ts].revenueBTC += Math.abs(amount) / BTC_SATS - } - } - } - return daily -} - function processEbitdaPrices (results) { const daily = {} for (const res of results) { @@ -556,18 +475,6 @@ function processEbitdaPrices (results) { return daily } -function extractEbitdaCurrentPrice (results) { - for (const res of results) { - if (res.error || !res) continue - const data = Array.isArray(res) ? res[0] : res - if (!data) continue - const price = data.data || data.result || data - if (typeof price === 'number') return price - if (typeof price === 'object') return price.USD || price.price || price.current_price || 0 - } - return 0 -} - function calculateEbitdaSummary (log, currentBtcPrice) { if (!log.length) { return { @@ -604,18 +511,9 @@ function calculateEbitdaSummary (log, currentBtcPrice) { // ==================== Cost Summary ==================== async function getCostSummary (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) + const { start, end } = validateStartEnd(req) const period = req.query.period || PERIOD_TYPES.MONTHLY - if (!start || !end) { - throw new Error('ERR_MISSING_START_END') - } - - if (start >= end) { - throw new Error('ERR_INVALID_DATE_RANGE') - } - const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() @@ -718,18 +616,9 @@ function calculateCostSummary (log) { // ==================== Subsidy Fees ==================== async function getSubsidyFees (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) + const { start, end } = validateStartEnd(req) const period = req.query.period || PERIOD_TYPES.DAILY - if (!start || !end) { - throw new Error('ERR_MISSING_START_END') - } - - if (start >= end) { - throw new Error('ERR_INVALID_DATE_RANGE') - } - const blockResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'HISTORICAL_BLOCKSIZES', start, end } @@ -754,41 +643,6 @@ async function getSubsidyFees (ctx, req) { return { log: aggregated, summary } } -function processBlockData (results) { - const daily = {} - for (const res of results) { - if (!res || res.error) continue - const data = Array.isArray(res) ? res : (res.data || res.result || []) - if (!Array.isArray(data)) continue - for (const entry of data) { - if (!entry) continue - const items = entry.data || entry.blocks || entry - if (Array.isArray(items)) { - for (const item of items) { - if (!item) continue - const rawTs = item.ts || item.timestamp || item.time - const ts = getStartOfDay(normalizeTimestampMs(rawTs)) - if (!ts) continue - if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } - daily[ts].blockReward += (item.blockReward || item.block_reward || item.subsidy || 0) - daily[ts].blockTotalFees += (item.blockTotalFees || item.block_total_fees || item.totalFees || item.total_fees || 0) - } - } else if (typeof items === 'object' && !Array.isArray(items)) { - for (const [key, val] of Object.entries(items)) { - const ts = getStartOfDay(Number(key)) - if (!ts) continue - if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } - if (typeof val === 'object') { - daily[ts].blockReward += (val.blockReward || val.block_reward || val.subsidy || 0) - daily[ts].blockTotalFees += (val.blockTotalFees || val.block_total_fees || val.totalFees || val.total_fees || 0) - } - } - } - } - } - return daily -} - function calculateSubsidyFeesSummary (log) { if (!log.length) { return { @@ -813,6 +667,311 @@ function calculateSubsidyFeesSummary (log) { } } +// ==================== Revenue ==================== + +async function getRevenue (ctx, req) { + const { start, end } = validateStartEnd(req) + const period = req.query.period || PERIOD_TYPES.DAILY + const pool = req.query.pool || null + + const type = pool ? WORKER_TYPES.MINERPOOL + '-' + pool : WORKER_TYPES.MINERPOOL + const query = { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + + const transactionResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type, + query + }) + + const dailyRevenue = processTransactions(transactionResults, { trackFees: true }) + + const log = [] + for (const dayTs of Object.keys(dailyRevenue).sort()) { + const ts = Number(dayTs) + const day = dailyRevenue[dayTs] + const revenueBTC = day.revenueBTC || 0 + const feesBTC = day.feesBTC || 0 + log.push({ + ts, + revenueBTC, + feesBTC, + netRevenueBTC: revenueBTC - feesBTC + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateRevenueSummary(aggregated) + + return { log: aggregated, summary } +} + +function calculateRevenueSummary (log) { + if (!log.length) { + return { + totalRevenueBTC: 0, + totalFeesBTC: 0, + totalNetRevenueBTC: 0 + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.feesBTC += entry.feesBTC || 0 + acc.netRevenueBTC += entry.netRevenueBTC || 0 + return acc + }, { revenueBTC: 0, feesBTC: 0, netRevenueBTC: 0 }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalFeesBTC: totals.feesBTC, + totalNetRevenueBTC: totals.netRevenueBTC + } +} + +// ==================== Revenue Summary ==================== + +async function getRevenueSummary (ctx, req) { + const { start, end } = validateStartEnd(req) + const period = req.query.period || PERIOD_TYPES.DAILY + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const [ + transactionResults, + priceResults, + currentPriceResults, + tailLogResults, + productionCosts, + blockResults, + activeEnergyInResults, + uteEnergyResults, + globalConfigResults + ] = await runParallel([ + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MINERPOOL, + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'HISTORICAL_PRICES', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'current_price' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [ + { + type: WORKER_TYPES.POWERMETER, + startDate, + endDate, + fields: { [AGGR_FIELDS.SITE_POWER]: 1 }, + shouldReturnDailyData: 1 + }, + { + type: WORKER_TYPES.MINER, + startDate, + endDate, + fields: { [AGGR_FIELDS.HASHRATE_SUM]: 1 }, + shouldReturnDailyData: 1 + } + ] + }).then(r => cb(null, r)).catch(cb), + + (cb) => getProductionCosts(ctx, start, end) + .then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'HISTORICAL_BLOCKSIZES', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.ELECTRICITY, + query: { key: 'stats-history', start, end, groupRange: '1D' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GLOBAL_CONFIG, {}) + .then(r => cb(null, r)).catch(cb) + ]) + + const dailyRevenue = processTransactions(transactionResults, { trackFees: true }) + const dailyPrices = processEbitdaPrices(priceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) + const dailyTailLog = processTailLogData(tailLogResults) + const costsByMonth = processCostsData(productionCosts) + const dailyBlocks = processBlockData(blockResults) + const dailyActiveEnergyIn = processEnergyData(activeEnergyInResults, AGGR_FIELDS.ACTIVE_ENERGY_IN) + const dailyUteEnergy = processEnergyData(uteEnergyResults, AGGR_FIELDS.UTE_ENERGY) + const nominalPowerMW = extractNominalPower(globalConfigResults) + + const allDays = new Set([ + ...Object.keys(dailyRevenue), + ...Object.keys(dailyTailLog), + ...Object.keys(dailyPrices) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const revenue = dailyRevenue[dayTs] || {} + const tailLog = dailyTailLog[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 + const block = dailyBlocks[dayTs] || {} + + const revenueBTC = revenue.revenueBTC || 0 + const feesBTC = revenue.feesBTC || 0 + const revenueUSD = revenueBTC * btcPrice + const feesUSD = feesBTC * btcPrice + + const powerW = tailLog.powerW || 0 + const consumptionMWh = (powerW * 24) / 1000000 + const hashrateMhs = tailLog.hashrateMhs || 0 + const hashratePhs = hashrateMhs / 1e9 + + const monthKey = `${new Date(ts).getFullYear()}-${String(new Date(ts).getMonth() + 1).padStart(2, '0')}` + const costs = costsByMonth[monthKey] || {} + const energyCostsUSD = costs.energyCostPerDay || 0 + const operationalCostsUSD = costs.operationalCostPerDay || 0 + const totalCostsUSD = energyCostsUSD + operationalCostsUSD + + const activeEnergyIn = dailyActiveEnergyIn[dayTs] || 0 + const uteEnergy = dailyUteEnergy[dayTs] || 0 + + const curtailmentMWh = activeEnergyIn > 0 + ? activeEnergyIn - consumptionMWh + : null + const curtailmentRate = curtailmentMWh !== null + ? safeDiv(curtailmentMWh, consumptionMWh) + : null + + const operationalIssuesRate = uteEnergy > 0 + ? safeDiv(uteEnergy - consumptionMWh, uteEnergy) + : null + + const actualPowerMW = powerW / 1000000 + const powerUtilization = nominalPowerMW > 0 + ? safeDiv(actualPowerMW, nominalPowerMW) + : null + + log.push({ + ts, + revenueBTC, + feesBTC, + revenueUSD, + feesUSD, + btcPrice, + powerW, + consumptionMWh, + hashrateMhs, + energyCostsUSD, + operationalCostsUSD, + totalCostsUSD, + ebitdaSelling: revenueUSD - totalCostsUSD, + ebitdaHodl: (revenueBTC * currentBtcPrice) - totalCostsUSD, + btcProductionCost: safeDiv(totalCostsUSD, revenueBTC), + energyRevenuePerMWh: safeDiv(revenueUSD, consumptionMWh), + allInCostPerMWh: safeDiv(totalCostsUSD, consumptionMWh), + hashRevenueBTCPerPHsPerDay: safeDiv(revenueBTC, hashratePhs), + hashRevenueUSDPerPHsPerDay: safeDiv(revenueUSD, hashratePhs), + blockReward: block.blockReward || 0, + blockTotalFees: block.blockTotalFees || 0, + curtailmentMWh, + curtailmentRate, + operationalIssuesRate, + powerUtilization + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateDetailedRevenueSummary(aggregated, currentBtcPrice) + + return { log: aggregated, summary } +} + +function calculateDetailedRevenueSummary (log, currentBtcPrice) { + if (!log.length) { + return { + totalRevenueBTC: 0, + totalRevenueUSD: 0, + totalFeesBTC: 0, + totalFeesUSD: 0, + totalCostsUSD: 0, + totalConsumptionMWh: 0, + avgCostPerMWh: null, + avgRevenuePerMWh: null, + avgBtcPrice: null, + avgCurtailmentRate: null, + avgPowerUtilization: null, + totalEbitdaSelling: 0, + totalEbitdaHodl: 0, + currentBtcPrice: currentBtcPrice || 0 + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.revenueUSD += entry.revenueUSD || 0 + acc.feesBTC += entry.feesBTC || 0 + acc.feesUSD += entry.feesUSD || 0 + acc.costsUSD += entry.totalCostsUSD || 0 + acc.consumptionMWh += entry.consumptionMWh || 0 + acc.ebitdaSelling += entry.ebitdaSelling || 0 + acc.ebitdaHodl += entry.ebitdaHodl || 0 + acc.btcPriceSum += entry.btcPrice || 0 + acc.btcPriceCount += entry.btcPrice ? 1 : 0 + if (entry.curtailmentRate !== null && entry.curtailmentRate !== undefined) { + acc.curtailmentRateSum += entry.curtailmentRate + acc.curtailmentRateCount++ + } + if (entry.powerUtilization !== null && entry.powerUtilization !== undefined) { + acc.powerUtilizationSum += entry.powerUtilization + acc.powerUtilizationCount++ + } + return acc + }, { + revenueBTC: 0, + revenueUSD: 0, + feesBTC: 0, + feesUSD: 0, + costsUSD: 0, + consumptionMWh: 0, + ebitdaSelling: 0, + ebitdaHodl: 0, + btcPriceSum: 0, + btcPriceCount: 0, + curtailmentRateSum: 0, + curtailmentRateCount: 0, + powerUtilizationSum: 0, + powerUtilizationCount: 0 + }) + + return { + totalRevenueBTC: totals.revenueBTC, + totalRevenueUSD: totals.revenueUSD, + totalFeesBTC: totals.feesBTC, + totalFeesUSD: totals.feesUSD, + totalCostsUSD: totals.costsUSD, + totalConsumptionMWh: totals.consumptionMWh, + avgCostPerMWh: safeDiv(totals.costsUSD, totals.consumptionMWh), + avgRevenuePerMWh: safeDiv(totals.revenueUSD, totals.consumptionMWh), + avgBtcPrice: safeDiv(totals.btcPriceSum, totals.btcPriceCount), + avgCurtailmentRate: safeDiv(totals.curtailmentRateSum, totals.curtailmentRateCount), + avgPowerUtilization: safeDiv(totals.powerUtilizationSum, totals.powerUtilizationCount), + totalEbitdaSelling: totals.ebitdaSelling, + totalEbitdaHodl: totals.ebitdaHodl, + currentBtcPrice: currentBtcPrice || 0 + } +} + // ==================== Shared ==================== async function getProductionCosts (ctx, start, end) { @@ -851,21 +1010,26 @@ module.exports = { getEbitda, getCostSummary, getSubsidyFees, + getRevenue, + getRevenueSummary, getProductionCosts, processConsumptionData, - processTransactionData, processPriceData, - extractCurrentPrice, processEnergyData, extractNominalPower, processCostsData, calculateSummary, processTailLogData, - processEbitdaTransactions, processEbitdaPrices, - extractEbitdaCurrentPrice, calculateEbitdaSummary, calculateCostSummary, - processBlockData, - calculateSubsidyFeesSummary + calculateSubsidyFeesSummary, + calculateRevenueSummary, + calculateDetailedRevenueSummary, + // Re-export from finance.utils + validateStartEnd, + normalizeTimestampMs, + processTransactions, + extractCurrentPrice, + processBlockData } diff --git a/workers/lib/server/handlers/finance.utils.js b/workers/lib/server/handlers/finance.utils.js new file mode 100644 index 0000000..8b6746c --- /dev/null +++ b/workers/lib/server/handlers/finance.utils.js @@ -0,0 +1,130 @@ +'use strict' + +const { BTC_SATS } = require('../../constants') +const { getStartOfDay } = require('../../utils') + +function validateStartEnd (req) { + const start = Number(req.query.start) + const end = Number(req.query.end) + + if (!start || !end) { + throw new Error('ERR_MISSING_START_END') + } + + if (start >= end) { + throw new Error('ERR_INVALID_DATE_RANGE') + } + + return { start, end } +} + +function normalizeTimestampMs (ts) { + if (!ts) return 0 + return ts < 1e12 ? ts * 1000 : ts +} + +function processTransactions (results, opts) { + const trackFees = opts && opts.trackFees + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const tx of data) { + if (!tx) continue + const txList = tx.data || tx.transactions || tx + if (!Array.isArray(txList)) continue + for (const t of txList) { + if (!t) continue + const rawTs = t.ts || t.created_at || t.timestamp || t.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + if (!ts) continue + const day = daily[ts] ??= trackFees + ? { revenueBTC: 0, feesBTC: 0 } + : { revenueBTC: 0 } + if (t.satoshis_net_earned) { + day.revenueBTC += Math.abs(t.satoshis_net_earned) / BTC_SATS + if (trackFees) { + day.feesBTC += (t.fees_colected_satoshis || 0) / BTC_SATS + } + } else { + day.revenueBTC += Math.abs(t.changed_balance || t.amount || t.value || 0) + if (trackFees) { + day.feesBTC += (t.mining_extra?.tx_fee || 0) + } + } + } + } + } + return daily +} + +function extractCurrentPrice (results) { + for (const res of results) { + if (!res || res.error) continue + + // Flat entry format: [{currentPrice: N}, {priceUSD: N}, {price: N}] + const data = Array.isArray(res) ? res : [res] + for (const entry of data) { + if (!entry) continue + if (entry.currentPrice) return entry.currentPrice + if (entry.priceUSD) return entry.priceUSD + if (entry.price) return entry.price + + // Nested EBITDA format: {data: N} or {data: {USD: N}} or {result: ...} + const nested = entry.data || entry.result + if (nested) { + if (typeof nested === 'number') return nested + if (typeof nested === 'object') { + if (nested.USD) return nested.USD + if (nested.price) return nested.price + if (nested.current_price) return nested.current_price + } + } + } + } + return 0 +} + +function processBlockData (results) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.blocks || entry + if (Array.isArray(items)) { + for (const item of items) { + if (!item) continue + const rawTs = item.ts || item.timestamp || item.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + if (!ts) continue + if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } + daily[ts].blockReward += (item.blockReward || item.block_reward || item.subsidy || 0) + daily[ts].blockTotalFees += (item.blockTotalFees || item.block_total_fees || item.totalFees || item.total_fees || 0) + } + } else if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!ts) continue + if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } + if (typeof val === 'object') { + daily[ts].blockReward += (val.blockReward || val.block_reward || val.subsidy || 0) + daily[ts].blockTotalFees += (val.blockTotalFees || val.block_total_fees || val.totalFees || val.total_fees || 0) + } + } + } + } + } + return daily +} + +module.exports = { + validateStartEnd, + normalizeTimestampMs, + processTransactions, + extractCurrentPrice, + processBlockData +} diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js index 7b05e02..e31b4a0 100644 --- a/workers/lib/server/routes/finance.routes.js +++ b/workers/lib/server/routes/finance.routes.js @@ -8,7 +8,9 @@ const { getEnergyBalance, getEbitda, getCostSummary, - getSubsidyFees + getSubsidyFees, + getRevenue, + getRevenueSummary } = require('../handlers/finance.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') @@ -87,6 +89,43 @@ module.exports = (ctx) => { ENDPOINTS.FINANCE_SUBSIDY_FEES, getSubsidyFees ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_REVENUE, + schema: { + querystring: schemas.query.revenue + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/revenue', + req.query.start, + req.query.end, + req.query.period, + req.query.pool + ], + ENDPOINTS.FINANCE_REVENUE, + getRevenue + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_REVENUE_SUMMARY, + schema: { + querystring: schemas.query.revenueSummary + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/revenue-summary', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_REVENUE_SUMMARY, + getRevenueSummary + ) } ] } diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index b7d3fc2..bde6d99 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -41,6 +41,27 @@ const schemas = { overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] + }, + revenue: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'yearly'] }, + pool: { type: 'string' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, + revenueSummary: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] } } } From cd01902ad2be0428ec1a79dc862e8464d9250527 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Fri, 6 Mar 2026 12:53:17 +0300 Subject: [PATCH 09/63] feat: add device listing API v2 endpoints (#25) * feat: add device listing API v2 endpoints * fix: paginate listThings RPC and add field projections for device endpoints Address PR review: use requestRpcMapAllPages to fetch all items beyond the default 100-item RPC limit, and pass DEVICE_LIST_FIELDS projection to reduce response size when status=1. * fix: use requestRpcMapLimit with limit/offset and remove status - Switch getMiners/getContainers from requestRpcMapAllPages to requestRpcMapLimit, passing limit/offset to RPC instead of fetching all objects - Remove status:1 from all listThings calls as DEVICE_LIST_FIELDS don't have snap properties - Update pagination test to verify RPC payload * Add maximium to limit * Remove /auth/miners --- tests/unit/handlers/devices.handlers.test.js | 282 ++++++++++++++++++ tests/unit/routes/devices.routes.test.js | 63 ++++ workers/lib/constants.js | 9 +- .../lib/server/handlers/devices.handlers.js | 153 ++++++++++ workers/lib/server/index.js | 2 + workers/lib/server/routes/devices.routes.js | 72 +++++ workers/lib/server/schemas/devices.schemas.js | 30 ++ workers/lib/utils.js | 23 ++ 8 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 tests/unit/handlers/devices.handlers.test.js create mode 100644 tests/unit/routes/devices.routes.test.js create mode 100644 workers/lib/server/handlers/devices.handlers.js create mode 100644 workers/lib/server/routes/devices.routes.js create mode 100644 workers/lib/server/schemas/devices.schemas.js diff --git a/tests/unit/handlers/devices.handlers.test.js b/tests/unit/handlers/devices.handlers.test.js new file mode 100644 index 0000000..2668c06 --- /dev/null +++ b/tests/unit/handlers/devices.handlers.test.js @@ -0,0 +1,282 @@ +'use strict' + +const test = require('brittle') +const { + getContainers, + getCabinets, + getCabinetById, + groupIntoCabinets, + buildMingoFilter, + queryAndPaginate +} = require('../../../workers/lib/server/handlers/devices.handlers') +const { flattenRpcResults } = require('../../../workers/lib/utils') + +test('flattenRpcResults - flattens multi-ork arrays', (t) => { + const results = [ + [{ id: 'm1', ip: '10.0.0.1' }, { id: 'm2', ip: '10.0.0.2' }], + [{ id: 'm3', ip: '10.0.0.3' }] + ] + const items = flattenRpcResults(results) + t.is(items.length, 3, 'should flatten all items') + t.pass() +}) + +test('flattenRpcResults - deduplicates by id', (t) => { + const results = [ + [{ id: 'm1', ip: '10.0.0.1' }], + [{ id: 'm1', ip: '10.0.0.1' }] + ] + const items = flattenRpcResults(results) + t.is(items.length, 1, 'should deduplicate by id') + t.pass() +}) + +test('flattenRpcResults - handles error results', (t) => { + const results = [{ error: 'timeout' }, [{ id: 'm1' }]] + const items = flattenRpcResults(results) + t.is(items.length, 1, 'should skip error results') + t.pass() +}) + +test('flattenRpcResults - handles null input', (t) => { + const items = flattenRpcResults(null) + t.is(items.length, 0, 'should return empty array') + t.pass() +}) + +test('flattenRpcResults - handles empty input', (t) => { + const items = flattenRpcResults([]) + t.is(items.length, 0, 'should return empty array') + t.pass() +}) + +test('flattenRpcResults - handles nested data property', (t) => { + const results = [ + { data: [{ id: 'm1' }, { id: 'm2' }] } + ] + const items = flattenRpcResults(results) + t.is(items.length, 2, 'should extract from data property') + t.pass() +}) + +test('buildMingoFilter - no filter no search returns empty object', (t) => { + const result = buildMingoFilter(null, null) + t.alike(result, {}, 'should return empty object') + t.pass() +}) + +test('buildMingoFilter - filter only returns filter as-is', (t) => { + const filter = { type: 's19' } + const result = buildMingoFilter(filter, null) + t.alike(result, filter, 'should return filter directly') + t.pass() +}) + +test('buildMingoFilter - search only returns $or filter', (t) => { + const result = buildMingoFilter(null, 'alpha') + t.ok(result.$or, 'should have $or') + t.is(result.$or.length, 2, 'should have 2 search conditions') + t.pass() +}) + +test('buildMingoFilter - filter and search combined with $and', (t) => { + const filter = { $or: [{ type: 's19' }, { type: 's21' }] } + const result = buildMingoFilter(filter, 'alpha') + t.ok(result.$and, 'should wrap in $and') + t.is(result.$and.length, 2, 'should have filter and search') + t.ok(result.$and[0].$or, 'first should be user filter with $or') + t.ok(result.$and[1].$or, 'second should be search filter with $or') + t.pass() +}) + +test('queryAndPaginate - filters and paginates', (t) => { + const items = [ + { id: 'm1', type: 's19' }, + { id: 'm2', type: 's21' }, + { id: 'm3', type: 's19' } + ] + const result = queryAndPaginate(items, { + filter: { type: 's19' }, + fields: null, + sort: null, + search: null, + offset: 0, + limit: 1 + }) + t.is(result.total, 2, 'total should be filtered count') + t.is(result.page.length, 1, 'page should respect limit') + t.pass() +}) + +test('getContainers - happy path', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => [ + { id: 'c1', type: 'bitdeer-d40' }, + { id: 'c2', type: 'antbox-hydro' } + ] + } + } + + const result = await getContainers(mockCtx, { query: {} }) + t.ok(result.containers, 'should return containers array') + t.is(result.containers.length, 2, 'should have 2 containers') + t.is(result.total, 2, 'should report total') + t.pass() +}) + +test('getContainers - with filter', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => [ + { id: 'c1', type: 'bitdeer-d40', status: 'online' }, + { id: 'c2', type: 'antbox-hydro', status: 'offline' } + ] + } + } + + const result = await getContainers(mockCtx, { query: { filter: '{"status":"online"}' } }) + t.is(result.containers.length, 1, 'should filter containers') + t.is(result.containers[0].status, 'online', 'should match filter') + t.pass() +}) + +test('getContainers - empty results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => [] } + } + + const result = await getContainers(mockCtx, { query: {} }) + t.is(result.containers.length, 0, 'should return empty array') + t.is(result.total, 0, 'total should be 0') + t.pass() +}) + +test('getCabinets - happy path with grouping', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => [ + { id: 'd1', info: { pos: 'cab-A/slot1' }, tags: ['t-powermeter'] }, + { id: 'd2', info: { pos: 'cab-A/slot2' }, tags: ['t-sensor-temp'] }, + { id: 'd3', info: { pos: 'cab-B/slot1' }, tags: ['t-powermeter'] } + ] + } + } + + const result = await getCabinets(mockCtx, { query: {} }) + t.ok(result.cabinets, 'should return cabinets array') + t.is(result.cabinets.length, 2, 'should group into 2 cabinets') + t.is(result.total, 2, 'should report total') + + const cabA = result.cabinets.find(c => c.id === 'cab-A') + t.ok(cabA, 'should have cab-A') + t.is(cabA.devices.length, 2, 'cab-A should have 2 devices') + t.pass() +}) + +test('getCabinets - empty results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => [] } + } + + const result = await getCabinets(mockCtx, { query: {} }) + t.is(result.cabinets.length, 0, 'should return empty array') + t.is(result.total, 0, 'total should be 0') + t.pass() +}) + +test('getCabinets - with pagination', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => [ + { id: 'd1', info: { pos: 'cab-A/slot1' } }, + { id: 'd2', info: { pos: 'cab-B/slot1' } }, + { id: 'd3', info: { pos: 'cab-C/slot1' } } + ] + } + } + + const result = await getCabinets(mockCtx, { query: { offset: '0', limit: '2' } }) + t.is(result.total, 3, 'total should reflect all cabinets') + t.is(result.cabinets.length, 2, 'should return limited results') + t.pass() +}) + +test('getCabinetById - happy path', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => [ + { id: 'd1', info: { pos: 'cab-A/slot1' } }, + { id: 'd2', info: { pos: 'cab-A/slot2' } }, + { id: 'd3', info: { pos: 'cab-B/slot1' } } + ] + } + } + + const result = await getCabinetById(mockCtx, { params: { id: 'cab-A' }, query: {} }) + t.ok(result.cabinet, 'should return cabinet') + t.is(result.cabinet.id, 'cab-A', 'should match requested id') + t.is(result.cabinet.devices.length, 2, 'should have 2 devices') + t.pass() +}) + +test('getCabinetById - not found', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => [ + { id: 'd1', info: { pos: 'cab-A/slot1' } } + ] + } + } + + try { + await getCabinetById(mockCtx, { params: { id: 'nonexistent' }, query: {} }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_CABINET_NOT_FOUND', 'should throw not found error') + t.is(err.statusCode, 404, 'should have 404 status code') + } + t.pass() +}) + +test('groupIntoCabinets - groups by pos root', (t) => { + const devices = [ + { id: 'd1', info: { pos: 'cab-A/slot1' } }, + { id: 'd2', info: { pos: 'cab-A/slot2' } }, + { id: 'd3', info: { pos: 'cab-B/slot1' } } + ] + + const cabinets = groupIntoCabinets(devices) + t.is(cabinets.length, 2, 'should create 2 groups') + + const cabA = cabinets.find(c => c.id === 'cab-A') + t.ok(cabA, 'should have cab-A') + t.is(cabA.devices.length, 2, 'cab-A should have 2 devices') + t.is(cabA.type, 'cabinet', 'should have type cabinet') + t.pass() +}) + +test('groupIntoCabinets - handles devices without pos', (t) => { + const devices = [ + { id: 'd1' }, + { id: 'd2', info: {} } + ] + + const cabinets = groupIntoCabinets(devices) + t.ok(cabinets.length > 0, 'should still group devices') + t.pass() +}) + +test('groupIntoCabinets - empty input', (t) => { + const cabinets = groupIntoCabinets([]) + t.is(cabinets.length, 0, 'should return empty array') + t.pass() +}) diff --git a/tests/unit/routes/devices.routes.test.js b/tests/unit/routes/devices.routes.test.js new file mode 100644 index 0000000..ba37f66 --- /dev/null +++ b/tests/unit/routes/devices.routes.test.js @@ -0,0 +1,63 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions, testOnRequestFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') +const schemas = require('../../../workers/lib/server/schemas/devices.schemas.js') + +const ROUTES_PATH = '../../../workers/lib/server/routes/devices.routes.js' + +test('devices routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'devices') + t.pass() +}) + +test('devices routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/containers'), 'should have containers route') + t.ok(routeUrls.includes('/auth/cabinets'), 'should have cabinets route') + t.ok(routeUrls.includes('/auth/cabinets/:id'), 'should have cabinet by id route') + t.pass() +}) + +test('devices 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('devices routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'devices') + t.pass() +}) + +test('devices routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'devices') + t.pass() +}) + +test('devices routes - schemas enforce limit maximum of 100', (t) => { + const schemaNames = ['containers', 'cabinets'] + for (const name of schemaNames) { + const schema = schemas.query[name] + t.ok(schema.properties.limit, `${name} schema should have limit property`) + t.is(schema.properties.limit.maximum, 100, `${name} limit maximum should be 100`) + t.is(schema.properties.limit.minimum, 1, `${name} limit minimum should be 1`) + } + t.pass() +}) + +test('devices routes - schemas have querystring on routes', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + const containersRoute = routes.find(r => r.url === '/auth/containers') + const cabinetsRoute = routes.find(r => r.url === '/auth/cabinets') + + t.ok(containersRoute.schema.querystring, 'containers route should have querystring schema') + t.ok(cabinetsRoute.schema.querystring, 'cabinets route should have querystring schema') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 1148a61..3b026dc 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -137,6 +137,11 @@ const ENDPOINTS = { SITE_STATUS_LIVE: '/auth/site/status/live', + // Device listing endpoints + CONTAINERS: '/auth/containers', + CABINETS: '/auth/cabinets', + CABINET_BY_ID: '/auth/cabinets/:id', + // Metrics endpoints METRICS_HASHRATE: '/auth/metrics/hashrate', METRICS_CONSUMPTION: '/auth/metrics/consumption', @@ -289,7 +294,9 @@ const LOG_KEYS = { const WORKER_TAGS = { MINER: 't-miner', - CONTAINER: 't-container' + CONTAINER: 't-container', + POWERMETER: 't-powermeter', + TEMP_SENSOR: 't-sensor-temp' } const DEVICE_LIST_FIELDS = { diff --git a/workers/lib/server/handlers/devices.handlers.js b/workers/lib/server/handlers/devices.handlers.js new file mode 100644 index 0000000..81aa9f1 --- /dev/null +++ b/workers/lib/server/handlers/devices.handlers.js @@ -0,0 +1,153 @@ +'use strict' + +const mingo = require('mingo') +const { + RPC_METHODS, + STATUS_CODES, + WORKER_TAGS, + DEVICE_LIST_FIELDS +} = require('../../constants') +const { + requestRpcMapAllPages, + requestRpcMapLimit, + parseJsonQueryParam, + flattenRpcResults +} = require('../../utils') + +const CABINET_TAGS_QUERY = { tags: { $in: [WORKER_TAGS.POWERMETER, WORKER_TAGS.TEMP_SENSOR] } } + +function parseListQuery (req) { + return { + filter: req.query.filter ? parseJsonQueryParam(req.query.filter, 'ERR_FILTER_INVALID_JSON') : null, + sort: req.query.sort ? parseJsonQueryParam(req.query.sort, 'ERR_SORT_INVALID_JSON') : null, + fields: req.query.fields ? parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') : null, + search: req.query.search || null, + offset: Number(req.query.offset) || 0, + limit: Number(req.query.limit) || 0 + } +} + +function buildMingoFilter (filter, search) { + if (!filter && !search) return {} + + const searchFilter = search + ? { + $or: [ + { id: { $regex: search, $options: 'i' } }, + { ip: { $regex: search, $options: 'i' } } + ] + } + : null + + if (!filter) return searchFilter + if (!searchFilter) return filter + return { $and: [filter, searchFilter] } +} + +function queryAndPaginate (items, { filter, fields, sort, search, offset, limit }) { + const mingoFilter = buildMingoFilter(filter, search) + const query = new mingo.Query(mingoFilter) + let cursor = query.find(items, fields || {}) + if (sort) cursor = cursor.sort(sort) + const filtered = cursor.all() + + const total = filtered.length + const page = (offset || limit) + ? filtered.slice(offset, limit ? offset + limit : undefined) + : filtered + + return { page, total } +} + +async function getContainers (ctx, req) { + const params = parseListQuery(req) + + const results = await requestRpcMapLimit(ctx, RPC_METHODS.LIST_THINGS, { + query: { tags: { $in: [WORKER_TAGS.CONTAINER] } }, + fields: DEVICE_LIST_FIELDS, + ...(params.limit && { limit: params.limit }), + ...(params.offset && { offset: params.offset }) + }) + + const items = flattenRpcResults(results) + const { page: containers, total } = queryAndPaginate(items, { + ...params, + offset: 0, + limit: 0 + }) + + return { containers, total } +} + +async function getCabinets (ctx, req) { + const { filter, sort, offset, limit } = parseListQuery(req) + + const results = await requestRpcMapAllPages(ctx, RPC_METHODS.LIST_THINGS, { + query: CABINET_TAGS_QUERY, + fields: DEVICE_LIST_FIELDS + }) + + const devices = flattenRpcResults(results) + let cabinets = groupIntoCabinets(devices) + + if (filter || sort) { + const query = new mingo.Query(filter || {}) + let cursor = query.find(cabinets) + if (sort) cursor = cursor.sort(sort) + cabinets = cursor.all() + } + + const total = cabinets.length + if (offset || limit) { + cabinets = cabinets.slice(offset, limit ? offset + limit : undefined) + } + + return { cabinets, total } +} + +async function getCabinetById (ctx, req) { + const cabinetId = req.params.id + + const results = await requestRpcMapAllPages(ctx, RPC_METHODS.LIST_THINGS, { + query: CABINET_TAGS_QUERY, + fields: DEVICE_LIST_FIELDS + }) + + const devices = flattenRpcResults(results) + const cabinets = groupIntoCabinets(devices) + const cabinet = cabinets.find(c => c.id === cabinetId) + + if (!cabinet) { + const err = new Error('ERR_CABINET_NOT_FOUND') + err.statusCode = STATUS_CODES.NOT_FOUND + throw err + } + + return { cabinet } +} + +function groupIntoCabinets (devices) { + const groups = {} + + for (const device of devices) { + const pos = device.info?.pos || device.pos || '' + const root = pos.split('/')[0] || device.id || 'unknown' + + if (!groups[root]) { + groups[root] = { id: root, devices: [], type: 'cabinet' } + } + groups[root].devices.push(device) + } + + return Object.values(groups) +} + +module.exports = { + getContainers, + getCabinets, + getCabinetById, + groupIntoCabinets, + parseListQuery, + buildMingoFilter, + queryAndPaginate +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 462d8f3..9a823bc 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -12,6 +12,7 @@ const financeRoutes = require('./routes/finance.routes') const poolsRoutes = require('./routes/pools.routes') const poolManagerRoutes = require('./routes/poolManager.routes') const siteRoutes = require('./routes/site.routes') +const devicesRoutes = require('./routes/devices.routes') const metricsRoutes = require('./routes/metrics.routes') const alertsRoutes = require('./routes/alerts.routes') @@ -33,6 +34,7 @@ function routes (ctx) { ...poolsRoutes(ctx), ...poolManagerRoutes(ctx), ...siteRoutes(ctx), + ...devicesRoutes(ctx), ...metricsRoutes(ctx), ...alertsRoutes(ctx) ] diff --git a/workers/lib/server/routes/devices.routes.js b/workers/lib/server/routes/devices.routes.js new file mode 100644 index 0000000..c45c81e --- /dev/null +++ b/workers/lib/server/routes/devices.routes.js @@ -0,0 +1,72 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { + getContainers, + getCabinets, + getCabinetById +} = require('../handlers/devices.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/devices.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.CONTAINERS, + schema: { + querystring: schemas.query.containers + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'containers', + req.query.filter, + req.query.sort, + req.query.fields, + req.query.search, + req.query.offset, + req.query.limit + ], + ENDPOINTS.CONTAINERS, + getContainers + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.CABINETS, + schema: { + querystring: schemas.query.cabinets + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'cabinets', + req.query.filter, + req.query.sort, + req.query.offset, + req.query.limit + ], + ENDPOINTS.CABINETS, + getCabinets + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.CABINET_BY_ID, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'cabinets', + req.params.id + ], + ENDPOINTS.CABINET_BY_ID, + getCabinetById + ) + } + ] +} diff --git a/workers/lib/server/schemas/devices.schemas.js b/workers/lib/server/schemas/devices.schemas.js new file mode 100644 index 0000000..e155e0b --- /dev/null +++ b/workers/lib/server/schemas/devices.schemas.js @@ -0,0 +1,30 @@ +'use strict' + +const schemas = { + query: { + containers: { + type: 'object', + properties: { + filter: { type: 'string' }, + sort: { type: 'string' }, + fields: { type: 'string' }, + search: { type: 'string' }, + offset: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + overwriteCache: { type: 'boolean' } + } + }, + cabinets: { + type: 'object', + properties: { + filter: { type: 'string' }, + sort: { type: 'string' }, + offset: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + overwriteCache: { type: 'boolean' } + } + } + } +} + +module.exports = schemas diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 069bd99..b4051b6 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -170,6 +170,28 @@ const runParallel = (tasks) => }) }) +const flattenRpcResults = (results) => { + const items = [] + const seen = new Set() + if (!Array.isArray(results)) return items + + for (const orkResult of results) { + if (!orkResult || orkResult.error) continue + const data = Array.isArray(orkResult) ? orkResult : (orkResult.data || orkResult.result || []) + if (!Array.isArray(data)) continue + + for (const item of data) { + if (!item) continue + const id = item.id || item._id + if (id && seen.has(id)) continue + if (id) seen.add(id) + items.push(item) + } + } + + return items +} + const safeDiv = (numerator, denominator) => typeof numerator === 'number' && typeof denominator === 'number' && @@ -218,6 +240,7 @@ module.exports = { requestRpcMapLimit, requestRpcMapAllPages, getStartOfDay, + flattenRpcResults, safeDiv, runParallel, deduplicateAlerts, From 4925c0b66dc966225d2151e0b19291366457b629 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:07:02 +0530 Subject: [PATCH 10/63] Feat/add get configs route (#26) * Add get configs route * Add tests and lint --- config/facs/auth.config.json.example | 17 +- tests/unit/handlers/configs.handlers.test.js | 258 ++++++++++++++++++ tests/unit/routes/configs.routes.test.js | 55 ++++ workers/lib/constants.js | 16 +- .../lib/server/handlers/configs.handlers.js | 47 ++++ workers/lib/server/index.js | 2 + workers/lib/server/routes/configs.routes.js | 42 +++ 7 files changed, 430 insertions(+), 7 deletions(-) create mode 100644 tests/unit/handlers/configs.handlers.test.js create mode 100644 tests/unit/routes/configs.routes.test.js create mode 100644 workers/lib/server/handlers/configs.handlers.js create mode 100644 workers/lib/server/routes/configs.routes.js diff --git a/config/facs/auth.config.json.example b/config/facs/auth.config.json.example index 7dbd3ae..af4b490 100644 --- a/config/facs/auth.config.json.example +++ b/config/facs/auth.config.json.example @@ -24,7 +24,8 @@ "settings:rw", "ticket:rw", "power_spot_forecast:rw", - "pool_manager:rw" + "pool_config:rw", + "pool_config_approve:rw" ], "roles": { "admin": [ @@ -48,7 +49,8 @@ "settings:rw", "ticket:rw", "power_spot_forecast:rw", - "pool_manager:rw" + "pool_config:rw", + "pool_config_approve:rw" ], "reporting_tool_manager": [ "revenue:rw", @@ -138,8 +140,12 @@ "comments:r", "settings:r", "ticket:r", - "alerts:r", - "pool_manager:r" + "alerts:r" + ], + "pool_manager": [ + "pool_config:rw", + "miner:r", + "actions:rw" ] }, "roleManagement": { @@ -149,7 +155,8 @@ "reporting_tool_manager", "field_operator", "repair_technician", - "read_only_user" + "read_only_user", + "pool_manager" ] } } diff --git a/tests/unit/handlers/configs.handlers.test.js b/tests/unit/handlers/configs.handlers.test.js new file mode 100644 index 0000000..463255c --- /dev/null +++ b/tests/unit/handlers/configs.handlers.test.js @@ -0,0 +1,258 @@ +'use strict' + +const test = require('brittle') +const { getConfigs } = require('../../../workers/lib/server/handlers/configs.handlers') + +test('getConfigs - happy path', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'getConfigs') { + return [ + { id: 'config1', name: 'Pool Config 1', url: 'stratum://pool1.example.com' }, + { id: 'config2', name: 'Pool Config 2', url: 'stratum://pool2.example.com' } + ] + } + return [] + } + } + } + + const mockReq = { + params: { type: 'pool' }, + query: {} + } + + const result = await getConfigs(mockCtx, mockReq) + t.ok(Array.isArray(result), 'should return array') + t.is(result.length, 2, 'should have 2 configs') + t.is(result[0].id, 'config1', 'should have correct config id') + t.pass() +}) + +test('getConfigs - with query filter', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ id: 'config1', active: true }] + } + } + } + + const mockReq = { + params: { type: 'pool' }, + query: { query: '{"active":true}' } + } + + await getConfigs(mockCtx, mockReq) + t.ok(capturedPayload.query, 'should pass query in payload') + t.is(capturedPayload.query.active, true, 'should parse query JSON correctly') + t.pass() +}) + +test('getConfigs - with fields projection', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ name: 'Config 1' }] + } + } + } + + const mockReq = { + params: { type: 'pool' }, + query: { fields: '{"name":1,"url":1}' } + } + + await getConfigs(mockCtx, mockReq) + t.ok(capturedPayload.fields, 'should pass fields in payload') + t.is(capturedPayload.fields.name, 1, 'should parse fields JSON correctly') + t.is(capturedPayload.fields.url, 1, 'should include url field') + t.pass() +}) + +test('getConfigs - with both query and fields', async (t) => { + let capturedPayload = null + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ name: 'Config 1' }] + } + } + } + + const mockReq = { + params: { type: 'pool' }, + query: { + query: '{"active":true}', + fields: '{"name":1}' + } + } + + await getConfigs(mockCtx, mockReq) + t.ok(capturedPayload.query, 'should pass query in payload') + t.ok(capturedPayload.fields, 'should pass fields in payload') + t.is(capturedPayload.type, 'pool', 'should pass type in payload') + t.pass() +}) + +test('getConfigs - invalid config type throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ([]) } + } + + const mockReq = { + params: { type: 'invalid_type' }, + query: {} + } + + try { + await getConfigs(mockCtx, mockReq) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_CONFIG_TYPE_INVALID', 'should throw config type invalid error') + } + t.pass() +}) + +test('getConfigs - missing config type throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ([]) } + } + + const mockReq = { + params: {}, + query: {} + } + + try { + await getConfigs(mockCtx, mockReq) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_CONFIG_TYPE_INVALID', 'should throw config type invalid error') + } + t.pass() +}) + +test('getConfigs - invalid query JSON throws', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ([]) } + } + + const mockReq = { + params: { type: 'pool' }, + query: { query: 'not valid json' } + } + + try { + await getConfigs(mockCtx, mockReq) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_QUERY_INVALID_JSON', 'should throw query invalid JSON error') + } + t.pass() +}) + +test('getConfigs - invalid fields JSON throws', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ([]) } + } + + const mockReq = { + params: { type: 'pool' }, + query: { fields: '{invalid}' } + } + + try { + await getConfigs(mockCtx, mockReq) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_FIELDS_INVALID_JSON', 'should throw fields invalid JSON error') + } + t.pass() +}) + +test('getConfigs - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ([]) } + } + + const mockReq = { + params: { type: 'pool' }, + query: {} + } + + const result = await getConfigs(mockCtx, mockReq) + t.ok(Array.isArray(result), 'should return array') + t.is(result.length, 0, 'should be empty') + t.pass() +}) + +test('getConfigs - handles error results from orks', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => ({ error: 'timeout' }) + } + } + + const mockReq = { + params: { type: 'pool' }, + query: {} + } + + const result = await getConfigs(mockCtx, mockReq) + t.ok(Array.isArray(result), 'should return array') + t.is(result.length, 0, 'should be empty when ork returns error') + t.pass() +}) + +test('getConfigs - aggregates results from multiple orks', async (t) => { + let callCount = 0 + const mockCtx = { + conf: { + orks: [ + { rpcPublicKey: 'key1' }, + { rpcPublicKey: 'key2' } + ] + }, + net_r0: { + jRequest: async () => { + callCount++ + return [{ id: `config${callCount}`, name: `Config ${callCount}` }] + } + } + } + + const mockReq = { + params: { type: 'pool' }, + query: {} + } + + const result = await getConfigs(mockCtx, mockReq) + t.ok(Array.isArray(result), 'should return array') + t.is(result.length, 2, 'should aggregate results from both orks') + t.pass() +}) diff --git a/tests/unit/routes/configs.routes.test.js b/tests/unit/routes/configs.routes.test.js new file mode 100644 index 0000000..4c8c3c7 --- /dev/null +++ b/tests/unit/routes/configs.routes.test.js @@ -0,0 +1,55 @@ +'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/configs.routes.js' + +test('configs routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'configs') + t.pass() +}) + +test('configs routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/configs/:type'), 'should have configs route') + t.pass() +}) + +test('configs 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('configs routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'configs') + t.pass() +}) + +test('configs routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'configs') + t.pass() +}) + +test('configs routes - schema validation', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + const configsRoute = routes.find(r => r.url === '/auth/configs/:type') + + t.ok(configsRoute.schema, 'should have schema') + t.ok(configsRoute.schema.params, 'should have params schema') + t.ok(configsRoute.schema.params.properties.type, 'should have type param') + t.ok(configsRoute.schema.params.required.includes('type'), 'type should be required') + + t.ok(configsRoute.schema.querystring, 'should have querystring schema') + t.ok(configsRoute.schema.querystring.properties.query, 'should have query property') + t.ok(configsRoute.schema.querystring.properties.fields, 'should have fields property') + t.ok(configsRoute.schema.querystring.properties.overwriteCache, 'should have overwriteCache property') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 3b026dc..ac93865 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -37,7 +37,9 @@ const AUTH_PERMISSIONS = { REPORTING: 'reporting', SETTINGS: 'settings', TICKETS: 'tickets', - POWER_SPOT_FORECAST: 'power_spot_forecast' + POWER_SPOT_FORECAST: 'power_spot_forecast', + POOL_CONFIG: 'pool_config', + POOL_CONFIG_APPROVE: 'pool_config_approve' } const AUTH_LEVELS = { @@ -137,6 +139,9 @@ const ENDPOINTS = { SITE_STATUS_LIVE: '/auth/site/status/live', + // Generic Config endpoints (type passed as parameter) + // Note: Config mutations (register, update, delete) go through pushAction endpoint + CONFIGS: '/auth/configs/:type', // Device listing endpoints CONTAINERS: '/auth/containers', CABINETS: '/auth/cabinets', @@ -210,7 +215,8 @@ const RPC_METHODS = { LIST_THINGS: 'listThings', GET_HISTORICAL_LOGS: 'getHistoricalLogs', TAIL_LOG: 'tailLog', - GLOBAL_CONFIG: 'getGlobalConfig' + GLOBAL_CONFIG: 'getGlobalConfig', + GET_CONFIGS: 'getConfigs' } const WORKER_TYPES = { @@ -354,6 +360,11 @@ const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 const RPC_PAGE_LIMIT = 100 +// Allowed config types for generic config CRUD +const CONFIG_TYPES = { + POOL: 'pool' +} + module.exports = { SUPER_ADMIN_ROLE, GLOBAL_DATA_TYPES, @@ -387,6 +398,7 @@ module.exports = { NON_METRIC_KEYS, BTC_SATS, RANGE_BUCKETS, + CONFIG_TYPES, METRICS_TIME, METRICS_DEFAULTS, MINER_CATEGORIES, diff --git a/workers/lib/server/handlers/configs.handlers.js b/workers/lib/server/handlers/configs.handlers.js new file mode 100644 index 0000000..e6e996e --- /dev/null +++ b/workers/lib/server/handlers/configs.handlers.js @@ -0,0 +1,47 @@ +'use strict' + +const { requestRpcEachLimit } = require('../../utils') +const { CONFIG_TYPES } = require('../../constants') + +const VALID_CONFIG_TYPES = Object.values(CONFIG_TYPES) + +function validateConfigType (type) { + if (!type || !VALID_CONFIG_TYPES.includes(type)) { + throw new Error('ERR_CONFIG_TYPE_INVALID') + } +} + +async function getConfigs (ctx, req) { + const { type } = req.params + validateConfigType(type) + + const payload = { type } + + if (req.query.query) { + try { + payload.query = JSON.parse(req.query.query) + } catch { + throw new Error('ERR_QUERY_INVALID_JSON') + } + } + + if (req.query.fields) { + try { + payload.fields = JSON.parse(req.query.fields) + } catch { + throw new Error('ERR_FIELDS_INVALID_JSON') + } + } + + return await requestRpcEachLimit(ctx, 'getConfigs', payload, (res, resultsArray) => { + if (res.error) { + console.error(new Date().toISOString(), res.error) + } else if (Array.isArray(res)) { + resultsArray.push(...res) + } + }) +} + +module.exports = { + getConfigs +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 9a823bc..7e61145 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -12,6 +12,7 @@ const financeRoutes = require('./routes/finance.routes') const poolsRoutes = require('./routes/pools.routes') const poolManagerRoutes = require('./routes/poolManager.routes') const siteRoutes = require('./routes/site.routes') +const configsRoutes = require('./routes/configs.routes') const devicesRoutes = require('./routes/devices.routes') const metricsRoutes = require('./routes/metrics.routes') const alertsRoutes = require('./routes/alerts.routes') @@ -34,6 +35,7 @@ function routes (ctx) { ...poolsRoutes(ctx), ...poolManagerRoutes(ctx), ...siteRoutes(ctx), + ...configsRoutes(ctx), ...devicesRoutes(ctx), ...metricsRoutes(ctx), ...alertsRoutes(ctx) diff --git a/workers/lib/server/routes/configs.routes.js b/workers/lib/server/routes/configs.routes.js new file mode 100644 index 0000000..77e6920 --- /dev/null +++ b/workers/lib/server/routes/configs.routes.js @@ -0,0 +1,42 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_PERMISSIONS +} = require('../../constants') +const { getConfigs } = require('../handlers/configs.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.CONFIGS, + schema: { + params: { + type: 'object', + properties: { + type: { type: 'string' } + }, + required: ['type'] + }, + querystring: { + type: 'object', + properties: { + query: { type: 'string' }, + fields: { type: 'string' }, + overwriteCache: { type: 'boolean' } + } + } + }, + ...createCachedAuthRoute( + ctx, + (req) => ['configs', req.params.type, req.query.query, req.query.fields], + ENDPOINTS.CONFIGS, + getConfigs, + [AUTH_PERMISSIONS.POOL_CONFIG] + ) + } + ] +} From b712aa8e4176839996b2db4d4a0bf8345d699f05 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:09:11 +0530 Subject: [PATCH 11/63] Remove old pool manager apis (#29) --- tests/integration/api.poolManager.test.js | 30 ---- tests/unit/services.poolManager.test.js | 88 +---------- workers/lib/constants.js | 13 -- workers/lib/server/controllers/poolManager.js | 66 +------- .../lib/server/routes/poolManager.routes.js | 50 +----- workers/lib/server/services/poolManager.js | 146 +----------------- 6 files changed, 6 insertions(+), 387 deletions(-) diff --git a/tests/integration/api.poolManager.test.js b/tests/integration/api.poolManager.test.js index 6f786f4..a749e92 100644 --- a/tests/integration/api.poolManager.test.js +++ b/tests/integration/api.poolManager.test.js @@ -133,9 +133,6 @@ test('Pool Manager API', { timeout: 90000 }, async (main) => { if (method === 'listThings') { return Promise.resolve(mockMiners) } - if (method === 'applyThings') { - return Promise.resolve({ success: true, affected: params.query?.id?.$in?.length || 0 }) - } if (method === 'getWrkExtData') { return Promise.resolve([{ stats: [ @@ -347,31 +344,4 @@ test('Pool Manager API', { timeout: 90000 }, async (main) => { }) }) - await main.test('Api: auth/pool-manager/miners/assign', async (n) => { - const api = `${appNodeBaseUrl}/auth/pool-manager/miners/assign?${baseParams}` - const body = { - minerIds: ['miner-001', 'miner-002'] - } - - await n.test('api should fail for missing auth token', async (t) => { - try { - await httpClient.post(api, { body, encoding }) - t.fail() - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('api should fail without pool_manager permission', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - try { - await httpClient.post(api, { body, headers, encoding }) - t.fail() - } catch (e) { - t.is(e.response.message.includes('ERR_POOL_MANAGER_PERM_REQUIRED'), true) - t.pass() - } - }) - }) }) diff --git a/tests/unit/services.poolManager.test.js b/tests/unit/services.poolManager.test.js index 9778333..13d49fe 100644 --- a/tests/unit/services.poolManager.test.js +++ b/tests/unit/services.poolManager.test.js @@ -7,9 +7,7 @@ const { getPoolConfigs, getMinersWithPools, getUnitsWithPoolData, - getPoolAlerts, - assignPoolToMiners, - setPowerMode + getPoolAlerts } = require('../../workers/lib/server/services/poolManager') function createMockCtx (responseData) { @@ -371,87 +369,3 @@ test('poolManager:getPoolAlerts includes severity', async function (t) { t.is(result[0].type, 'all_pools_dead') }) -test('poolManager:assignPoolToMiners validates miner IDs', async function (t) { - const mockCtx = createMockCtx({ success: true }) - - await t.exception(async () => { - await assignPoolToMiners(mockCtx, []) - }, /ERR_MINER_IDS_REQUIRED/) -}) - -test('poolManager:assignPoolToMiners calls RPC with correct params', async function (t) { - let capturedParams - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: (pk, method, params) => { - capturedParams = params - return Promise.resolve({ success: true, affected: 2 }) - } - } - } - - const result = await assignPoolToMiners(mockCtx, ['miner-1', 'miner-2']) - - t.ok(capturedParams) - t.is(capturedParams.action, 'setupPools') - t.alike(capturedParams.query, { id: { $in: ['miner-1', 'miner-2'] } }) - t.is(capturedParams.params, undefined) - t.is(result.success, true) - t.is(result.assigned, 2) -}) - -test('poolManager:setPowerMode validates miner IDs', async function (t) { - const mockCtx = createMockCtx({ success: true }) - - await t.exception(async () => { - await setPowerMode(mockCtx, [], 'sleep') - }, /ERR_MINER_IDS_REQUIRED/) -}) - -test('poolManager:setPowerMode validates power mode', async function (t) { - const mockCtx = createMockCtx({ success: true }) - - await t.exception(async () => { - await setPowerMode(mockCtx, ['miner-1'], 'invalid-mode') - }, /ERR_INVALID_POWER_MODE/) -}) - -test('poolManager:setPowerMode calls RPC with correct params', async function (t) { - let capturedParams - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: (pk, method, params) => { - capturedParams = params - return Promise.resolve({ success: true, affected: 2 }) - } - } - } - - const result = await setPowerMode(mockCtx, ['miner-1', 'miner-2'], 'sleep') - - t.ok(capturedParams) - t.is(capturedParams.action, 'setPowerMode') - t.is(capturedParams.params.mode, 'sleep') - t.ok(result.success) - t.is(result.affected, 2) - t.is(result.mode, 'sleep') -}) - -test('poolManager:setPowerMode accepts all valid modes', async function (t) { - const validModes = ['low', 'normal', 'high', 'sleep'] - - for (const mode of validModes) { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: () => Promise.resolve({ success: true, affected: 1 }) - } - } - - const result = await setPowerMode(mockCtx, ['miner-1'], mode) - t.ok(result.success) - t.is(result.mode, mode) - } -}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index ac93865..0c484e7 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -134,9 +134,6 @@ const ENDPOINTS = { POOL_MANAGER_MINERS: '/auth/pool-manager/miners', POOL_MANAGER_UNITS: '/auth/pool-manager/units', POOL_MANAGER_ALERTS: '/auth/pool-manager/alerts', - POOL_MANAGER_ASSIGN: '/auth/pool-manager/miners/assign', - POOL_MANAGER_POWER_MODE: '/auth/pool-manager/miners/power-mode', - SITE_STATUS_LIVE: '/auth/site/status/live', // Generic Config endpoints (type passed as parameter) @@ -206,7 +203,6 @@ const STATUS_CODES = { } const LIST_THINGS = 'listThings' -const APPLY_THINGS = 'applyThings' const GET_HISTORICAL_LOGS = 'getHistoricalLogs' const RPC_METHODS = { @@ -262,13 +258,6 @@ const MINER_POOL_STATUS = { INACTIVE: 'inactive' } -const POWER_MODES = { - LOW: 'low', - NORMAL: 'normal', - HIGH: 'high', - SLEEP: 'sleep' -} - const METRICS_TIME = { ONE_DAY_MS: 24 * 60 * 60 * 1000, TWO_DAYS_MS: 2 * 24 * 60 * 60 * 1000, @@ -384,14 +373,12 @@ module.exports = { RPC_PAGE_LIMIT, USER_SETTINGS_TYPE, LIST_THINGS, - APPLY_THINGS, GET_HISTORICAL_LOGS, RPC_METHODS, WORKER_TYPES, CACHE_KEYS, POOL_ALERT_TYPES, MINER_POOL_STATUS, - POWER_MODES, AGGR_FIELDS, PERIOD_TYPES, MINERPOOL_EXT_DATA_KEYS, diff --git a/workers/lib/server/controllers/poolManager.js b/workers/lib/server/controllers/poolManager.js index b6cca73..badca8b 100644 --- a/workers/lib/server/controllers/poolManager.js +++ b/workers/lib/server/controllers/poolManager.js @@ -50,74 +50,10 @@ const getAlerts = async (ctx, req) => { } } -const assignPool = async (ctx, req) => { - const { write } = await ctx.authLib.getTokenPerms(req._info.authToken) - if (!write) { - throw new Error('ERR_WRITE_PERM_REQUIRED') - } - - const hasPoolManagerPerm = await ctx.authLib.tokenHasPerms( - req._info.authToken, - true, - ['pool_manager:rw'] - ) - if (!hasPoolManagerPerm) { - throw new Error('ERR_POOL_MANAGER_PERM_REQUIRED') - } - - const { minerIds } = req.body - - if (!minerIds || !Array.isArray(minerIds) || minerIds.length === 0) { - throw new Error('ERR_MINER_IDS_REQUIRED') - } - - const auditInfo = { - user: req._info.user?.metadata?.email || 'unknown', - timestamp: Date.now() - } - - return poolManagerService.assignPoolToMiners(ctx, minerIds, auditInfo) -} - -const setPowerMode = async (ctx, req) => { - const { write } = await ctx.authLib.getTokenPerms(req._info.authToken) - if (!write) { - throw new Error('ERR_WRITE_PERM_REQUIRED') - } - - const hasPoolManagerPerm = await ctx.authLib.tokenHasPerms( - req._info.authToken, - true, - ['pool_manager:rw'] - ) - if (!hasPoolManagerPerm) { - throw new Error('ERR_POOL_MANAGER_PERM_REQUIRED') - } - - const { minerIds, mode } = req.body - - if (!minerIds || !Array.isArray(minerIds) || minerIds.length === 0) { - throw new Error('ERR_MINER_IDS_REQUIRED') - } - - if (!mode) { - throw new Error('ERR_POWER_MODE_REQUIRED') - } - - const auditInfo = { - user: req._info.user?.metadata?.email || 'unknown', - timestamp: Date.now() - } - - return poolManagerService.setPowerMode(ctx, minerIds, mode, auditInfo) -} - module.exports = { getStats, getPools, getMiners, getUnits, - getAlerts, - assignPool, - setPowerMode + getAlerts } diff --git a/workers/lib/server/routes/poolManager.routes.js b/workers/lib/server/routes/poolManager.routes.js index ecc3cf3..5552307 100644 --- a/workers/lib/server/routes/poolManager.routes.js +++ b/workers/lib/server/routes/poolManager.routes.js @@ -5,12 +5,10 @@ const { getPools, getMiners, getUnits, - getAlerts, - assignPool, - setPowerMode + getAlerts } = require('../controllers/poolManager') const { ENDPOINTS, HTTP_METHODS } = require('../../constants') -const { createCachedAuthRoute, createAuthRoute } = require('../lib/routeHelpers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') const POOL_MANAGER_MINERS_SCHEMA = { querystring: { @@ -46,36 +44,6 @@ const POOL_MANAGER_CACHE_SCHEMA = { } } -const POOL_MANAGER_ASSIGN_SCHEMA = { - body: { - type: 'object', - properties: { - minerIds: { - type: 'array', - items: { type: 'string' } - } - }, - required: ['minerIds'] - } -} - -const POOL_MANAGER_POWER_MODE_SCHEMA = { - body: { - type: 'object', - properties: { - minerIds: { - type: 'array', - items: { type: 'string' } - }, - mode: { - type: 'string', - enum: ['low', 'normal', 'high', 'sleep'] - } - }, - required: ['minerIds', 'mode'] - } -} - module.exports = (ctx) => { return [ { @@ -147,20 +115,6 @@ module.exports = (ctx) => { ENDPOINTS.POOL_MANAGER_ALERTS, getAlerts ) - }, - - { - method: HTTP_METHODS.POST, - url: ENDPOINTS.POOL_MANAGER_ASSIGN, - schema: POOL_MANAGER_ASSIGN_SCHEMA, - ...createAuthRoute(ctx, assignPool) - }, - - { - method: HTTP_METHODS.POST, - url: ENDPOINTS.POOL_MANAGER_POWER_MODE, - schema: POOL_MANAGER_POWER_MODE_SCHEMA, - ...createAuthRoute(ctx, setPowerMode) } ] } diff --git a/workers/lib/server/services/poolManager.js b/workers/lib/server/services/poolManager.js index 21ecf44..0059448 100644 --- a/workers/lib/server/services/poolManager.js +++ b/workers/lib/server/services/poolManager.js @@ -4,15 +4,12 @@ const { LIST_THINGS, WORKER_TYPES, POOL_ALERT_TYPES, - APPLY_THINGS, - POWER_MODES, RPC_METHODS, MINERPOOL_EXT_DATA_KEYS } = require('../../constants') const { requestRpcMapLimit, - requestRpcMapAllPages, - requestRpcEachLimit + requestRpcMapAllPages } = require('../../utils') const getPoolStats = async (ctx) => { @@ -174,143 +171,6 @@ const getPoolAlerts = async (ctx, filters = {}) => { return alerts.slice(0, limit) } -const assignPoolToMiners = async (ctx, minerIds, auditInfo = {}) => { - if (!Array.isArray(minerIds) || minerIds.length === 0) { - throw new Error('ERR_MINER_IDS_REQUIRED') - } - - if (ctx.logger && auditInfo.user) { - ctx.logger.info({ - action: 'pool_assignment', - user: auditInfo.user, - timestamp: auditInfo.timestamp, - minerCount: minerIds.length - }, 'Pool setup initiated') - } - - const params = { - type: WORKER_TYPES.MINER, - query: { - id: { $in: minerIds } - }, - action: 'setupPools' - } - - const results = await requestRpcEachLimit(ctx, APPLY_THINGS, params) - - let assigned = 0 - let failed = 0 - const details = [] - - results.forEach((clusterResult) => { - if (clusterResult?.success) { - assigned += clusterResult.affected || 0 - } else { - failed++ - } - - if (clusterResult?.details) { - details.push(...clusterResult.details) - } - }) - - if (ctx.logger && auditInfo.user) { - ctx.logger.info({ - action: 'pool_assignment_complete', - user: auditInfo.user, - timestamp: Date.now(), - assigned, - failed, - total: minerIds.length - }, 'Pool assignment completed') - } - - return { - success: failed === 0, - assigned, - failed, - total: minerIds.length, - details, - audit: { - user: auditInfo.user, - timestamp: auditInfo.timestamp - } - } -} - -const setPowerMode = async (ctx, minerIds, mode, auditInfo = {}) => { - if (!Array.isArray(minerIds) || minerIds.length === 0) { - throw new Error('ERR_MINER_IDS_REQUIRED') - } - - const validModes = Object.values(POWER_MODES) - if (!mode || !validModes.includes(mode)) { - throw new Error('ERR_INVALID_POWER_MODE') - } - - if (ctx.logger && auditInfo.user) { - ctx.logger.info({ - action: 'set_power_mode', - user: auditInfo.user, - timestamp: auditInfo.timestamp, - minerCount: minerIds.length, - mode - }, 'Power mode change initiated') - } - - const params = { - type: WORKER_TYPES.MINER, - query: { - id: { $in: minerIds } - }, - action: 'setPowerMode', - params: { mode } - } - - const results = await requestRpcEachLimit(ctx, APPLY_THINGS, params) - - let affected = 0 - let failed = 0 - const details = [] - - results.forEach((clusterResult) => { - if (clusterResult?.success) { - affected += clusterResult.affected || 0 - } else { - failed++ - } - - if (clusterResult?.details) { - details.push(...clusterResult.details) - } - }) - - if (ctx.logger && auditInfo.user) { - ctx.logger.info({ - action: 'set_power_mode_complete', - user: auditInfo.user, - timestamp: Date.now(), - affected, - failed, - total: minerIds.length, - mode - }, 'Power mode change completed') - } - - return { - success: failed === 0, - affected, - failed, - total: minerIds.length, - mode, - details, - audit: { - user: auditInfo.user, - timestamp: auditInfo.timestamp - } - } -} - async function _fetchPoolStats (ctx) { const results = await requestRpcMapLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { type: 'minerpool', @@ -403,7 +263,5 @@ module.exports = { getPoolConfigs, getMinersWithPools, getUnitsWithPoolData, - getPoolAlerts, - assignPoolToMiners, - setPowerMode + getPoolAlerts } From e47b962d8c86dc5b0fee8ea8de5ebab3adfaf7e1 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Tue, 10 Mar 2026 02:03:10 +0300 Subject: [PATCH 12/63] feat: add GET /auth/finance/hash-revenue endpoint (#27) --- tests/unit/handlers/finance.handlers.test.js | 223 +++++++++++++++- tests/unit/routes/finance.routes.test.js | 1 + workers/lib/constants.js | 1 + .../lib/server/handlers/finance.handlers.js | 246 ++++++++++++++++++ workers/lib/server/routes/finance.routes.js | 21 +- workers/lib/server/schemas/finance.schemas.js | 10 + 6 files changed, 500 insertions(+), 2 deletions(-) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 71549c5..9fa0341 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -18,7 +18,11 @@ const { getRevenue, calculateRevenueSummary, getRevenueSummary, - calculateDetailedRevenueSummary + calculateDetailedRevenueSummary, + getHashRevenue, + processHashrateData, + processNetworkHashrateData, + calculateHashRevenueSummary } = require('../../../workers/lib/server/handlers/finance.handlers') // ==================== Energy Balance Tests ==================== @@ -862,3 +866,220 @@ test('calculateDetailedRevenueSummary - handles empty log', (t) => { t.is(summary.currentBtcPrice, 42000, 'should include current price') t.pass() }) + +// ==================== Hash Revenue Tests ==================== + +test('getHashRevenue - happy path', async (t) => { + const dayTs = 1700006400000 + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ data: { [dayTs]: { hashrate_mhs_5m_sum_aggr: 500000000 } } }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return [{ transactions: [{ ts: dayTs, changed_balance: 0.5, mining_extra: { tx_fee: 0.001 } }] }] + } + if (payload.query && payload.query.key === 'prices') { + return [{ prices: [{ ts: dayTs, price: 40000 }] }] + } + if (payload.query && payload.query.key === 'current_price') { + return [{ currentPrice: 40000 }] + } + if (payload.query && payload.query.key === 'HISTORICAL_HASHRATE') { + return [{ data: [{ ts: dayTs, avgHashrateMHs: 500000000000000 }] }] + } + } + return {} + } + } + } + + const mockReq = { + query: { start: 1700000000000, end: 1700100000000, period: 'daily' } + } + + const result = await getHashRevenue(mockCtx, mockReq, {}) + t.ok(result.log, 'should return log array') + t.ok(result.summary, 'should return summary') + t.ok(Array.isArray(result.log), 'log should be array') + if (result.log.length > 0) { + const entry = result.log[0] + t.ok(entry.revenueBTC !== undefined, 'entry should have revenueBTC') + t.ok(entry.feesBTC !== undefined, 'entry should have feesBTC') + t.ok(entry.revenueUSD !== undefined, 'entry should have revenueUSD') + t.ok(entry.hashRevenueBTCPerPHsPerDay !== undefined, 'entry should have hashRevenueBTCPerPHsPerDay') + t.ok(entry.hashRevenueUSDPerPHsPerDay !== undefined, 'entry should have hashRevenueUSDPerPHsPerDay') + t.ok(entry.hashCostBTCPerPHsPerDay !== undefined, 'entry should have hashCostBTCPerPHsPerDay') + t.ok(entry.hashCostUSDPerPHsPerDay !== undefined, 'entry should have hashCostUSDPerPHsPerDay') + t.ok(entry.networkHashPriceBTCPerPHsPerDay !== undefined, 'entry should have networkHashPriceBTCPerPHsPerDay') + t.ok(entry.networkHashPriceUSDPerPHsPerDay !== undefined, 'entry should have networkHashPriceUSDPerPHsPerDay') + t.ok(entry.networkHashrateMhs !== undefined, 'entry should have networkHashrateMhs') + } + t.pass() +}) + +test('getHashRevenue - missing start throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getHashRevenue(mockCtx, { query: { end: 1700100000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') + } + t.pass() +}) + +test('getHashRevenue - invalid range throws', async (t) => { + const mockCtx = { + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + } + + try { + await getHashRevenue(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') + } + t.pass() +}) + +test('getHashRevenue - empty ork results', async (t) => { + const mockCtx = { + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + } + + const result = await getHashRevenue(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) + t.ok(result.log, 'should return log array') + t.is(result.log.length, 0, 'log should be empty') + t.pass() +}) + +test('processHashrateData - processes object-keyed data', (t) => { + const results = [ + [{ data: { 1700006400000: { hashrate_mhs_5m_sum_aggr: 500000 } } }] + ] + + const daily = processHashrateData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key], 500000, 'should extract hashrate from val') + t.pass() +}) + +test('processHashrateData - processes array data', (t) => { + const results = [ + [{ data: [{ ts: 1700006400000, hashrate_mhs_5m_sum_aggr: 500000 }] }] + ] + + const daily = processHashrateData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + t.pass() +}) + +test('processHashrateData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processHashrateData(results) + t.is(Object.keys(daily).length, 0, 'should be empty for errors') + t.pass() +}) + +test('processNetworkHashrateData - processes array data', (t) => { + const results = [ + [{ data: [{ ts: 1700006400000, avgHashrateMHs: 500000000000000 }] }] + ] + + const daily = processNetworkHashrateData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + const key = Object.keys(daily)[0] + t.is(daily[key], 500000000000000, 'should extract avgHashrateMHs') + t.pass() +}) + +test('processNetworkHashrateData - processes object-keyed data', (t) => { + const results = [ + [{ data: { 1700006400000: { avgHashrateMHs: 500000000000000 } } }] + ] + + const daily = processNetworkHashrateData(results) + t.ok(typeof daily === 'object', 'should return object') + t.ok(Object.keys(daily).length > 0, 'should have entries') + t.pass() +}) + +test('processNetworkHashrateData - handles error results', (t) => { + const results = [{ error: 'timeout' }] + const daily = processNetworkHashrateData(results) + t.is(Object.keys(daily).length, 0, 'should be empty for errors') + t.pass() +}) + +test('calculateHashRevenueSummary - calculates from log entries', (t) => { + const log = [ + { + revenueBTC: 0.5, + revenueUSD: 20000, + feesBTC: 0.01, + feesUSD: 400, + hashRevenueBTCPerPHsPerDay: 0.001, + hashRevenueUSDPerPHsPerDay: 40, + hashCostBTCPerPHsPerDay: 0.00002, + hashCostUSDPerPHsPerDay: 0.8, + networkHashPriceBTCPerPHsPerDay: 0.0005, + networkHashPriceUSDPerPHsPerDay: 20 + }, + { + revenueBTC: 0.3, + revenueUSD: 12600, + feesBTC: 0.005, + feesUSD: 210, + hashRevenueBTCPerPHsPerDay: 0.0008, + hashRevenueUSDPerPHsPerDay: 33.6, + hashCostBTCPerPHsPerDay: 0.00001, + hashCostUSDPerPHsPerDay: 0.42, + networkHashPriceBTCPerPHsPerDay: 0.0004, + networkHashPriceUSDPerPHsPerDay: 16.8 + } + ] + + const summary = calculateHashRevenueSummary(log) + t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue') + t.is(summary.totalRevenueUSD, 32600, 'should sum USD revenue') + t.is(summary.totalFeesBTC, 0.015, 'should sum fees BTC') + t.is(summary.totalFeesUSD, 610, 'should sum fees USD') + t.ok(summary.avgHashRevenueBTCPerPHsPerDay !== null, 'should calculate avg hash revenue BTC') + t.ok(summary.avgHashRevenueUSDPerPHsPerDay !== null, 'should calculate avg hash revenue USD') + t.ok(summary.avgHashCostBTCPerPHsPerDay !== null, 'should calculate avg hash cost BTC') + t.ok(summary.avgHashCostUSDPerPHsPerDay !== null, 'should calculate avg hash cost USD') + t.ok(summary.avgNetworkHashPriceBTCPerPHsPerDay !== null, 'should calculate avg network hash price BTC') + t.ok(summary.avgNetworkHashPriceUSDPerPHsPerDay !== null, 'should calculate avg network hash price USD') + t.pass() +}) + +test('calculateHashRevenueSummary - handles empty log', (t) => { + const summary = calculateHashRevenueSummary([]) + t.is(summary.totalRevenueBTC, 0, 'should be zero') + t.is(summary.totalRevenueUSD, 0, 'should be zero') + t.is(summary.totalFeesBTC, 0, 'should be zero') + t.is(summary.totalFeesUSD, 0, 'should be zero') + t.is(summary.avgHashRevenueBTCPerPHsPerDay, null, 'should be null') + t.is(summary.avgHashRevenueUSDPerPHsPerDay, null, 'should be null') + t.is(summary.avgHashCostBTCPerPHsPerDay, null, 'should be null') + t.is(summary.avgHashCostUSDPerPHsPerDay, null, 'should be null') + t.is(summary.avgNetworkHashPriceBTCPerPHsPerDay, null, 'should be null') + t.is(summary.avgNetworkHashPriceUSDPerPHsPerDay, null, 'should be null') + t.pass() +}) diff --git a/tests/unit/routes/finance.routes.test.js b/tests/unit/routes/finance.routes.test.js index 08505a4..ad7ca9f 100644 --- a/tests/unit/routes/finance.routes.test.js +++ b/tests/unit/routes/finance.routes.test.js @@ -21,6 +21,7 @@ test('finance routes - route definitions', (t) => { t.ok(routeUrls.includes('/auth/finance/subsidy-fees'), 'should have subsidy-fees route') t.ok(routeUrls.includes('/auth/finance/revenue'), 'should have revenue route') t.ok(routeUrls.includes('/auth/finance/revenue-summary'), 'should have revenue-summary route') + t.ok(routeUrls.includes('/auth/finance/hash-revenue'), 'should have hash-revenue route') t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 0c484e7..b6227c0 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -120,6 +120,7 @@ const ENDPOINTS = { FINANCE_SUBSIDY_FEES: '/auth/finance/subsidy-fees', FINANCE_REVENUE: '/auth/finance/revenue', FINANCE_REVENUE_SUMMARY: '/auth/finance/revenue-summary', + FINANCE_HASH_REVENUE: '/auth/finance/hash-revenue', // Pools endpoints POOLS: '/auth/pools', diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index 20eb291..aecfc36 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -972,6 +972,248 @@ function calculateDetailedRevenueSummary (log, currentBtcPrice) { } } +// ==================== Hash Revenue ==================== + +async function getHashRevenue (ctx, req) { + const { start, end } = validateStartEnd(req) + const period = req.query.period || PERIOD_TYPES.DAILY + + const startDate = new Date(start).toISOString() + const endDate = new Date(end).toISOString() + + const [ + transactionResults, + tailLogResults, + priceResults, + currentPriceResults, + networkHashrateResults + ] = await runParallel([ + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MINERPOOL, + query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + keys: [{ + type: WORKER_TYPES.MINER, + startDate, + endDate, + fields: { [AGGR_FIELDS.HASHRATE_SUM]: 1 }, + shouldReturnDailyData: 1 + }] + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'prices', start, end } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'current_price' } + }).then(r => cb(null, r)).catch(cb), + + (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + type: WORKER_TYPES.MEMPOOL, + query: { key: 'HISTORICAL_HASHRATE', start, end } + }).then(r => cb(null, r)).catch(cb) + ]) + + const dailyTransactions = processTransactions(transactionResults, { trackFees: true }) + const dailyHashrate = processHashrateData(tailLogResults) + const dailyPrices = processEbitdaPrices(priceResults) + const currentBtcPrice = extractCurrentPrice(currentPriceResults) + const dailyNetworkHashrate = processNetworkHashrateData(networkHashrateResults) + + const allDays = new Set([ + ...Object.keys(dailyTransactions), + ...Object.keys(dailyHashrate) + ]) + + const log = [] + for (const dayTs of [...allDays].sort()) { + const ts = Number(dayTs) + const transactions = dailyTransactions[dayTs] || {} + const btcPrice = dailyPrices[dayTs] || currentBtcPrice || 0 + + const revenueBTC = transactions.revenueBTC || 0 + const feesBTC = transactions.feesBTC || 0 + const revenueUSD = revenueBTC * btcPrice + const feesUSD = feesBTC * btcPrice + const hashrateMhs = dailyHashrate[dayTs] || 0 + const hashratePhs = hashrateMhs / 1e9 + const networkHashrateMhs = dailyNetworkHashrate[dayTs] || 0 + const networkHashratePhs = networkHashrateMhs / 1e9 + + log.push({ + ts, + revenueBTC, + feesBTC, + revenueUSD, + feesUSD, + btcPrice, + hashrateMhs, + hashRevenueBTCPerPHsPerDay: safeDiv(revenueBTC, hashratePhs), + hashRevenueUSDPerPHsPerDay: safeDiv(revenueUSD, hashratePhs), + hashCostBTCPerPHsPerDay: safeDiv(feesBTC, hashratePhs), + hashCostUSDPerPHsPerDay: safeDiv(feesUSD, hashratePhs), + networkHashPriceBTCPerPHsPerDay: safeDiv(revenueBTC, networkHashratePhs), + networkHashPriceUSDPerPHsPerDay: safeDiv(revenueUSD, networkHashratePhs), + networkHashrateMhs + }) + } + + const aggregated = aggregateByPeriod(log, period) + const summary = calculateHashRevenueSummary(aggregated) + + return { log: aggregated, summary } +} + +function processHashrateData (results) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry.items || entry + if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!ts) continue + if (!daily[ts]) daily[ts] = 0 + if (typeof val === 'object') { + daily[ts] += (val[AGGR_FIELDS.HASHRATE_SUM] || 0) + } + } + } else if (Array.isArray(items)) { + for (const item of items) { + const ts = getStartOfDay(item.ts || item.timestamp) + if (!ts) continue + if (!daily[ts]) daily[ts] = 0 + daily[ts] += (item[AGGR_FIELDS.HASHRATE_SUM] || 0) + } + } + } + } + return daily +} + +function processNetworkHashrateData (results) { + const daily = {} + for (const res of results) { + if (!res || res.error) continue + const data = Array.isArray(res) ? res : (res.data || res.result || []) + if (!Array.isArray(data)) continue + for (const entry of data) { + if (!entry) continue + const items = entry.data || entry + if (Array.isArray(items)) { + for (const item of items) { + if (!item) continue + const rawTs = item.ts || item.timestamp || item.time + const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + if (!ts) continue + if (item.avgHashrateMHs) { + daily[ts] = item.avgHashrateMHs + } + } + } else if (typeof items === 'object' && !Array.isArray(items)) { + for (const [key, val] of Object.entries(items)) { + const ts = getStartOfDay(Number(key)) + if (!ts) continue + if (typeof val === 'object' && val.avgHashrateMHs) { + daily[ts] = val.avgHashrateMHs + } else if (typeof val === 'number') { + daily[ts] = val + } + } + } + } + } + return daily +} + +function calculateHashRevenueSummary (log) { + if (!log.length) { + return { + avgHashRevenueBTCPerPHsPerDay: null, + avgHashRevenueUSDPerPHsPerDay: null, + avgHashCostBTCPerPHsPerDay: null, + avgHashCostUSDPerPHsPerDay: null, + avgNetworkHashPriceBTCPerPHsPerDay: null, + avgNetworkHashPriceUSDPerPHsPerDay: null, + totalRevenueBTC: 0, + totalRevenueUSD: 0, + totalFeesBTC: 0, + totalFeesUSD: 0 + } + } + + const totals = log.reduce((acc, entry) => { + acc.revenueBTC += entry.revenueBTC || 0 + acc.revenueUSD += entry.revenueUSD || 0 + acc.feesBTC += entry.feesBTC || 0 + acc.feesUSD += entry.feesUSD || 0 + if (entry.hashRevenueBTCPerPHsPerDay !== null && entry.hashRevenueBTCPerPHsPerDay !== undefined) { + acc.hashRevBTCSum += entry.hashRevenueBTCPerPHsPerDay + acc.hashRevBTCCount++ + } + if (entry.hashRevenueUSDPerPHsPerDay !== null && entry.hashRevenueUSDPerPHsPerDay !== undefined) { + acc.hashRevUSDSum += entry.hashRevenueUSDPerPHsPerDay + acc.hashRevUSDCount++ + } + if (entry.hashCostBTCPerPHsPerDay !== null && entry.hashCostBTCPerPHsPerDay !== undefined) { + acc.hashCostBTCSum += entry.hashCostBTCPerPHsPerDay + acc.hashCostBTCCount++ + } + if (entry.hashCostUSDPerPHsPerDay !== null && entry.hashCostUSDPerPHsPerDay !== undefined) { + acc.hashCostUSDSum += entry.hashCostUSDPerPHsPerDay + acc.hashCostUSDCount++ + } + if (entry.networkHashPriceBTCPerPHsPerDay !== null && entry.networkHashPriceBTCPerPHsPerDay !== undefined) { + acc.netHashBTCSum += entry.networkHashPriceBTCPerPHsPerDay + acc.netHashBTCCount++ + } + if (entry.networkHashPriceUSDPerPHsPerDay !== null && entry.networkHashPriceUSDPerPHsPerDay !== undefined) { + acc.netHashUSDSum += entry.networkHashPriceUSDPerPHsPerDay + acc.netHashUSDCount++ + } + return acc + }, { + revenueBTC: 0, + revenueUSD: 0, + feesBTC: 0, + feesUSD: 0, + hashRevBTCSum: 0, + hashRevBTCCount: 0, + hashRevUSDSum: 0, + hashRevUSDCount: 0, + hashCostBTCSum: 0, + hashCostBTCCount: 0, + hashCostUSDSum: 0, + hashCostUSDCount: 0, + netHashBTCSum: 0, + netHashBTCCount: 0, + netHashUSDSum: 0, + netHashUSDCount: 0 + }) + + return { + avgHashRevenueBTCPerPHsPerDay: safeDiv(totals.hashRevBTCSum, totals.hashRevBTCCount), + avgHashRevenueUSDPerPHsPerDay: safeDiv(totals.hashRevUSDSum, totals.hashRevUSDCount), + avgHashCostBTCPerPHsPerDay: safeDiv(totals.hashCostBTCSum, totals.hashCostBTCCount), + avgHashCostUSDPerPHsPerDay: safeDiv(totals.hashCostUSDSum, totals.hashCostUSDCount), + avgNetworkHashPriceBTCPerPHsPerDay: safeDiv(totals.netHashBTCSum, totals.netHashBTCCount), + avgNetworkHashPriceUSDPerPHsPerDay: safeDiv(totals.netHashUSDSum, totals.netHashUSDCount), + totalRevenueBTC: totals.revenueBTC, + totalRevenueUSD: totals.revenueUSD, + totalFeesBTC: totals.feesBTC, + totalFeesUSD: totals.feesUSD + } +} + // ==================== Shared ==================== async function getProductionCosts (ctx, start, end) { @@ -1012,6 +1254,7 @@ module.exports = { getSubsidyFees, getRevenue, getRevenueSummary, + getHashRevenue, getProductionCosts, processConsumptionData, processPriceData, @@ -1026,6 +1269,9 @@ module.exports = { calculateSubsidyFeesSummary, calculateRevenueSummary, calculateDetailedRevenueSummary, + processHashrateData, + processNetworkHashrateData, + calculateHashRevenueSummary, // Re-export from finance.utils validateStartEnd, normalizeTimestampMs, diff --git a/workers/lib/server/routes/finance.routes.js b/workers/lib/server/routes/finance.routes.js index e31b4a0..107f435 100644 --- a/workers/lib/server/routes/finance.routes.js +++ b/workers/lib/server/routes/finance.routes.js @@ -10,7 +10,8 @@ const { getCostSummary, getSubsidyFees, getRevenue, - getRevenueSummary + getRevenueSummary, + getHashRevenue } = require('../handlers/finance.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') @@ -126,6 +127,24 @@ module.exports = (ctx) => { ENDPOINTS.FINANCE_REVENUE_SUMMARY, getRevenueSummary ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.FINANCE_HASH_REVENUE, + schema: { + querystring: schemas.query.hashRevenue + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'finance/hash-revenue', + req.query.start, + req.query.end, + req.query.period + ], + ENDPOINTS.FINANCE_HASH_REVENUE, + getHashRevenue + ) } ] } diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index bde6d99..e6e5c7b 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -62,6 +62,16 @@ const schemas = { overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] + }, + hashRevenue: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] } } } From 24372e32f27ce8cf3d1120b3230a42984e015768 Mon Sep 17 00:00:00 2001 From: tekwani Date: Tue, 10 Mar 2026 20:18:14 +0530 Subject: [PATCH 13/63] non rpc method calls to ork (#28) --- config/facs/auth.config.json.example | 2 +- config/facs/httpd-oauth2.config.json.example | 4 +- package-lock.json | 355 ++++++++++-------- tests/unit/handlers/actions.handlers.test.js | 42 +-- tests/unit/handlers/finance.handlers.test.js | 105 +++--- tests/unit/handlers/global.handlers.test.js | 13 +- tests/unit/handlers/metrics.handlers.test.js | 157 ++++---- tests/unit/handlers/pools.handlers.test.js | 41 +- tests/unit/handlers/site.handlers.test.js | 5 +- tests/unit/helpers/mockHelpers.js | 64 +++- tests/unit/lib/alerts.test.js | 54 +-- tests/unit/services.poolManager.test.js | 5 +- workers/http.node.wrk.js | 7 +- workers/lib/alerts.js | 17 +- workers/lib/auth.js | 3 - workers/lib/data.proxy.js | 93 +++++ .../lib/server/handlers/actions.handlers.js | 16 +- .../lib/server/handlers/alerts.handlers.js | 6 +- .../lib/server/handlers/finance.handlers.js | 47 ++- .../lib/server/handlers/global.handlers.js | 6 +- workers/lib/server/handlers/logs.handlers.js | 14 +- .../lib/server/handlers/metrics.handlers.js | 22 +- workers/lib/server/handlers/pools.handlers.js | 8 +- workers/lib/server/handlers/site.handlers.js | 8 +- .../lib/server/handlers/things.handlers.js | 16 +- workers/lib/server/services/poolManager.js | 12 +- 26 files changed, 652 insertions(+), 470 deletions(-) create mode 100644 workers/lib/data.proxy.js diff --git a/config/facs/auth.config.json.example b/config/facs/auth.config.json.example index af4b490..3e72947 100644 --- a/config/facs/auth.config.json.example +++ b/config/facs/auth.config.json.example @@ -1,6 +1,6 @@ { "a0": { - "superAdmin": "", + "superAdmin": "test@localhost", "ttl": 86400, "saltRounds": 10, "superAdminPerms": [ diff --git a/config/facs/httpd-oauth2.config.json.example b/config/facs/httpd-oauth2.config.json.example index 7ef276c..9a80f68 100644 --- a/config/facs/httpd-oauth2.config.json.example +++ b/config/facs/httpd-oauth2.config.json.example @@ -10,8 +10,6 @@ "startRedirectPath": "/oauth/google", "callbackUri": "http://localhost:3000/oauth/google/callback", "callbackUriUI": "http://localhost:3030", - "users": [ - {"email": "", "write": false} - ] + "users": [] } } diff --git a/package-lock.json b/package-lock.json index 1632f76..588ded6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,8 +56,9 @@ } }, "node_modules/@bitfinexcom/lib-js-util-base": { - "version": "1.22.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#a1cd34d5a6add343f6c322779a307db54364fcc0", + "name": "@bitfinex/lib-js-util-base", + "version": "2.0.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#8c06f3a377e62ae556f19dead07489475acd4bce", "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { @@ -157,9 +158,9 @@ } }, "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -291,7 +292,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -308,9 +309,9 @@ } }, "node_modules/@fastify/static/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -467,10 +468,10 @@ } }, "node_modules/@isaacs/fs-minipass/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -728,12 +729,11 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -800,9 +800,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -834,9 +834,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1109,9 +1109,9 @@ } }, "node_modules/autobase": { - "version": "7.25.1", - "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.25.1.tgz", - "integrity": "sha512-hYNv0Hdo4ADF91xyoFMsyPSqLwV/trQ2FGhYnBMWzsoB2jesgQ3lYMc3AeoYjFJueWbWK308x9/QdQnQEXZk8w==", + "version": "7.26.1", + "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.26.1.tgz", + "integrity": "sha512-h3jTF9arKyxyqkFTgRY/5rKb3uJZP7eCRC8FPxe1e9g9L+9vbwNlahphgqJS0O3yqDQieOYhovdo7Z+nVrL/xw==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.1", @@ -1165,9 +1165,9 @@ } }, "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -1184,10 +1184,17 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-abort": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/bare-abort/-/bare-abort-2.0.13.tgz", + "integrity": "sha512-zdc8l88eB11Jsz5rDd6sCAgv2kUFXgdrZWoMlgU6JMkfAi1/uuGFC3IEHswKbIRQTk5H3T5CMuechsXYxiaHlQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/bare-addon-resolve": { - "version": "1.9.6", - "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.9.6.tgz", - "integrity": "sha512-hvOQY1zDK6u0rSr27T6QlULoVLwi8J2k8HHHJlxSfT7XQdQ/7bsS+AnjYkHtu/TkL+gm3aMXAKucJkJAbrDG/g==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", + "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==", "license": "Apache-2.0", "dependencies": { "bare-module-resolve": "^1.10.0", @@ -1267,12 +1274,13 @@ } }, "node_modules/bare-crypto": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.0.tgz", - "integrity": "sha512-RQl13yD+YTACWUZHMMck0C6LNjXgGgRyhVC557ag0xIrkxXf4IQwpnc8WOB58f4k5kSzKhIjy05oW3HUBrFpSQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.4.tgz", + "integrity": "sha512-JiCZ5l2YOG1y8J7yy1BCAKTCZrPnHLb7pDRIdurBTOn5oIwBQDIv8iH5Pl2V85vzjl1NZXRfNY4HZLsE942jJA==", "dev": true, "license": "Apache-2.0", "dependencies": { + "bare-assert": "^1.2.0", "bare-stream": "^2.6.3" }, "peerDependencies": { @@ -1305,9 +1313,9 @@ } }, "node_modules/bare-encoding": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/bare-encoding/-/bare-encoding-1.0.0.tgz", - "integrity": "sha512-9T5CSCaytaIWZpFWx9LQLJ6/z/m2Slnan9tQBKmOvoq/UtPBbOKT/B2fo29Xhi4X1FFtNx8DFdtrFgqm2yse/Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bare-encoding/-/bare-encoding-1.0.3.tgz", + "integrity": "sha512-Kqf+t/azs13lUeyK4Tb7ha4wdLRXKWCXQ8w1rVmt7KtoPCPdHD/Xwt7LBIsCSwwGglrcmblo5VOLa5avkJqULA==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -1354,9 +1362,9 @@ } }, "node_modules/bare-fs": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", - "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -1541,9 +1549,9 @@ } }, "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz", + "integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==", "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -1559,9 +1567,9 @@ } }, "node_modules/bare-pipe": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.1.2.tgz", - "integrity": "sha512-btXtZLlABEDRp50cfLj9iweISqAJSNMCjeq5v0v9tBY2a7zSSqmfa2ZoE1ki2qxAvubagLUqw6VDifpsuI/qmg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.1.3.tgz", + "integrity": "sha512-DqQsx93rAzre6yJ9T6l/Vgh+X+bntkVMB1X5ggtXjXtqtMmF2Y2RVlCzxxy/09R6yeR9FSWBEUIaMYJL1/5VDA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1573,16 +1581,17 @@ } }, "node_modules/bare-process": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.2.2.tgz", - "integrity": "sha512-ikzEw+HGLB+2lS/WLVEsqi8BXBwzleG3n7DYI0v6/YNklvNabOIGlLd1dof+7Jw5ob4jsBRPtyflweVahY7rFA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.3.0.tgz", + "integrity": "sha512-HwtAdKbqS98V+jeWAagUdzkrJR4vtuPeeWlsoeD1YKTqM1hKUWM2eU+l2diUYeTqFfxY9b6J9SsBLuBukH0tMQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "bare-abort": "^2.0.13", "bare-env": "^3.0.0", "bare-events": "^2.3.1", "bare-hrtime": "^2.0.0", - "bare-os": "^3.5.0", + "bare-os": "^3.7.1", "bare-pipe": "^4.0.0", "bare-signals": "^4.0.0", "bare-tty": "^5.0.0" @@ -1609,12 +1618,13 @@ } }, "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", + "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", "license": "Apache-2.0", "dependencies": { - "streamx": "^2.21.0" + "streamx": "^2.21.0", + "teex": "^1.0.1" }, "peerDependencies": { "bare-buffer": "*", @@ -1655,9 +1665,9 @@ } }, "node_modules/bare-tcp": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.2.tgz", - "integrity": "sha512-bYnw1AhzGlfLOD4nTceUXkhhgznZKvDuwjX1Au0VWaVitwqG40oaTvvhEQVCcK3FEwjRTiukUzHnAFsYXUI+3Q==", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.7.tgz", + "integrity": "sha512-rjpqNQ2cOCkNo3NeYA/W4GTK3DRkl8sDHO3uos+AEswUjLC8XXMQF8WrJCSjlIowCbS6NUVxKE92X5RGXjyefg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1670,9 +1680,9 @@ } }, "node_modules/bare-tls": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-2.1.7.tgz", - "integrity": "sha512-h6wcNXQdBeTX7fed9tjPp0/9cA/QfcBTv3ItgjnbUk4rWAU8bEFalZCZnUDdCK/t9zrNfJ+yvcPx4D/1Y6biyA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-2.2.1.tgz", + "integrity": "sha512-hZ+ZqwrUO4dyH7/6WYkYWjgAFNJKjzwEYJiDaMnMs+eRleBDjQ3CvNZawpkw0Ar9jnM9NZK6+f6GqjkZ2FLGmQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1713,7 +1723,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -1751,9 +1760,9 @@ } }, "node_modules/bare-ws": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/bare-ws/-/bare-ws-2.0.4.tgz", - "integrity": "sha512-SQMXzBYna9dRj57Dz1/ag+VWHCRXbfCjMHgyfM2F2lhkVLzMjnVSZP72aVeFWPFqe494Rd70Kzhe2JElGwFlJQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bare-ws/-/bare-ws-2.1.0.tgz", + "integrity": "sha512-2gEWlPK9iyBchACdIY6oQXgmDz3KLrChdwrPgmU3IVXOFTnxTqSUT27WE/+izd4QojHj/SsqDkQiD2HDvuTdAA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1907,8 +1916,9 @@ } }, "node_modules/bfx-svc-boot-js": { - "version": "1.0.2", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-svc-boot-js.git#6ae412ee70ed62a5ff5047db7298e9a2be2e3ac7", + "name": "@bitfinex/bfx-svc-boot-js", + "version": "1.1.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-svc-boot-js.git#be28ca5387eef34e651042e64d278feedf9b3a61", "license": "Apache-2.0", "dependencies": { "lodash": "^4.17.21", @@ -1967,9 +1977,9 @@ } }, "node_modules/bogon": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.1.0.tgz", - "integrity": "sha512-a6SnToksXHuUlgeMvI/txWmTcKz7c7iBa8f0HbXL4toN1Uza/CTQ4F7n9jSDX49TCpxv3KUP100q4sZfwLyLiw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.2.0.tgz", + "integrity": "sha512-FqOBr/1VMzCOsoJd+fzNUMarUYki2+TKt07A2+xaulsNx4r53iJ7MV5k0jbqg7W2U0CsLqxCZOrFibdG6h6HSg==", "license": "MIT", "dependencies": { "compact-encoding": "^2.11.0", @@ -2235,9 +2245,9 @@ } }, "node_modules/compact-encoding": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.18.0.tgz", - "integrity": "sha512-goACAOlhMI2xo5jGOMUDfOLnGdRE1jGfyZ+zie8N5114nHrbPIqf6GLUtzbLof6DSyrERlYRm3EcBplte5LcQw==", + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.0.tgz", + "integrity": "sha512-bPQlzwxgzsuOp0wB6G9TVoZ2tGFANJckQfM10xLKUS2fbLe+fFZG6Gi1wYehcMMTcjvtil8oOJSsToMOlCSu4g==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.3.0" @@ -2340,9 +2350,9 @@ } }, "node_modules/corestore": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.7.0.tgz", - "integrity": "sha512-o1ZZFSjcUtIE6FFIsNJB3k1gv7AyVEYFmgFscdew6oSgSbWn4T5xBGT7L06lZNkwXTuYlsy56A+TPZvptLS4Rg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.8.0.tgz", + "integrity": "sha512-zC9FNHoVzPXiFetVN39QpwJkNtJkqm5jymx7wY+NB6G6cpruEpYjd0/KuDpBtgFK6/JG1l+Qr+DV9ozmz31Dkw==", "license": "MIT", "dependencies": { "b4a": "^1.6.7", @@ -2563,9 +2573,9 @@ } }, "node_modules/dht-rpc": { - "version": "6.24.2", - "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.24.2.tgz", - "integrity": "sha512-XcYHF3pXP1NyoNOxoTjbwiIjysNUWF5OHO8qfBQhn6kUL4Gbu25w1iJz6nEK2NyebqVI/6wog3FPHKBwiGWEgA==", + "version": "6.26.3", + "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.26.3.tgz", + "integrity": "sha512-KuLfRv/hecUHipQcTXHpVv4/N4Jhpww5sLdsrn3Edm5oHwzK9SgNV34hNt7a2aVZCOcG5SfP4AvcQ7pI+y9YNg==", "license": "MIT", "dependencies": { "adaptive-timeout": "^1.0.1", @@ -2893,7 +2903,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3168,7 +3177,6 @@ "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -3195,7 +3203,6 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -3253,19 +3260,25 @@ } }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3475,9 +3488,9 @@ } }, "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3684,9 +3697,9 @@ "license": "MIT" }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", "dev": true, "license": "ISC" }, @@ -3947,7 +3960,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -4162,7 +4175,7 @@ }, "node_modules/hp-svc-facs-net": { "version": "0.0.1", - "resolved": "git+ssh://git@github.com/tetherto/hp-svc-facs-net.git#bf88bf554c0e9f49ceb2f2eb64d927d9d79b19fb", + "resolved": "git+ssh://git@github.com/tetherto/hp-svc-facs-net.git#9879be0a2dc9614003900e3d2eeac237dfdd50c6", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", @@ -4367,9 +4380,9 @@ } }, "node_modules/hypercore": { - "version": "11.23.1", - "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.23.1.tgz", - "integrity": "sha512-tdNKh/YhqNZz/plt9kmreWyrozEw1h2bta/LrjllSyejvUeFFCqTYxiQ1Slf3C5swHKo3y+AWI5v3RYhhSnQuA==", + "version": "11.27.5", + "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.27.5.tgz", + "integrity": "sha512-/XDAhdSd0/giA/bChonghQSsM7heyBlI52nlpaoEBDkE8dX3h7LqayZC9633qr2CG0Zbzd+WYbHL13H6v48tuA==", "license": "MIT", "dependencies": { "@hyperswarm/secret-stream": "^6.0.0", @@ -4426,9 +4439,9 @@ } }, "node_modules/hypercore-storage": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.4.1.tgz", - "integrity": "sha512-VbdkPdj7iYkGX4mufUIPqmkk87PnJ5csi1Y5WGkMHAMneK/6ogIoGrBRmAH4jpFfagOsLnSN33rHmmq6OyM77g==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.5.5.tgz", + "integrity": "sha512-0W74LFPKhj5XBHnTmzg69PI2m3qYFZ04wycgHqyysnIYopiWZP7ka26yCjf+2UTStgP45PYugHEo9J03Pz3H1A==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.7", @@ -4447,9 +4460,9 @@ } }, "node_modules/hyperdht": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.28.0.tgz", - "integrity": "sha512-roUPpAF4ktS4pA10G1XjcWLamrOhfJd4R7U1e7W4rDouDA5GiZ8/pl4D5IXseizbHNRuAWh+Dz9IcpRiH9YqeQ==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.29.1.tgz", + "integrity": "sha512-V7jkUew1wAur/PfTyiB/y7jTzFAOpqF7pPnlicTJ9m7yA6Iq1O6PDjzjqkRayN/k43jtoOnMwI3Bpg5t1PZYmQ==", "license": "MIT", "dependencies": { "@hyperswarm/secret-stream": "^6.6.2", @@ -4477,21 +4490,21 @@ } }, "node_modules/hyperschema": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.19.0.tgz", - "integrity": "sha512-gHbLxLygsDUmX9MVs8G1W4xC9NglSyrw+t28sfFFzdU40gCUUmIo3n2MkIRHpUOT7Jj+8iuvq2wSkpaG+3k/Xg==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.20.1.tgz", + "integrity": "sha512-7gnvaTNUs8FqdrU3NNE6nzhOneGyDlUlpROu9YFlHA61fE8ppKKf2gLZjZjsx/enNt+VFN4ITxy8ijypfyWBLw==", "license": "Apache-2.0", "dependencies": { "bare-fs": "^4.0.1", - "compact-encoding": "^2.15.0", + "compact-encoding": "^2.19.0", "generate-object-property": "^2.0.0", "generate-string": "^1.0.1" } }, "node_modules/hyperswarm": { - "version": "4.16.0", - "resolved": "https://registry.npmjs.org/hyperswarm/-/hyperswarm-4.16.0.tgz", - "integrity": "sha512-f86RsI1qP/nbBTv4wYxOhu1JnWvGZgiC1w69aRFn0xGSavLMWjvbbVspVApOnRjiPKpYVVGACXdrvEOrL3rHdw==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/hyperswarm/-/hyperswarm-4.17.0.tgz", + "integrity": "sha512-oe86sK961Ueg7rvDN/veFwG8xH+Iv6vObPhGDkPJcDVxk/NduW41ZhAcVDnHzRbm7S0eLU7WaDUvehOYoKSpRQ==", "license": "MIT", "dependencies": { "b4a": "^1.3.1", @@ -4504,13 +4517,14 @@ } }, "node_modules/hyperswarm-capability": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/hyperswarm-capability/-/hyperswarm-capability-1.0.3.tgz", - "integrity": "sha512-AGLaoQ1orqBe4AigREgJbpNbSvF07Eeave9xD89TksNK4Kk5GSm0RryfEH/s4QGYKO7wgSGY21QiJ+QXZeLVGw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hyperswarm-capability/-/hyperswarm-capability-1.1.1.tgz", + "integrity": "sha512-zYl9Sg0RKvoxxO9BuqliZb+U4Y9RRUlBfydXltE3VlEiavmePbd6SViQlwkXygMwns5zSoB93mik2MvAD9fxCQ==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.7", "hypercore-crypto": "^3.6.1", + "hyperschema": "^1.20.1", "sodium-universal": "^5.0.1" } }, @@ -4595,9 +4609,9 @@ } }, "node_modules/index-encoder": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/index-encoder/-/index-encoder-3.4.0.tgz", - "integrity": "sha512-k3+ENtseFYI9ZPOIZzVH8LlONUvXAcd4jvCPo+Nob/T/2t5R5Rfh8XiFXBG++gHHuVby7HBDp/3YbyEmE481cg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/index-encoder/-/index-encoder-3.5.0.tgz", + "integrity": "sha512-idZ1cxtZz2dRV6rUiaP9Xo99UjXbSzjcMacoQmxUMu/A7fEQcNPngvwDJYeWelQUS5XFlY71/or70lKn6XnwbQ==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -5268,9 +5282,9 @@ } }, "node_modules/lib-js-util-base": { - "name": "@bitfinexcom/lib-js-util-base", - "version": "1.22.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#a1cd34d5a6add343f6c322779a307db54364fcc0", + "name": "@bitfinex/lib-js-util-base", + "version": "2.0.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#8c06f3a377e62ae556f19dead07489475acd4bce", "license": "MIT" }, "node_modules/light-my-request": { @@ -5472,9 +5486,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -5676,6 +5690,35 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -6272,6 +6315,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", @@ -6409,9 +6453,9 @@ } }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -6429,9 +6473,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6799,9 +6843,9 @@ } }, "node_modules/rocksdb-native": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.12.0.tgz", - "integrity": "sha512-uibeikLKoAZAHO3tVLRFA+NcP3REC5Kwc96qRl+leri1HsKBdeMivUscwaMDpsX5EDDio5Ge4v9eeipbMvJZ2A==", + "version": "3.13.2", + "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.13.2.tgz", + "integrity": "sha512-FB8gH5eBo+SjqA7uDVGWHe2zlYugF8H775tueWAl+jK26zZvxPP8nXCgs5rZTjMhdBY7wYC2nm3V20pyAKbkcQ==", "license": "Apache-2.0", "dependencies": { "compact-encoding": "^2.15.0", @@ -6959,9 +7003,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7328,9 +7372,9 @@ } }, "node_modules/sonic-boom": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", - "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" @@ -7718,7 +7762,7 @@ }, "node_modules/svc-facs-httpd": { "version": "0.0.1", - "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd.git#5f4aa377fd19683aa28db1def620619de4e490fd", + "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd.git#c2ca996f29504941a3806ddd3d7cb803f8b94de9", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", @@ -7752,9 +7796,9 @@ } }, "node_modules/svc-facs-logging/node_modules/pino": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz", - "integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", @@ -7811,9 +7855,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -7870,10 +7914,10 @@ } }, "node_modules/tar/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -7899,6 +7943,15 @@ "node": ">=18" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-tmp": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/test-tmp/-/test-tmp-1.4.0.tgz", @@ -7939,9 +7992,9 @@ } }, "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" diff --git a/tests/unit/handlers/actions.handlers.test.js b/tests/unit/handlers/actions.handlers.test.js index 35714bf..7c6e4fd 100644 --- a/tests/unit/handlers/actions.handlers.test.js +++ b/tests/unit/handlers/actions.handlers.test.js @@ -11,7 +11,7 @@ const { voteAction, cancelActionsBatch } = require('../../../workers/lib/server/handlers/actions.handlers') -const { createMockCtxWithOrks, createMockReq } = require('../helpers/mockHelpers') +const { createMockCtxWithOrks, createMockReq, withDataProxy } = require('../helpers/mockHelpers') test('queryActionsBatch - basic functionality', async (t) => { const mockCtx = createMockCtxWithOrks( @@ -70,14 +70,14 @@ test('queryActions - with queries parameter', async (t) => { }) test('queryActions - with invalid queries JSON', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) const mockReq = { query: { queries: 'invalid-json' } @@ -94,7 +94,7 @@ test('queryActions - with invalid queries JSON', async (t) => { }) test('queryActions - with groupBatch parameter', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -105,7 +105,7 @@ test('queryActions - with groupBatch parameter', async (t) => { return { actions: [] } } } - } + }) const mockReq = { query: { groupBatch: 'true' } @@ -119,7 +119,7 @@ test('queryActions - with groupBatch parameter', async (t) => { }) test('queryActions - handles network errors', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -130,7 +130,7 @@ test('queryActions - handles network errors', async (t) => { throw new Error('Network error') } } - } + }) const mockReq = { query: {} @@ -145,7 +145,7 @@ test('queryActions - handles network errors', async (t) => { }) test('getAction - basic functionality', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -156,7 +156,7 @@ test('getAction - basic functionality', async (t) => { return { id: payload.id, type: payload.type } } } - } + }) const mockReq = { params: { id: 'action123', type: 'test' } @@ -171,7 +171,7 @@ test('getAction - basic functionality', async (t) => { }) test('getAction - handles errors', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -182,7 +182,7 @@ test('getAction - handles errors', async (t) => { throw new Error('Action not found') } } - } + }) const mockReq = { params: { id: 'nonexistent', type: 'test' } @@ -218,7 +218,7 @@ test('pushAction - requires write permission', async (t) => { }) test('pushAction - with valid permissions', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -235,7 +235,7 @@ test('pushAction - with valid permissions', async (t) => { return { id: 'new-action', success: true } } } - } + }) const mockReq = { _info: { @@ -306,7 +306,7 @@ test('pushActionsBatch - validates batchActionsPayload array', async (t) => { }) test('pushActionsBatch - with valid data', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -323,7 +323,7 @@ test('pushActionsBatch - with valid data', async (t) => { return { id: 'batch-action', success: true } } } - } + }) const mockReq = { _info: { @@ -366,7 +366,7 @@ test('voteAction - requires write permission', async (t) => { }) test('voteAction - with valid permissions', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -383,7 +383,7 @@ test('voteAction - with valid permissions', async (t) => { return { success: true, vote: payload.approve } } } - } + }) const mockReq = { _info: { @@ -424,7 +424,7 @@ test('cancelActionsBatch - requires write permission', async (t) => { }) test('cancelActionsBatch - with valid permissions', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -438,7 +438,7 @@ test('cancelActionsBatch - with valid permissions', async (t) => { return { success: true, cancelled: payload.ids } } } - } + }) const mockReq = { _info: { @@ -457,7 +457,7 @@ test('cancelActionsBatch - with valid permissions', async (t) => { }) test('cancelActionsBatch - handles errors', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -471,7 +471,7 @@ test('cancelActionsBatch - handles errors', async (t) => { throw new Error('Cancellation failed') } } - } + }) const mockReq = { _info: { diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 9fa0341..2bae74b 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -24,12 +24,13 @@ const { processNetworkHashrateData, calculateHashRevenueSummary } = require('../../../workers/lib/server/handlers/finance.handlers') +const { withDataProxy } = require('../helpers/mockHelpers') // ==================== Energy Balance Tests ==================== test('getEnergyBalance - happy path', async (t) => { const dayTs = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -61,7 +62,7 @@ test('getEnergyBalance - happy path', async (t) => { globalDataLib: { getGlobalData: async () => [] } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, period: 'daily' } @@ -75,11 +76,11 @@ test('getEnergyBalance - happy path', async (t) => { }) test('getEnergyBalance - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) const mockReq = { query: { end: 1700100000000 } } @@ -93,11 +94,11 @@ test('getEnergyBalance - missing start throws', async (t) => { }) test('getEnergyBalance - missing end throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) const mockReq = { query: { start: 1700000000000 } } @@ -111,11 +112,11 @@ test('getEnergyBalance - missing end throws', async (t) => { }) test('getEnergyBalance - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [], site: 'test-site' }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) const mockReq = { query: { start: 1700100000000, end: 1700000000000 } } @@ -129,7 +130,7 @@ test('getEnergyBalance - invalid range throws', async (t) => { }) test('getEnergyBalance - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -143,7 +144,7 @@ test('getEnergyBalance - empty ork results', async (t) => { }) }) } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, period: 'daily' } @@ -259,7 +260,7 @@ test('calculateSummary - handles empty log', (t) => { // ==================== EBITDA Tests ==================== test('getEbitda - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -285,7 +286,7 @@ test('getEbitda - happy path', async (t) => { globalDataLib: { getGlobalData: async () => [] } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, period: 'daily' } @@ -300,11 +301,11 @@ test('getEbitda - happy path', async (t) => { }) test('getEbitda - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) try { await getEbitda(mockCtx, { query: { end: 1700100000000 } }, {}) @@ -316,11 +317,11 @@ test('getEbitda - missing start throws', async (t) => { }) test('getEbitda - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) try { await getEbitda(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) @@ -332,11 +333,11 @@ test('getEbitda - invalid range throws', async (t) => { }) test('getEbitda - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) const result = await getEbitda(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) t.ok(result.log, 'should return log array') @@ -397,7 +398,7 @@ test('calculateEbitdaSummary - handles empty log', (t) => { // ==================== Cost Summary Tests ==================== test('getCostSummary - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -415,7 +416,7 @@ test('getCostSummary - happy path', async (t) => { globalDataLib: { getGlobalData: async () => [] } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, period: 'daily' } @@ -429,11 +430,11 @@ test('getCostSummary - happy path', async (t) => { }) test('getCostSummary - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) try { await getCostSummary(mockCtx, { query: { end: 1700100000000 } }, {}) @@ -445,11 +446,11 @@ test('getCostSummary - missing start throws', async (t) => { }) test('getCostSummary - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) try { await getCostSummary(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) @@ -461,11 +462,11 @@ test('getCostSummary - invalid range throws', async (t) => { }) test('getCostSummary - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) const result = await getCostSummary(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) t.ok(result.log, 'should return log array') @@ -499,7 +500,7 @@ test('calculateCostSummary - handles empty log', (t) => { // ==================== Subsidy Fees Tests ==================== test('getSubsidyFees - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -511,7 +512,7 @@ test('getSubsidyFees - happy path', async (t) => { return {} } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, period: 'daily' } @@ -525,10 +526,10 @@ test('getSubsidyFees - happy path', async (t) => { }) test('getSubsidyFees - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getSubsidyFees(mockCtx, { query: { end: 1700100000000 } }, {}) @@ -540,10 +541,10 @@ test('getSubsidyFees - missing start throws', async (t) => { }) test('getSubsidyFees - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getSubsidyFees(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) @@ -555,10 +556,10 @@ test('getSubsidyFees - invalid range throws', async (t) => { }) test('getSubsidyFees - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getSubsidyFees(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) t.ok(result.log, 'should return log array') @@ -593,7 +594,7 @@ test('calculateSubsidyFeesSummary - handles empty log', (t) => { // ==================== Revenue Tests ==================== test('getRevenue - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -605,7 +606,7 @@ test('getRevenue - happy path', async (t) => { return {} } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, period: 'daily' } @@ -619,10 +620,10 @@ test('getRevenue - happy path', async (t) => { }) test('getRevenue - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getRevenue(mockCtx, { query: { end: 1700100000000 } }, {}) @@ -634,10 +635,10 @@ test('getRevenue - missing start throws', async (t) => { }) test('getRevenue - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getRevenue(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) @@ -649,10 +650,10 @@ test('getRevenue - invalid range throws', async (t) => { }) test('getRevenue - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getRevenue(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) t.ok(result.log, 'should return log array') @@ -662,7 +663,7 @@ test('getRevenue - empty ork results', async (t) => { test('getRevenue - pool filter', async (t) => { let capturedPayload = null - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -672,7 +673,7 @@ test('getRevenue - pool filter', async (t) => { return [{ transactions: [{ ts: 1700006400000, changed_balance: 0.5 }] }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, pool: 'f2pool' } @@ -708,7 +709,7 @@ test('calculateRevenueSummary - handles empty log', (t) => { test('getRevenueSummary - happy path', async (t) => { const dayTs = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -743,7 +744,7 @@ test('getRevenueSummary - happy path', async (t) => { globalDataLib: { getGlobalData: async () => [] } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, period: 'daily' } @@ -767,11 +768,11 @@ test('getRevenueSummary - happy path', async (t) => { }) test('getRevenueSummary - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) try { await getRevenueSummary(mockCtx, { query: { end: 1700100000000 } }, {}) @@ -783,11 +784,11 @@ test('getRevenueSummary - missing start throws', async (t) => { }) test('getRevenueSummary - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) try { await getRevenueSummary(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) @@ -799,11 +800,11 @@ test('getRevenueSummary - invalid range throws', async (t) => { }) test('getRevenueSummary - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) }, globalDataLib: { getGlobalData: async () => [] } - } + }) const result = await getRevenueSummary(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) t.ok(result.log, 'should return log array') diff --git a/tests/unit/handlers/global.handlers.test.js b/tests/unit/handlers/global.handlers.test.js index d18fa32..ede15d6 100644 --- a/tests/unit/handlers/global.handlers.test.js +++ b/tests/unit/handlers/global.handlers.test.js @@ -3,9 +3,10 @@ const test = require('brittle') const { getGlobalConfig, setGlobalConfig, getFeatureConfig, getFeatures, setFeatures, getGlobalData, setGlobalData } = require('../../../workers/lib/server/handlers/global.handlers') const { GLOBAL_DATA_TYPES } = require('../../../workers/lib/constants') +const { withDataProxy } = require('../helpers/mockHelpers') test('getGlobalConfig - with fields query param', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' }, @@ -15,7 +16,7 @@ test('getGlobalConfig - with fields query param', async (t) => { net_r0: { jRequest: async () => ({ config: 'test' }) } - } + }) const mockReq = { query: { @@ -30,7 +31,7 @@ test('getGlobalConfig - with fields query param', async (t) => { }) test('getGlobalConfig - without fields query param', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' } @@ -39,7 +40,7 @@ test('getGlobalConfig - without fields query param', async (t) => { net_r0: { jRequest: async () => ({ config: 'test' }) } - } + }) const mockReq = { query: {} @@ -51,7 +52,7 @@ test('getGlobalConfig - without fields query param', async (t) => { }) test('setGlobalConfig - basic functionality', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [ { rpcPublicKey: 'key1' }, @@ -61,7 +62,7 @@ test('setGlobalConfig - basic functionality', async (t) => { net_r0: { jRequest: async () => ({ success: true }) } - } + }) const mockReq = { body: { diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index 9a6355b..e039068 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -34,12 +34,13 @@ const { getContainerHistory, processContainerHistoryData } = require('../../../workers/lib/server/handlers/metrics.handlers') +const { withDataProxy } = require('../helpers/mockHelpers') // ==================== Hashrate Tests ==================== test('getHashrate - happy path', async (t) => { const dayTs = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -48,7 +49,7 @@ test('getHashrate - happy path', async (t) => { return [{ type: 'miner', data: [{ ts: dayTs, val: { hashrate_mhs_5m_sum_aggr: 100000 } }], error: null }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000 } @@ -66,10 +67,10 @@ test('getHashrate - happy path', async (t) => { }) test('getHashrate - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getHashrate(mockCtx, { query: { end: 1700100000000 } }) @@ -81,10 +82,10 @@ test('getHashrate - missing start throws', async (t) => { }) test('getHashrate - missing end throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getHashrate(mockCtx, { query: { start: 1700000000000 } }) @@ -96,10 +97,10 @@ test('getHashrate - missing end throws', async (t) => { }) test('getHashrate - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getHashrate(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) @@ -111,10 +112,10 @@ test('getHashrate - invalid range throws', async (t) => { }) test('getHashrate - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getHashrate(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }) t.ok(result.log, 'should return log array') @@ -192,7 +193,7 @@ test('calculateHashrateSummary - handles empty log', (t) => { test('getConsumption - happy path', async (t) => { const dayTs = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -201,7 +202,7 @@ test('getConsumption - happy path', async (t) => { return [{ type: 'powermeter', data: [{ ts: dayTs, val: { site_power_w: 5000000 } }], error: null }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000 } @@ -220,10 +221,10 @@ test('getConsumption - happy path', async (t) => { }) test('getConsumption - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getConsumption(mockCtx, { query: { end: 1700100000000 } }) @@ -235,10 +236,10 @@ test('getConsumption - missing start throws', async (t) => { }) test('getConsumption - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getConsumption(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) @@ -250,10 +251,10 @@ test('getConsumption - invalid range throws', async (t) => { }) test('getConsumption - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getConsumption(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }) t.ok(result.log, 'should return log array') @@ -330,7 +331,7 @@ test('calculateConsumptionSummary - handles empty log', (t) => { test('getEfficiency - happy path', async (t) => { const dayTs = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -339,7 +340,7 @@ test('getEfficiency - happy path', async (t) => { return [{ type: 'miner', data: [{ ts: dayTs, val: { efficiency_w_ths_avg_aggr: 25.5 } }], error: null }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000 } @@ -356,10 +357,10 @@ test('getEfficiency - happy path', async (t) => { }) test('getEfficiency - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getEfficiency(mockCtx, { query: { end: 1700100000000 } }) @@ -371,10 +372,10 @@ test('getEfficiency - missing start throws', async (t) => { }) test('getEfficiency - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getEfficiency(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) @@ -386,10 +387,10 @@ test('getEfficiency - invalid range throws', async (t) => { }) test('getEfficiency - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getEfficiency(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }) t.ok(result.log, 'should return log array') @@ -474,7 +475,7 @@ test('sumObjectValues - sums keyed object values', (t) => { test('getMinerStatus - happy path', async (t) => { const dayTs = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -489,7 +490,7 @@ test('getMinerStatus - happy path', async (t) => { }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000 } @@ -508,10 +509,10 @@ test('getMinerStatus - happy path', async (t) => { }) test('getMinerStatus - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getMinerStatus(mockCtx, { query: { end: 1700100000000 } }) @@ -523,10 +524,10 @@ test('getMinerStatus - missing start throws', async (t) => { }) test('getMinerStatus - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getMinerStatus(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) @@ -538,10 +539,10 @@ test('getMinerStatus - invalid range throws', async (t) => { }) test('getMinerStatus - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getMinerStatus(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }) t.ok(result.log, 'should return log array') @@ -763,7 +764,7 @@ test('processTemperatureData - handles range string ts with groupRange', (t) => test('getPowerMode - happy path', async (t) => { const ts = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -776,7 +777,7 @@ test('getPowerMode - happy path', async (t) => { }] } } - } + }) const result = await getPowerMode(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } @@ -792,10 +793,10 @@ test('getPowerMode - happy path', async (t) => { }) test('getPowerMode - missing start/end throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getPowerMode(mockCtx, { query: { end: 1700100000000 } }) @@ -807,10 +808,10 @@ test('getPowerMode - missing start/end throws', async (t) => { }) test('getPowerMode - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getPowerMode(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) @@ -822,10 +823,10 @@ test('getPowerMode - invalid range throws', async (t) => { }) test('getPowerMode - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getPowerMode(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } @@ -943,7 +944,7 @@ test('calculatePowerModeSummary - handles empty log', (t) => { // ==================== Power Mode Timeline Tests ==================== test('getPowerModeTimeline - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -963,7 +964,7 @@ test('getPowerModeTimeline - happy path', async (t) => { ] } } - } + }) const result = await getPowerModeTimeline(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } @@ -978,10 +979,10 @@ test('getPowerModeTimeline - happy path', async (t) => { }) test('getPowerModeTimeline - default start/end', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ([]) } - } + }) const result = await getPowerModeTimeline(mockCtx, { query: {} }) t.ok(result.log, 'should return log with defaults') @@ -990,10 +991,10 @@ test('getPowerModeTimeline - default start/end', async (t) => { }) test('getPowerModeTimeline - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getPowerModeTimeline(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) @@ -1005,10 +1006,10 @@ test('getPowerModeTimeline - invalid range throws', async (t) => { }) test('getPowerModeTimeline - empty results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getPowerModeTimeline(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } @@ -1109,7 +1110,7 @@ test('processPowerModeTimelineData - extracts container from miner id', (t) => { test('getPowerModeTimeline - always uses t-miner tag', async (t) => { let capturedPayload = null - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async (key, method, payload) => { @@ -1117,7 +1118,7 @@ test('getPowerModeTimeline - always uses t-miner tag', async (t) => { return [] } } - } + }) await getPowerModeTimeline(mockCtx, { query: { start: 1700000000000, end: 1700100000000, container: 'my-container' } @@ -1146,7 +1147,7 @@ test('processPowerModeTimelineData - filters by container post-RPC', (t) => { test('getTemperature - happy path', async (t) => { const ts = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -1159,7 +1160,7 @@ test('getTemperature - happy path', async (t) => { }] } } - } + }) const result = await getTemperature(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } @@ -1178,10 +1179,10 @@ test('getTemperature - happy path', async (t) => { }) test('getTemperature - missing start/end throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getTemperature(mockCtx, { query: { end: 1700100000000 } }) @@ -1193,10 +1194,10 @@ test('getTemperature - missing start/end throws', async (t) => { }) test('getTemperature - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getTemperature(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }) @@ -1208,10 +1209,10 @@ test('getTemperature - invalid range throws', async (t) => { }) test('getTemperature - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getTemperature(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } @@ -1300,7 +1301,7 @@ test('calculateTemperatureSummary - handles empty log', (t) => { test('getTemperature - always uses t-miner tag with container post-filter', async (t) => { let capturedPayload = null - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async (key, method, payload) => { @@ -1308,7 +1309,7 @@ test('getTemperature - always uses t-miner tag with container post-filter', asyn return [] } } - } + }) await getTemperature(mockCtx, { query: { start: 1700000000000, end: 1700100000000, container: 'my-container' } @@ -1321,7 +1322,7 @@ test('getTemperature - always uses t-miner tag with container post-filter', asyn // ==================== Container Telemetry Tests ==================== test('getContainerTelemetry - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -1341,7 +1342,7 @@ test('getContainerTelemetry - happy path', async (t) => { return {} } } - } + }) const mockReq = { params: { id: 'bitdeer-9a' }, @@ -1358,10 +1359,10 @@ test('getContainerTelemetry - happy path', async (t) => { }) test('getContainerTelemetry - missing id throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getContainerTelemetry(mockCtx, { params: {}, query: {} }) @@ -1373,10 +1374,10 @@ test('getContainerTelemetry - missing id throws', async (t) => { }) test('getContainerTelemetry - no sensor data returns null telemetry', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => [] } - } + }) const result = await getContainerTelemetry(mockCtx, { params: { id: 'bitdeer-9a' }, @@ -1447,7 +1448,7 @@ test('processContainerSensorSnapshot - prefix match fallback', (t) => { // ==================== Container History Tests ==================== test('getContainerHistory - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -1466,7 +1467,7 @@ test('getContainerHistory - happy path', async (t) => { }] } } - } + }) const mockReq = { params: { id: 'bitdeer-9a' }, @@ -1483,10 +1484,10 @@ test('getContainerHistory - happy path', async (t) => { }) test('getContainerHistory - missing id throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getContainerHistory(mockCtx, { params: {}, query: {} }) @@ -1498,10 +1499,10 @@ test('getContainerHistory - missing id throws', async (t) => { }) test('getContainerHistory - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getContainerHistory(mockCtx, { @@ -1517,7 +1518,7 @@ test('getContainerHistory - invalid range throws', async (t) => { test('getContainerHistory - uses defaults when no start/end', async (t) => { let capturedPayload = null - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async (key, method, payload) => { @@ -1525,7 +1526,7 @@ test('getContainerHistory - uses defaults when no start/end', async (t) => { return [] } } - } + }) const result = await getContainerHistory(mockCtx, { params: { id: 'bitdeer-9a' }, @@ -1540,10 +1541,10 @@ test('getContainerHistory - uses defaults when no start/end', async (t) => { }) test('getContainerHistory - empty results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => [] } - } + }) const result = await getContainerHistory(mockCtx, { params: { id: 'bitdeer-9a' }, diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js index 3eddfa7..d1f6a7b 100644 --- a/tests/unit/handlers/pools.handlers.test.js +++ b/tests/unit/handlers/pools.handlers.test.js @@ -12,9 +12,10 @@ const { processTransactionData, calculateAggregateSummary } = require('../../../workers/lib/server/handlers/pools.handlers') +const { withDataProxy } = require('../helpers/mockHelpers') test('getPools - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -32,7 +33,7 @@ test('getPools - happy path', async (t) => { return [] } } - } + }) const mockReq = { query: {} } const result = await getPools(mockCtx, mockReq, {}) @@ -45,7 +46,7 @@ test('getPools - happy path', async (t) => { }) test('getPools - with filter', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => [{ @@ -56,7 +57,7 @@ test('getPools - with filter', async (t) => { ] }] } - } + }) const mockReq = { query: { query: '{"pool":"f2pool"}' } } const result = await getPools(mockCtx, mockReq, {}) @@ -67,10 +68,10 @@ test('getPools - with filter', async (t) => { }) test('getPools - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ([]) } - } + }) const result = await getPools(mockCtx, { query: {} }, {}) t.ok(result.pools, 'should return pools array') @@ -143,7 +144,7 @@ test('calculatePoolsSummary - handles empty pools', (t) => { }) test('getPoolBalanceHistory - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -159,7 +160,7 @@ test('getPoolBalanceHistory - happy path', async (t) => { }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, range: '1D' }, @@ -178,7 +179,7 @@ test('getPoolBalanceHistory - happy path', async (t) => { test('getPoolBalanceHistory - with pool filter', async (t) => { let capturedPayload = null - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async (key, method, payload) => { @@ -192,7 +193,7 @@ test('getPoolBalanceHistory - with pool filter', async (t) => { }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000 }, @@ -206,7 +207,7 @@ test('getPoolBalanceHistory - with pool filter', async (t) => { }) test('getPoolBalanceHistory - "all" pool filter returns all pools', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => { @@ -219,7 +220,7 @@ test('getPoolBalanceHistory - "all" pool filter returns all pools', async (t) => }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000 }, @@ -262,10 +263,10 @@ test('getPoolBalanceHistory - invalid range throws', async (t) => { }) test('getPoolBalanceHistory - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getPoolBalanceHistory(mockCtx, { query: { start: 1700000000000, end: 1700100000000 }, params: {} }, {}) t.ok(result.log, 'should return log array') @@ -327,7 +328,7 @@ test('groupByBucket - handles missing timestamps', (t) => { }) test('getPoolStatsAggregate - happy path', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -343,7 +344,7 @@ test('getPoolStatsAggregate - happy path', async (t) => { }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, range: 'daily' } @@ -360,7 +361,7 @@ test('getPoolStatsAggregate - happy path', async (t) => { test('getPoolStatsAggregate - with pool filter', async (t) => { let capturedPayload = null - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async (key, method, payload) => { @@ -374,7 +375,7 @@ test('getPoolStatsAggregate - with pool filter', async (t) => { }] } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, pool: 'user1' } @@ -417,10 +418,10 @@ test('getPoolStatsAggregate - invalid range throws', async (t) => { }) test('getPoolStatsAggregate - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getPoolStatsAggregate(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) t.ok(result.log, 'should return log array') diff --git a/tests/unit/handlers/site.handlers.test.js b/tests/unit/handlers/site.handlers.test.js index f328c7d..2ef8aa0 100644 --- a/tests/unit/handlers/site.handlers.test.js +++ b/tests/unit/handlers/site.handlers.test.js @@ -2,9 +2,10 @@ const test = require('brittle') const { getSiteLiveStatus } = require('../../../workers/lib/server/handlers/site.handlers') +const { withDataProxy } = require('../helpers/mockHelpers') function createMockCtx (tailLogMultiResponse, extDataResponse, globalConfigResponse) { - return { + return withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -16,7 +17,7 @@ function createMockCtx (tailLogMultiResponse, extDataResponse, globalConfigRespo return {} } } - } + }) } test('getSiteLiveStatus - returns composed response with correct structure', async (t) => { diff --git a/tests/unit/helpers/mockHelpers.js b/tests/unit/helpers/mockHelpers.js index 242e594..d095ae3 100644 --- a/tests/unit/helpers/mockHelpers.js +++ b/tests/unit/helpers/mockHelpers.js @@ -1,16 +1,64 @@ 'use strict' -const createMockCtxWithOrks = (orks = [{ rpcPublicKey: 'key1' }], jRequestImpl = async () => ({})) => { +const async = require('async') + +const buildDataProxy = (orks = [], jRequestImpl = async () => ({})) => { + const eachLimit = async (method, params, errorHandler = null) => { + const results = [] + await async.eachLimit(orks, 2, async (store) => { + try { + const res = await jRequestImpl(store.rpcPublicKey, method, params, {}) + if (errorHandler) errorHandler(res, results) + else results.push(res) + } catch (err) { + if (errorHandler) errorHandler({ error: err.message }, results) + else results.push({ error: err.message }) + } + }) + return results + } + + const mapLimit = async (method, params) => { + return async.mapLimit(orks, 2, async (store) => { + return jRequestImpl(store.rpcPublicKey, method, params, {}) + }) + } + + const mapAllPages = async (method, params, pageLimit = 100) => { + return async.mapLimit(orks, 2, async (store) => { + const allItems = [] + let offset = 0 + while (true) { + const batch = await jRequestImpl(store.rpcPublicKey, method, { ...params, limit: pageLimit, offset }, {}) + if (!Array.isArray(batch) || batch.length === 0) break + allItems.push(...batch) + if (batch.length < pageLimit) break + offset += pageLimit + } + return allItems + }) + } + return { - conf: { - orks - }, - net_r0: { - jRequest: jRequestImpl - } + requestData: eachLimit, + requestDataMap: mapLimit, + requestDataAllPages: mapAllPages } } +const withDataProxy = (ctx) => { + const orks = ctx.conf?.orks || [] + const jRequestImpl = ctx.net_r0?.jRequest || (async () => ({})) + return { ...ctx, dataProxy: buildDataProxy(orks, jRequestImpl) } +} + +const createMockCtxWithOrks = (orks = [{ rpcPublicKey: 'key1' }], jRequestImpl = async () => ({})) => { + return withDataProxy({ + conf: { orks }, + net_r0: { jRequest: jRequestImpl } + }) +} + const createMockCtxWithOrksAndNet = (orks, netImpl) => { return { conf: { @@ -102,6 +150,8 @@ const createRoutesForTest = (routesPath) => { } module.exports = { + buildDataProxy, + withDataProxy, createMockCtxWithOrks, createMockCtxWithOrksAndNet, createMockAuthCtx, diff --git a/tests/unit/lib/alerts.test.js b/tests/unit/lib/alerts.test.js index 892f4c5..ada2dbd 100644 --- a/tests/unit/lib/alerts.test.js +++ b/tests/unit/lib/alerts.test.js @@ -2,14 +2,18 @@ const test = require('brittle') const { AlertsService } = require('../../../workers/lib/alerts') +const { buildDataProxy } = require('../helpers/mockHelpers') -test('AlertsService constructor should initialize with orks and net', (t) => { +const makeService = (orks, jRequestImpl) => { + return new AlertsService({ dataProxy: buildDataProxy(orks, jRequestImpl) }) +} + +test('AlertsService constructor should initialize with dataProxy', (t) => { const mockOrks = [{ rpcPublicKey: 'key1' }] const mockNet = { jRequest: async () => { } } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) - t.is(service.orks, mockOrks, 'Should set orks property') - t.is(service.net, mockNet, 'Should set net property') + t.ok(service.dataProxy, 'Should set dataProxy property') }) test('fetchAlerts should fetch alerts from orks', async (t) => { @@ -20,7 +24,7 @@ test('fetchAlerts should fetch alerts from orks', async (t) => { return [{ id: 'thing1', last: { alerts: { createdAt: Date.now() } } }] } } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.ok(Array.isArray(result), 'Should return an array') @@ -35,7 +39,7 @@ test('fetchAlerts should handle fetchAll parameter', async (t) => { return [{ id: 'thing1' }] } } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) await service.fetchAlerts(true) t.ok(capturedQuery.query['last.alerts.createdAt'].$exists === true, 'Should use $exists when fetchAll is true') @@ -51,7 +55,7 @@ test('fetchAlerts should filter by time when fetchAll is false', async (t) => { return [] } } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) await service.fetchAlerts(false) t.ok(capturedQuery.query['last.alerts.createdAt'].$gte, 'Should use $gte time filter when fetchAll is false') @@ -64,7 +68,7 @@ test('fetchAlerts should handle errors and return empty array', async (t) => { throw new Error('Network error') } } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.alike(result, [], 'Should return empty array on error') @@ -79,7 +83,7 @@ test('fetchAlerts should fetch from multiple orks', async (t) => { return [{ id: `thing-${_key}` }] } } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.is(requestCount, 3, 'Should make requests to all orks') @@ -91,7 +95,7 @@ test('broadcastAlerts should send alerts to all clients', async (t) => { const mockNet = { jRequest: async () => [{ id: 'alert1' }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) let sentData = null const mockClient = { readyState: 1, @@ -112,7 +116,7 @@ test('broadcastAlerts should remove clients with readyState !== 1', async (t) => const mockNet = { jRequest: async () => [{ id: 'alert1' }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const mockClient = { readyState: 0, send: () => { } @@ -129,7 +133,7 @@ test('broadcastAlerts should remove clients that throw errors on send', async (t const mockNet = { jRequest: async () => [{ id: 'alert1' }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const mockClient = { readyState: 1, subscriptions: new Set(['alerts']), @@ -148,7 +152,7 @@ test('broadcastAlerts should handle multiple clients with mixed states', async ( const mockNet = { jRequest: async () => [{ id: 'alert1' }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) let sendCount = 0 const goodClient1 = { readyState: 1, @@ -185,7 +189,7 @@ test('fetchAlerts should create correct query structure', async (t) => { return [] } } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) await service.fetchAlerts() t.is(capturedQuery.status, 1, 'Should query for status 1') @@ -202,7 +206,7 @@ test('broadcastAlerts should handle empty clients set', async (t) => { const mockNet = { jRequest: async () => [{ id: 'alert1' }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const clients = new Set() await service.broadcastAlerts(clients) @@ -214,7 +218,7 @@ test('broadcastAlerts should skip null clients', async (t) => { const mockNet = { jRequest: async () => [{ id: 'alert1' }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const clients = new Set([null, undefined]) await service.broadcastAlerts(clients) @@ -227,7 +231,7 @@ test('fetchAlerts should handle empty orks array', async (t) => { const mockNet = { jRequest: async () => [] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.ok(Array.isArray(result), 'Should return array even with no orks') @@ -238,7 +242,7 @@ test('broadcastAlerts should not send to clients without subscription', async (t const mockNet = { jRequest: async () => [{ id: 'alert1' }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) let sendCount = 0 const unsubscribedClient = { readyState: 1, @@ -259,7 +263,7 @@ test('broadcastAlerts should only send to clients subscribed to alerts', async ( const mockNet = { jRequest: async () => [{ id: 'alert1' }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) let sendCount = 0 const subscribedClient = { readyState: 1, @@ -298,7 +302,7 @@ test('fetchAlerts should extract alerts and append id, type, code and container' } }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.is(result.length, 2, 'Should return 2 alerts') @@ -339,7 +343,7 @@ test('fetchAlerts should handle multiple miners with alerts', async (t) => { } ] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.is(result.length, 3, 'Should return 3 total alerts from 2 miners') @@ -375,7 +379,7 @@ test('fetchAlerts should skip miners without alerts array', async (t) => { } ] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.is(result.length, 1, 'Should only return alerts from miner with valid alerts array') @@ -395,7 +399,7 @@ test('fetchAlerts should handle miners with empty alerts array', async (t) => { } ] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.is(result.length, 0, 'Should return empty array when miner has no alerts') @@ -411,7 +415,7 @@ test('fetchAlerts should handle miners without last property', async (t) => { } ] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.is(result.length, 0, 'Should return empty array when miner has no last property') @@ -437,7 +441,7 @@ test('fetchAlerts should preserve all original alert properties', async (t) => { } }] } - const service = new AlertsService({ orks: mockOrks, net: mockNet }) + const service = makeService(mockOrks, mockNet.jRequest) const result = await service.fetchAlerts() t.is(result.length, 1, 'Should return 1 alert') diff --git a/tests/unit/services.poolManager.test.js b/tests/unit/services.poolManager.test.js index 13d49fe..3645ddc 100644 --- a/tests/unit/services.poolManager.test.js +++ b/tests/unit/services.poolManager.test.js @@ -9,9 +9,10 @@ const { getUnitsWithPoolData, getPoolAlerts } = require('../../workers/lib/server/services/poolManager') +const { withDataProxy } = require('./helpers/mockHelpers') function createMockCtx (responseData) { - return { + return withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -25,7 +26,7 @@ function createMockCtx (responseData) { return Promise.resolve(responseData) } } - } + }) } function createMockPoolStatsResponse (pools) { diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index aa2b95e..8d918d5 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -10,6 +10,7 @@ const GlobalDataLib = require('./lib/globalData') const { UserService } = require('./lib/users') const { AlertsService } = require('./lib/alerts') const { auditLogger } = require('./lib/server/lib/auditLogger') +const { createDataProxy } = require('./lib/data.proxy') class WrkServerHttp extends TetherWrkBase { constructor (conf, ctx) { @@ -25,6 +26,10 @@ class WrkServerHttp extends TetherWrkBase { this.queuedRequests = new Map() this.wsClients = new Set() + this.isRpcMode = ctx.isRpcMode !== false + if (ctx.ork) this.ork = ctx.ork + this.dataProxy = createDataProxy(this) + this.init() this.start() } @@ -137,7 +142,7 @@ class WrkServerHttp extends TetherWrkBase { }, 15 * 60 * 1000) } - this.alertsService = new AlertsService({ orks: this.conf.orks, net: this.net_r0 }) + this.alertsService = new AlertsService({ dataProxy: this.dataProxy }) this.interval_0.add('broadcastAlerts', async () => { try { await this.alertsService.broadcastAlerts(this.wsClients) diff --git a/workers/lib/alerts.js b/workers/lib/alerts.js index 70a2fb3..173a83b 100644 --- a/workers/lib/alerts.js +++ b/workers/lib/alerts.js @@ -1,14 +1,12 @@ 'use strict' -const async = require('async') -const { RPC_CONCURRENCY_LIMIT } = require('./constants') class AlertsService { - constructor ({ orks, net }) { - this.orks = orks - this.net = net + constructor ({ dataProxy }) { + this.dataProxy = dataProxy } async broadcastAlerts (clients) { + if (!clients.size) return const alerts = await this.fetchAlerts() const payload = JSON.stringify(alerts) @@ -44,16 +42,9 @@ class AlertsService { } try { - const res = await async.mapLimit(this.orks, RPC_CONCURRENCY_LIMIT, async (store) => { - return this.net.jRequest( - store.rpcPublicKey, - 'listThings', - query - ) - }) + const res = await this.dataProxy.requestDataMap('listThings', query) const alerts = [] - // res is an array of arrays (one array per ork), so we need to flatten it const things = res.flat() for (const thing of things) { if (Array.isArray(thing?.last?.alerts)) { diff --git a/workers/lib/auth.js b/workers/lib/auth.js index 7b15870..1b299dc 100644 --- a/workers/lib/auth.js +++ b/workers/lib/auth.js @@ -18,7 +18,6 @@ class AuthLib { } try { - console.log('Starting user migration') const oldUsers = httpdAuth.conf.users || [] const superAdmin = users.find(user => user.id.toString() === SUPER_ADMIN_ID) @@ -36,8 +35,6 @@ class AuthLib { console.error(`Failed to migrate user: ${oldUser.email}`, error) } })) - - console.log('Migration complete') } catch (error) { console.error('Unexpected error occurred during migration. Migration Failed!', error) throw error diff --git a/workers/lib/data.proxy.js b/workers/lib/data.proxy.js new file mode 100644 index 0000000..7a83c28 --- /dev/null +++ b/workers/lib/data.proxy.js @@ -0,0 +1,93 @@ +'use strict' + +const async = require('async') +const { RPC_CONCURRENCY_LIMIT, RPC_PAGE_LIMIT } = require('./constants') + +const getRpcTimeout = (conf) => conf?.rpcTimeout || 15000 + +const _rpcEachLimit = async (ctx, method, params, errorHandler = null) => { + const results = [] + const concurrency = ctx.conf?.rpcConcurrencyLimit || RPC_CONCURRENCY_LIMIT + const timeout = getRpcTimeout(ctx.conf) + + await async.eachLimit(ctx.conf.orks, concurrency, async (store) => { + try { + const res = await ctx.net_r0.jRequest(store.rpcPublicKey, method, params, { timeout }) + if (errorHandler) { + errorHandler(res, results) + } else { + results.push(res) + } + } catch (err) { + if (errorHandler) { + errorHandler({ error: err.message }, results) + } else { + results.push({ error: err.message }) + } + } + }) + + return results +} + +const _rpcMapLimit = async (ctx, method, params) => { + const concurrency = ctx.conf?.rpcConcurrencyLimit || RPC_CONCURRENCY_LIMIT + const timeout = getRpcTimeout(ctx.conf) + + return await async.mapLimit(ctx.conf.orks, concurrency, async (store) => { + return ctx.net_r0.jRequest(store.rpcPublicKey, method, params, { timeout }) + }) +} + +const _rpcMapAllPages = async (ctx, method, params, pageLimit = RPC_PAGE_LIMIT) => { + const concurrency = ctx.conf?.rpcConcurrencyLimit || RPC_CONCURRENCY_LIMIT + const timeout = getRpcTimeout(ctx.conf) + + return await async.mapLimit(ctx.conf.orks, concurrency, async (store) => { + const allItems = [] + let offset = 0 + + while (true) { + const batch = await ctx.net_r0.jRequest( + store.rpcPublicKey, + method, + { ...params, limit: pageLimit, offset }, + { timeout } + ) + + if (!Array.isArray(batch) || batch.length === 0) break + allItems.push(...batch) + if (batch.length < pageLimit) break + offset += pageLimit + } + + return allItems + }) +} + +const _orkCall = async (ctx, method, params) => { + return ctx.ork[method](params) +} + +const createDataProxy = (ctx) => { + return { + async requestData (method, params, errorHandler = null) { + if (ctx.isRpcMode === false) return _orkCall(ctx, method, params) + return _rpcEachLimit(ctx, method, params, errorHandler) + }, + + async requestDataMap (method, params) { + if (ctx.isRpcMode === false) return _orkCall(ctx, method, params) + return _rpcMapLimit(ctx, method, params) + }, + + async requestDataAllPages (method, params, pageLimit = RPC_PAGE_LIMIT) { + if (ctx.isRpcMode === false) return _orkCall(ctx, method, params) + return _rpcMapAllPages(ctx, method, params, pageLimit) + } + } +} + +module.exports = { + createDataProxy +} diff --git a/workers/lib/server/handlers/actions.handlers.js b/workers/lib/server/handlers/actions.handlers.js index 20df114..bd64531 100644 --- a/workers/lib/server/handlers/actions.handlers.js +++ b/workers/lib/server/handlers/actions.handlers.js @@ -1,13 +1,13 @@ 'use strict' -const { requestRpcEachLimit, parseJsonQueryParam } = require('../../utils') +const { parseJsonQueryParam } = require('../../utils') async function queryActionsBatch (ctx, req) { const payload = { ids: req.query.ids.split(',') } - return await requestRpcEachLimit(ctx, 'getActionsBatch', payload, (res, resultsArray) => { + return await ctx.dataProxy.requestData('getActionsBatch', payload, (res, resultsArray) => { if (res.error) { console.error(new Date().toISOString(), res.error) } else { @@ -29,7 +29,7 @@ async function queryActions (ctx, req, rep) { payload.suffix = req.query.suffix } - return await requestRpcEachLimit(ctx, 'queryActions', payload) + return await ctx.dataProxy.requestData('queryActions', payload) } async function getAction (ctx, req) { @@ -38,7 +38,7 @@ async function getAction (ctx, req) { type: req.params.type } - return await requestRpcEachLimit(ctx, 'getAction', payload) + return await ctx.dataProxy.requestData('getAction', payload) } async function pushActionsBatch (ctx, req, rep) { @@ -61,7 +61,7 @@ async function pushActionsBatch (ctx, req, rep) { authPerms: permissions } - return await requestRpcEachLimit(ctx, 'pushActionsBatch', payload, (res, resultsArray) => { + return await ctx.dataProxy.requestData('pushActionsBatch', payload, (res, resultsArray) => { if (res.error) { resultsArray.push({ id: null, errors: [res.error] }) } else { @@ -84,7 +84,7 @@ async function pushAction (ctx, req) { authPerms: permissions } - return await requestRpcEachLimit(ctx, 'pushAction', payload, (res, resultsArray) => { + return await ctx.dataProxy.requestData('pushAction', payload, (res, resultsArray) => { if (res.error) { resultsArray.push({ id: null, errors: [res.error] }) } else { @@ -106,7 +106,7 @@ async function voteAction (ctx, req) { authPerms: caps } - return await requestRpcEachLimit(ctx, 'voteAction', payload, (res, resultsArray) => { + return await ctx.dataProxy.requestData('voteAction', payload, (res, resultsArray) => { if (res.error) { resultsArray.push({ res: { success: false, error: res.error } }) } else { @@ -126,7 +126,7 @@ async function cancelActionsBatch (ctx, req) { voter: req._info.user.metadata.email } - return await requestRpcEachLimit(ctx, 'cancelActionsBatch', payload, (res, resultsArray) => { + return await ctx.dataProxy.requestData('cancelActionsBatch', payload, (res, resultsArray) => { if (res.error) { resultsArray.push({ res: { success: false, error: res.error } }) } else { diff --git a/workers/lib/server/handlers/alerts.handlers.js b/workers/lib/server/handlers/alerts.handlers.js index dc66059..7e864a7 100644 --- a/workers/lib/server/handlers/alerts.handlers.js +++ b/workers/lib/server/handlers/alerts.handlers.js @@ -11,7 +11,7 @@ const { HISTORY_FILTER_FIELDS, HISTORY_SEARCH_FIELDS } = require('../../constants') -const { requestRpcMapLimit, parseJsonQueryParam, matchesFilter, deduplicateAlerts } = require('../../utils') +const { parseJsonQueryParam, matchesFilter, deduplicateAlerts } = require('../../utils') function extractAlertsFromThings (things) { const alerts = [] @@ -95,7 +95,7 @@ async function getSiteAlerts (ctx, req) { const offset = Number(req.query.offset) || 0 const limit = Math.min(Number(req.query.limit) || ALERTS_DEFAULT_LIMIT, ALERTS_MAX_SITE_LIMIT) - const results = await requestRpcMapLimit(ctx, RPC_METHODS.LIST_THINGS, { + const results = await ctx.dataProxy.requestDataMap(RPC_METHODS.LIST_THINGS, { status: 1, query: { 'last.alerts': { $ne: null } }, fields: { @@ -137,7 +137,7 @@ async function getAlertsHistory (ctx, req) { const offset = Number(req.query.offset) || 0 const limit = Math.min(Number(req.query.limit) || ALERTS_DEFAULT_LIMIT, ALERTS_MAX_HISTORY_LIMIT) - const results = await requestRpcMapLimit(ctx, RPC_METHODS.GET_HISTORICAL_LOGS, { + const results = await ctx.dataProxy.requestDataMap(RPC_METHODS.GET_HISTORICAL_LOGS, { start, end, logType: 'alerts' diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index aecfc36..9a3256f 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -9,7 +9,6 @@ const { GLOBAL_DATA_TYPES } = require('../../constants') const { - requestRpcEachLimit, getStartOfDay, safeDiv, runParallel @@ -42,7 +41,7 @@ async function getEnergyBalance (ctx, req) { uteEnergyResults, globalConfigResults ] = await runParallel([ - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [{ type: WORKER_TYPES.POWERMETER, startDate, @@ -52,17 +51,17 @@ async function getEnergyBalance (ctx, req) { }] }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MINERPOOL, query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'HISTORICAL_PRICES', start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), @@ -70,17 +69,17 @@ async function getEnergyBalance (ctx, req) { (cb) => getProductionCosts(ctx, start, end) .then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.ELECTRICITY, query: { key: 'stats-history', start, end, groupRange: '1D' } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.ELECTRICITY, query: { key: 'stats-history', start, end, groupRange: '1D' } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GLOBAL_CONFIG, {}) + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GLOBAL_CONFIG, {}) .then(r => cb(null, r)).catch(cb) ]) @@ -320,12 +319,12 @@ async function getEbitda (ctx, req) { const endDate = new Date(end).toISOString() const [transactionResults, tailLogResults, priceResults, currentPriceResults, productionCosts] = await runParallel([ - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MINERPOOL, query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [ { type: WORKER_TYPES.POWERMETER, @@ -344,12 +343,12 @@ async function getEbitda (ctx, req) { ] }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'prices', start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), @@ -521,12 +520,12 @@ async function getCostSummary (ctx, req) { (cb) => getProductionCosts(ctx, start, end) .then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'prices', start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [{ type: WORKER_TYPES.POWERMETER, startDate, @@ -619,7 +618,7 @@ async function getSubsidyFees (ctx, req) { const { start, end } = validateStartEnd(req) const period = req.query.period || PERIOD_TYPES.DAILY - const blockResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + const blockResults = await ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'HISTORICAL_BLOCKSIZES', start, end } }) @@ -677,7 +676,7 @@ async function getRevenue (ctx, req) { const type = pool ? WORKER_TYPES.MINERPOOL + '-' + pool : WORKER_TYPES.MINERPOOL const query = { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } - const transactionResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + const transactionResults = await ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type, query }) @@ -747,22 +746,22 @@ async function getRevenueSummary (ctx, req) { uteEnergyResults, globalConfigResults ] = await runParallel([ - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MINERPOOL, query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'HISTORICAL_PRICES', start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [ { type: WORKER_TYPES.POWERMETER, @@ -784,22 +783,22 @@ async function getRevenueSummary (ctx, req) { (cb) => getProductionCosts(ctx, start, end) .then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'HISTORICAL_BLOCKSIZES', start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.ELECTRICITY, query: { key: 'stats-history', start, end, groupRange: '1D' } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.ELECTRICITY, query: { key: 'stats-history', start, end, groupRange: '1D' } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GLOBAL_CONFIG, {}) + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GLOBAL_CONFIG, {}) .then(r => cb(null, r)).catch(cb) ]) diff --git a/workers/lib/server/handlers/global.handlers.js b/workers/lib/server/handlers/global.handlers.js index 6f5fdcb..1de48e3 100644 --- a/workers/lib/server/handlers/global.handlers.js +++ b/workers/lib/server/handlers/global.handlers.js @@ -1,6 +1,6 @@ 'use strict' const { GLOBAL_DATA_TYPES } = require('../../constants') -const { parseJsonQueryParam, requestRpcMapLimit } = require('../../utils') +const { parseJsonQueryParam } = require('../../utils') async function getGlobalData (ctx, req) { const type = req.query.type @@ -63,11 +63,11 @@ async function getGlobalConfig (ctx, req, rep) { req.query.fields = parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') } - return await requestRpcMapLimit(ctx, 'getGlobalConfig', req.query) + return await ctx.dataProxy.requestDataMap('getGlobalConfig', req.query) } async function setGlobalConfig (ctx, req, rep) { - return await requestRpcMapLimit(ctx, 'setGlobalConfig', req.body.data) + return await ctx.dataProxy.requestDataMap('setGlobalConfig', req.body.data) } module.exports = { diff --git a/workers/lib/server/handlers/logs.handlers.js b/workers/lib/server/handlers/logs.handlers.js index 1cc02ec..e2e2e82 100644 --- a/workers/lib/server/handlers/logs.handlers.js +++ b/workers/lib/server/handlers/logs.handlers.js @@ -1,10 +1,6 @@ 'use strict' -const { - parseJsonQueryParam, - requestRpcMapLimit, - requestRpcEachLimit -} = require('../../utils') +const { parseJsonQueryParam } = require('../../utils') async function tailLogRoute (ctx, req, rep) { if (req.query.fields) { @@ -23,7 +19,7 @@ async function tailLogRoute (ctx, req, rep) { } } - return await requestRpcMapLimit(ctx, 'tailLog', req.query) + return await ctx.dataProxy.requestDataMap('tailLog', req.query) } async function tailLogMultiRoute (ctx, req, rep) { @@ -51,11 +47,11 @@ async function tailLogMultiRoute (ctx, req, rep) { } } - return await requestRpcMapLimit(ctx, 'tailLogMulti', req.query) + return await ctx.dataProxy.requestDataMap('tailLogMulti', req.query) } async function tailLogRangeAggrRoute (ctx, req, rep) { - return await requestRpcEachLimit(ctx, 'tailLogCustomRangeAggr', req.query) + return await ctx.dataProxy.requestData('tailLogCustomRangeAggr', req.query) } async function getHistoryLogRoute (ctx, req) { @@ -66,7 +62,7 @@ async function getHistoryLogRoute (ctx, req) { req.query.query = parseJsonQueryParam(req.query.query, 'ERR_QUERY_INVALID_JSON') } - return await requestRpcMapLimit(ctx, 'getHistoricalLogs', req.query) + return await ctx.dataProxy.requestDataMap('getHistoricalLogs', req.query) } module.exports = { diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index 42a4be9..c7c5420 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -12,8 +12,6 @@ const { DEVICE_LIST_FIELDS } = require('../../constants') const { - requestRpcEachLimit, - requestRpcMapAllPages, getStartOfDay, safeDiv } = require('../../utils') @@ -34,7 +32,7 @@ async function getHashrate (ctx, req) { const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [{ type: WORKER_TYPES.MINER, startDate, @@ -88,7 +86,7 @@ async function getConsumption (ctx, req) { const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [{ type: WORKER_TYPES.POWERMETER, startDate, @@ -148,7 +146,7 @@ async function getEfficiency (ctx, req) { const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [{ type: WORKER_TYPES.MINER, startDate, @@ -200,7 +198,7 @@ function calculateEfficiencySummary (log) { async function getMinerStatus (ctx, req) { const { start, end } = validateStartEnd(req) - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, { + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, { key: LOG_KEYS.STAT_3H, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER, @@ -301,7 +299,7 @@ async function getPowerMode (ctx, req) { rpcPayload.groupRange = config.groupRange } - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, rpcPayload) + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, rpcPayload) const timePoints = processPowerModeData(results, config.groupRange) const log = Object.keys(timePoints).sort().map(ts => ({ @@ -397,7 +395,7 @@ async function getPowerModeTimeline (ctx, req) { end } - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, rpcPayload) + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, rpcPayload) const log = processPowerModeTimelineData(results, container) @@ -480,7 +478,7 @@ async function getTemperature (ctx, req) { rpcPayload.groupRange = config.groupRange } - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, rpcPayload) + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, rpcPayload) const timePoints = processTemperatureData(results, config.groupRange, container) const log = Object.keys(timePoints).sort().map(ts => ({ @@ -569,11 +567,11 @@ async function getContainerTelemetry (ctx, req) { const containerTag = `container-${containerId}` const [minersResults, sensorResults] = await Promise.all([ - requestRpcMapAllPages(ctx, RPC_METHODS.LIST_THINGS, { + ctx.dataProxy.requestDataAllPages(RPC_METHODS.LIST_THINGS, { query: { tags: { $in: [containerTag] } }, fields: DEVICE_LIST_FIELDS }), - requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, { + ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, { key: LOG_KEYS.STAT_5M, type: WORKER_TYPES.CONTAINER, tag: WORKER_TAGS.CONTAINER, @@ -644,7 +642,7 @@ async function getContainerHistory (ctx, req) { throw new Error('ERR_INVALID_DATE_RANGE') } - const results = await requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG, { + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, { key: LOG_KEYS.STAT_5M, type: WORKER_TYPES.CONTAINER, tag: WORKER_TAGS.CONTAINER, diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 0ec7ffe..0cf0b3e 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -9,8 +9,6 @@ const { RANGE_BUCKETS } = require('../../constants') const { - requestRpcMapLimit, - requestRpcEachLimit, parseJsonQueryParam, getStartOfDay } = require('../../utils') @@ -21,7 +19,7 @@ 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 statsResults = await requestRpcMapLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + const statsResults = await ctx.dataProxy.requestDataMap(RPC_METHODS.GET_WRK_EXT_DATA, { type: 'minerpool', query: { key: MINERPOOL_EXT_DATA_KEYS.STATS } }) @@ -111,7 +109,7 @@ async function getPoolBalanceHistory (ctx, req) { throw new Error('ERR_INVALID_DATE_RANGE') } - const results = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + const results = await ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: 'minerpool', query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end, pool: poolFilter } }) @@ -208,7 +206,7 @@ async function getPoolStatsAggregate (ctx, req) { throw new Error('ERR_INVALID_DATE_RANGE') } - const transactionResults = await requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + const transactionResults = await ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MINERPOOL, query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end, pool: poolFilter } }) diff --git a/workers/lib/server/handlers/site.handlers.js b/workers/lib/server/handlers/site.handlers.js index 18066b5..3e46224 100644 --- a/workers/lib/server/handlers/site.handlers.js +++ b/workers/lib/server/handlers/site.handlers.js @@ -1,7 +1,5 @@ 'use strict' -const { requestRpcMapLimit } = require('../../utils') - /** * Extracts the latest entry from a tail-log key result. * tailLogMulti returns results per key in order. @@ -275,9 +273,9 @@ async function getSiteLiveStatus (ctx, req) { const [tailLogResults, poolDataResults, globalConfigResults] = await Promise.all([ - requestRpcMapLimit(ctx, 'tailLogMulti', tailLogPayload), - requestRpcMapLimit(ctx, 'getWrkExtData', poolPayload), - requestRpcMapLimit(ctx, 'getGlobalConfig', globalConfigPayload) + ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload), + ctx.dataProxy.requestDataMap('getWrkExtData', poolPayload), + ctx.dataProxy.requestDataMap('getGlobalConfig', globalConfigPayload) ]) return composeSiteStatus( diff --git a/workers/lib/server/handlers/things.handlers.js b/workers/lib/server/handlers/things.handlers.js index 805bf7f..6fb007a 100644 --- a/workers/lib/server/handlers/things.handlers.js +++ b/workers/lib/server/handlers/things.handlers.js @@ -4,7 +4,7 @@ const { AUTH_LEVELS, COMMENT_ACTION } = require('../../constants') -const { parseJsonQueryParam, requestRpcMapLimit, requestRpcEachLimit } = require('../../utils') +const { parseJsonQueryParam } = require('../../utils') async function listThingsRoute (ctx, req, rep) { if (req.query.query) { @@ -18,7 +18,7 @@ async function listThingsRoute (ctx, req, rep) { req.query.fields = parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') } - return await requestRpcMapLimit(ctx, 'listThings', req.query) + return await ctx.dataProxy.requestDataMap('listThings', req.query) } async function listRacksRoute (ctx, req, rep) { @@ -30,7 +30,7 @@ async function listRacksRoute (ctx, req, rep) { throw new Error('ERR_KEYS_NOT_ALLOWED') } - return await requestRpcMapLimit(ctx, 'listRacks', req.query) + return await ctx.dataProxy.requestDataMap('listRacks', req.query) } async function getThingSettings (ctx, req, rep) { @@ -38,7 +38,7 @@ async function getThingSettings (ctx, req, rep) { rackId: req.query.rackId } - return await requestRpcEachLimit(ctx, 'getWrkSettings', payload, (res, resultsArray) => { + return await ctx.dataProxy.requestData('getWrkSettings', payload, (res, resultsArray) => { if (res.error) { resultsArray.push({ error: res.error }) } else { @@ -58,7 +58,7 @@ async function saveThingSettings (ctx, req, rep) { entries: req.body.entries } - return await requestRpcEachLimit(ctx, 'saveWrkSettings', payload, (res, resultsArray) => { + return await ctx.dataProxy.requestData('saveWrkSettings', payload, (res, resultsArray) => { if (res.error) { resultsArray.push({ error: res.error }) } else { @@ -84,7 +84,7 @@ async function processThingComment (ctx, req, operation = COMMENT_ACTION.ADD) { user: req._info.user.metadata.email } - return await requestRpcEachLimit(ctx, operation, payload, (res, resultsArray) => { + return await ctx.dataProxy.requestData(operation, payload, (res, resultsArray) => { if (res.error) { resultsArray.push({ error: res.error }) } else { @@ -98,11 +98,11 @@ async function getWorkerConfig (ctx, req, rep) { req.query.fields = parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') } - return await requestRpcMapLimit(ctx, 'getWrkConf', req.query) + return await ctx.dataProxy.requestDataMap('getWrkConf', req.query) } async function getThingConfig (ctx, req, rep) { - return await requestRpcMapLimit(ctx, 'getThingConf', req.query) + return await ctx.dataProxy.requestDataMap('getThingConf', req.query) } module.exports = { diff --git a/workers/lib/server/services/poolManager.js b/workers/lib/server/services/poolManager.js index 0059448..38883a2 100644 --- a/workers/lib/server/services/poolManager.js +++ b/workers/lib/server/services/poolManager.js @@ -7,10 +7,6 @@ const { RPC_METHODS, MINERPOOL_EXT_DATA_KEYS } = require('../../constants') -const { - requestRpcMapLimit, - requestRpcMapAllPages -} = require('../../utils') const getPoolStats = async (ctx) => { const pools = await _fetchPoolStats(ctx) @@ -37,7 +33,7 @@ const getPoolConfigs = async (ctx) => { const getMinersWithPools = async (ctx, filters = {}) => { const { search, model, page = 1, limit = 50 } = filters - const results = await requestRpcMapAllPages(ctx, LIST_THINGS, { + const results = await ctx.dataProxy.requestDataAllPages(LIST_THINGS, { type: WORKER_TYPES.MINER, query: {}, fields: { id: 1, code: 1, type: 1, info: 1, address: 1 } @@ -96,7 +92,7 @@ const getMinersWithPools = async (ctx, filters = {}) => { } const getUnitsWithPoolData = async (ctx) => { - const results = await requestRpcMapAllPages(ctx, LIST_THINGS, { + const results = await ctx.dataProxy.requestDataAllPages(LIST_THINGS, { type: WORKER_TYPES.MINER, query: {}, fields: { id: 1, type: 1, info: 1 } @@ -136,7 +132,7 @@ const getUnitsWithPoolData = async (ctx) => { const getPoolAlerts = async (ctx, filters = {}) => { const { limit = 50 } = filters - const results = await requestRpcMapAllPages(ctx, LIST_THINGS, { + const results = await ctx.dataProxy.requestDataAllPages(LIST_THINGS, { type: WORKER_TYPES.MINER, query: {}, fields: { id: 1, code: 1, type: 1, info: 1, alerts: 1 } @@ -172,7 +168,7 @@ const getPoolAlerts = async (ctx, filters = {}) => { } async function _fetchPoolStats (ctx) { - const results = await requestRpcMapLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + const results = await ctx.dataProxy.requestDataMap(RPC_METHODS.GET_WRK_EXT_DATA, { type: 'minerpool', query: { key: MINERPOOL_EXT_DATA_KEYS.STATS } }) From da7391153270007510f1e953fa4dbaaebddbdae9 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 12 Mar 2026 01:46:56 +0300 Subject: [PATCH 14/63] fix: address OWASP security review findings for v2 API endpoints (#30) --- tests/unit/handlers/metrics.handlers.test.js | 28 ++++++++++++++++ workers/http.node.wrk.js | 9 +++++- workers/lib/constants.js | 1 - workers/lib/server/routes/metrics.routes.js | 3 +- workers/lib/server/schemas/alerts.schemas.js | 4 +-- workers/lib/server/schemas/finance.schemas.js | 24 +++++++------- workers/lib/server/schemas/metrics.schemas.js | 32 +++++++++---------- workers/lib/server/schemas/pools.schemas.js | 8 ++--- 8 files changed, 71 insertions(+), 38 deletions(-) diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index e039068..ce5c522 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -1128,6 +1128,34 @@ test('getPowerModeTimeline - always uses t-miner tag', async (t) => { t.pass() }) +test('getPowerModeTimeline - returns all results without truncation', async (t) => { + const mockCtx = { + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + const entries = [] + for (let i = 0; i < 5; i++) { + entries.push({ + ts: 1700000000000 + i * 10800000, + power_mode_group_aggr: { [`cont${i}-miner1`]: 'normal' }, + status_group_aggr: { [`cont${i}-miner1`]: 'mining' } + }) + } + return entries + } + } + } + + const result = await getPowerModeTimeline(mockCtx, { + query: { start: 1700000000000, end: 1700100000000 } + }) + + t.is(result.log.length, 5, 'should return all results') + t.pass() +}) + test('processPowerModeTimelineData - filters by container post-RPC', (t) => { const results = [[ { diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index 8d918d5..2d425a1 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -104,10 +104,17 @@ class WrkServerHttp extends TetherWrkBase { }) httpd.addHook('onError', async (request, reply, error) => { + const isSafe = error.message && error.message.startsWith('ERR_') + const message = isSafe ? error.message : 'Bad Request' + + if (!isSafe) { + debug('onError handler:', error.message) + } + return reply.status(400).send({ statusCode: 400, error: 'Bad Request', - message: error.message + message }) }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index b6227c0..b674103 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -268,7 +268,6 @@ const METRICS_TIME = { } const METRICS_DEFAULTS = { - TIMELINE_LIMIT: 10080, CONTAINER_HISTORY_LIMIT: 10080 } diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js index f2f27d4..c6a59ac 100644 --- a/workers/lib/server/routes/metrics.routes.js +++ b/workers/lib/server/routes/metrics.routes.js @@ -119,8 +119,7 @@ module.exports = (ctx) => { 'metrics/power-mode/timeline', req.query.start, req.query.end, - req.query.container, - req.query.limit + req.query.container ], ENDPOINTS.METRICS_POWER_MODE_TIMELINE, getPowerModeTimeline diff --git a/workers/lib/server/schemas/alerts.schemas.js b/workers/lib/server/schemas/alerts.schemas.js index 5173c31..47bb212 100644 --- a/workers/lib/server/schemas/alerts.schemas.js +++ b/workers/lib/server/schemas/alerts.schemas.js @@ -16,8 +16,8 @@ const schemas = { alertsHistory: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, filter: { type: 'string' }, search: { type: 'string' }, sort: { type: 'string' }, diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index e6e5c7b..2e40745 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -5,8 +5,8 @@ const schemas = { energyBalance: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, overwriteCache: { type: 'boolean' } }, @@ -15,8 +15,8 @@ const schemas = { ebitda: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, overwriteCache: { type: 'boolean' } }, @@ -25,8 +25,8 @@ const schemas = { costSummary: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, overwriteCache: { type: 'boolean' } }, @@ -35,8 +35,8 @@ const schemas = { subsidyFees: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, period: { type: 'string', enum: ['daily', 'weekly', 'monthly'] }, overwriteCache: { type: 'boolean' } }, @@ -45,8 +45,8 @@ const schemas = { revenue: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, period: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'yearly'] }, pool: { type: 'string' }, overwriteCache: { type: 'boolean' } @@ -56,8 +56,8 @@ const schemas = { revenueSummary: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, overwriteCache: { type: 'boolean' } }, diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index 2a149f8..c21404c 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -5,8 +5,8 @@ const schemas = { hashrate: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] @@ -14,8 +14,8 @@ const schemas = { consumption: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] @@ -23,8 +23,8 @@ const schemas = { efficiency: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] @@ -32,8 +32,8 @@ const schemas = { minerStatus: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] @@ -41,8 +41,8 @@ const schemas = { powerMode: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, interval: { type: 'string', enum: ['1h', '1d', '1w'] }, overwriteCache: { type: 'boolean' } }, @@ -51,8 +51,8 @@ const schemas = { powerModeTimeline: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, container: { type: 'string' }, overwriteCache: { type: 'boolean' } } @@ -60,8 +60,8 @@ const schemas = { temperature: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, interval: { type: 'string', enum: ['1h', '1d', '1w'] }, container: { type: 'string' }, overwriteCache: { type: 'boolean' } @@ -77,8 +77,8 @@ const schemas = { containerHistory: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, limit: { type: 'integer' }, overwriteCache: { type: 'boolean' } } diff --git a/workers/lib/server/schemas/pools.schemas.js b/workers/lib/server/schemas/pools.schemas.js index d5dcd6f..ec053cb 100644 --- a/workers/lib/server/schemas/pools.schemas.js +++ b/workers/lib/server/schemas/pools.schemas.js @@ -14,8 +14,8 @@ const schemas = { balanceHistory: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, range: { type: 'string', enum: ['1D', '1W', '1M'] }, overwriteCache: { type: 'boolean' } }, @@ -24,8 +24,8 @@ const schemas = { poolStatsAggregate: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, range: { type: 'string', enum: ['daily', 'weekly', 'monthly'] }, pool: { type: 'string' }, overwriteCache: { type: 'boolean' } From cb37c128669b167ecfbbc56bf72664752e342489 Mon Sep 17 00:00:00 2001 From: andretetherio Date: Thu, 12 Mar 2026 13:26:44 -0300 Subject: [PATCH 15/63] ci: enforce coverage (#31) --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f17fa4..842a691 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,8 @@ jobs: needs: setup permissions: contents: read + outputs: + coverage_result: ${{ steps.coverage-summary.outputs.coverage_result }} steps: - name: Checkout repository @@ -168,10 +170,46 @@ jobs: fi - name: Run tests with coverage - continue-on-error: true + id: run-tests run: | echo "Running tests with coverage..." - npm run test:coverage --if-present || npm test --if-present || echo "::warning::Tests failed but continuing workflow" + npm run test:coverage --if-present || npm test --if-present || echo "::warning::No test:coverage or test script; skipping" + + - name: Coverage (threshold + summary) + id: coverage-summary + if: always() + run: | + MIN=80 + # Vitest: detect by config (handles its own thresholds); brittle uses Istanbul + nyc check. + if [ -f vitest.config.js ] || [ -f vitest.config.ts ]; then + if [ -f coverage/coverage-final.json ]; then + if [ "${{ steps.run-tests.outcome }}" = "success" ]; then + STATUS_CELL="✅ PASS" + else + STATUS_CELL="❌ FAIL (coverage below threshold in vitest.config.js)" + fi + echo "coverage_result=$STATUS_CELL Vitest (thresholds in vitest.config.js)" >> "$GITHUB_OUTPUT" + else + echo "coverage_result=⚠️ No coverage report" >> "$GITHUB_OUTPUT" + fi + elif [ -f coverage/coverage-final.json ]; then + mkdir -p .nyc_output + cp coverage/coverage-final.json .nyc_output/out.json + set +e + echo "Running nyc check-coverage (threshold ${MIN}%)..." + npx --yes nyc check-coverage --lines=$MIN --statements=$MIN --functions=$MIN --branches=$MIN + EXIT=$? + set -e + echo "Running nyc report (text-summary)..." + npx --yes nyc report --reporter=text-summary 2>&1 | tee report-summary.txt + PCT=$(sed -n 's/^Lines[^:]*: *\([0-9.]*\)%.*/\1/p' report-summary.txt) + [ -z "$PCT" ] && PCT="—" + [ $EXIT -eq 0 ] && ICON="✅" || ICON="❌" + echo "coverage_result=$ICON Coverage ${PCT}% (min ${MIN}%)" >> "$GITHUB_OUTPUT" + exit $EXIT + else + echo "coverage_result=⚠️ No coverage report" >> "$GITHUB_OUTPUT" + fi - name: Run build (optional) continue-on-error: true @@ -265,6 +303,7 @@ jobs: R_SUPPLY: ${{ needs.supply-chain.result || 'skipped' }} R_LINT: ${{ needs.lint.result || 'skipped' }} R_TEST: ${{ needs.test-and-sonarqube.result || 'skipped' }} + COVERAGE: ${{ needs.test-and-sonarqube.outputs.coverage_result || '—' }} run: | { echo "## 📊 CI Pipeline Summary" @@ -280,6 +319,7 @@ jobs: [ "${R_SUPPLY}" != "skipped" ] && echo "- 🔐 Supply Chain: ${R_SUPPLY:-?}" [ "${R_LINT}" != "skipped" ] && echo "- ✨ Lint: ${R_LINT:-?}" [ "${R_TEST}" != "skipped" ] && echo "- 🧪 Test & SonarQube: ${R_TEST:-?}" + echo "- 📊 Coverage: ${COVERAGE}" echo "" echo "### 🔧 Pipeline" echo "- ✅ Dependency review (PR only)" From 28d479e3c2a2c5a51f73ecf202159651660bd1bb Mon Sep 17 00:00:00 2001 From: tekwani Date: Fri, 13 Mar 2026 03:38:17 +0530 Subject: [PATCH 16/63] feat: pool config stats (#32) * feat: pool config stats * var name typo fix --- tests/integration/api.poolManager.test.js | 51 ++++++ tests/unit/handlers/auth.handlers.test.js | 30 +--- tests/unit/handlers/configs.handlers.test.js | 137 ++++++-------- tests/unit/handlers/devices.handlers.test.js | 109 +++++------- tests/unit/handlers/finance.handlers.test.js | 16 +- tests/unit/handlers/metrics.handlers.test.js | 4 +- tests/unit/handlers/pools.handlers.test.js | 122 ++++++++++++- tests/unit/lib/utils.test.js | 167 +----------------- tests/unit/routes/pools.routes.test.js | 1 + tests/unit/services.poolManager.test.js | 1 - workers/lib/constants.js | 1 + workers/lib/server/handlers/auth.handlers.js | 4 +- .../lib/server/handlers/configs.handlers.js | 21 ++- .../lib/server/handlers/devices.handlers.js | 13 +- .../lib/server/handlers/finance.handlers.js | 16 +- workers/lib/server/handlers/pools.handlers.js | 22 ++- workers/lib/server/routes/pools.routes.js | 13 +- workers/lib/utils.js | 95 +--------- 18 files changed, 356 insertions(+), 467 deletions(-) diff --git a/tests/integration/api.poolManager.test.js b/tests/integration/api.poolManager.test.js index a749e92..2bd5a87 100644 --- a/tests/integration/api.poolManager.test.js +++ b/tests/integration/api.poolManager.test.js @@ -131,6 +131,16 @@ test('Pool Manager API', { timeout: 90000 }, async (main) => { await worker.start() worker.worker.net_r0.jRequest = (publicKey, method, params) => { if (method === 'listThings') { + if (params?.query?.id) { + const thing = mockMiners.find(m => m.id === params.query.id) + if (!thing) return Promise.resolve([]) + const withRackAndInfo = { + ...thing, + rack: thing.id.startsWith('miner-') ? 'miner-am-s19xp' : 'container-unit-a', + info: { ...thing.info, container: thing.tags?.unit || 'unit-A', poolConfig: thing.snap?.config?.pool_config ? 'stratum+tcp://btc.f2pool.com:3333' : null } + } + return Promise.resolve([withRackAndInfo]) + } return Promise.resolve(mockMiners) } if (method === 'getWrkExtData') { @@ -344,4 +354,45 @@ test('Pool Manager API', { timeout: 90000 }, async (main) => { }) }) + await main.test('Api: auth/pools/config/:id', async (n) => { + const thingId = 'miner-001' + const api = `${appNodeBaseUrl}/auth/pools/config/${thingId}` + + await n.test('api should fail for missing auth token', async (t) => { + try { + await httpClient.get(api, { encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('api should succeed and return pool config for thing', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + try { + const res = await httpClient.get(api, { headers, encoding }) + t.ok(res.body) + t.ok('poolConfig' in res.body) + t.ok(typeof res.body.overriddenConfig === 'number') + t.pass() + } catch (e) { + console.error('Pool thing config error:', e) + t.fail() + } + }) + + await n.test('api should return 404 for unknown thing id', async (t) => { + const token = await getTestToken(testUser) + const headers = { Authorization: `Bearer ${token}` } + const unknownApi = `${appNodeBaseUrl}/auth/pools/config/nonexistent-thing-id` + try { + await httpClient.get(unknownApi, { headers, encoding }) + t.fail() + } catch (e) { + t.ok(e.response?.message?.includes('ERR_THING_NOT_FOUND') || e.code === 'ERR_HTTP_REQUEST_FAILED' || e.statusCode === 404) + t.pass() + } + }) + }) }) diff --git a/tests/unit/handlers/auth.handlers.test.js b/tests/unit/handlers/auth.handlers.test.js index 52de1c1..bf45b17 100644 --- a/tests/unit/handlers/auth.handlers.test.js +++ b/tests/unit/handlers/auth.handlers.test.js @@ -2,6 +2,7 @@ const test = require('brittle') const { getSiteName, extDataRoute, getUserInfo, newAuthToken, getUserPermissions } = require('../../../workers/lib/server/handlers/auth.handlers') +const { createMockCtxWithOrks } = require('../helpers/mockHelpers') test('getSiteName - returns site from context', (t) => { const mockCtx = { @@ -16,17 +17,10 @@ test('getSiteName - returns site from context', (t) => { }) test('extDataRoute - with query param', async (t) => { - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' }, - { rpcPublicKey: 'key2' } - ] - }, - net_r0: { - jRequest: async () => ({ data: 'test' }) - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }, { rpcPublicKey: 'key2' }], + async () => ({ data: 'test' }) + ) const mockReq = { query: { @@ -42,16 +36,10 @@ test('extDataRoute - with query param', async (t) => { }) test('extDataRoute - without query param', async (t) => { - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' } - ] - }, - net_r0: { - jRequest: async () => ({ data: 'test' }) - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => ({ data: 'test' }) + ) const mockReq = { query: { diff --git a/tests/unit/handlers/configs.handlers.test.js b/tests/unit/handlers/configs.handlers.test.js index 463255c..064bd5a 100644 --- a/tests/unit/handlers/configs.handlers.test.js +++ b/tests/unit/handlers/configs.handlers.test.js @@ -2,24 +2,23 @@ const test = require('brittle') const { getConfigs } = require('../../../workers/lib/server/handlers/configs.handlers') +const { RPC_METHODS } = require('../../../workers/lib/constants') +const { createMockCtxWithOrks } = require('../helpers/mockHelpers') test('getConfigs - happy path', async (t) => { - const mockCtx = { - conf: { - orks: [{ rpcPublicKey: 'key1' }] - }, - net_r0: { - jRequest: async (key, method, payload) => { - if (method === 'getConfigs') { - return [ - { id: 'config1', name: 'Pool Config 1', url: 'stratum://pool1.example.com' }, - { id: 'config2', name: 'Pool Config 2', url: 'stratum://pool2.example.com' } - ] - } - return [] + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async (key, method, payload) => { + if (method === 'getConfigs') { + return [ + { id: 'config1', name: 'Pool Config 1', url: 'stratum://pool1.example.com' }, + { id: 'config2', name: 'Pool Config 2', url: 'stratum://pool2.example.com' } + ] } + if (method === RPC_METHODS.LIST_THINGS) return [] + return [] } - } + ) const mockReq = { params: { type: 'pool' }, @@ -35,17 +34,15 @@ test('getConfigs - happy path', async (t) => { test('getConfigs - with query filter', async (t) => { let capturedPayload = null - const mockCtx = { - conf: { - orks: [{ rpcPublicKey: 'key1' }] - }, - net_r0: { - jRequest: async (key, method, payload) => { - capturedPayload = payload - return [{ id: 'config1', active: true }] - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async (key, method, payload) => { + if (method === 'getConfigs') capturedPayload = payload + if (method === 'getConfigs') return [{ id: 'config1', active: true }] + if (method === RPC_METHODS.LIST_THINGS) return [] + return [] } - } + ) const mockReq = { params: { type: 'pool' }, @@ -60,17 +57,15 @@ test('getConfigs - with query filter', async (t) => { test('getConfigs - with fields projection', async (t) => { let capturedPayload = null - const mockCtx = { - conf: { - orks: [{ rpcPublicKey: 'key1' }] - }, - net_r0: { - jRequest: async (key, method, payload) => { - capturedPayload = payload - return [{ name: 'Config 1' }] - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async (key, method, payload) => { + if (method === 'getConfigs') capturedPayload = payload + if (method === 'getConfigs') return [{ name: 'Config 1' }] + if (method === RPC_METHODS.LIST_THINGS) return [] + return [] } - } + ) const mockReq = { params: { type: 'pool' }, @@ -86,17 +81,15 @@ test('getConfigs - with fields projection', async (t) => { test('getConfigs - with both query and fields', async (t) => { let capturedPayload = null - const mockCtx = { - conf: { - orks: [{ rpcPublicKey: 'key1' }] - }, - net_r0: { - jRequest: async (key, method, payload) => { - capturedPayload = payload - return [{ name: 'Config 1' }] - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async (key, method, payload) => { + if (method === 'getConfigs') capturedPayload = payload + if (method === 'getConfigs') return [{ name: 'Config 1' }] + if (method === RPC_METHODS.LIST_THINGS) return [] + return [] } - } + ) const mockReq = { params: { type: 'pool' }, @@ -114,10 +107,7 @@ test('getConfigs - with both query and fields', async (t) => { }) test('getConfigs - invalid config type throws', async (t) => { - const mockCtx = { - conf: { orks: [] }, - net_r0: { jRequest: async () => ([]) } - } + const mockCtx = createMockCtxWithOrks([], async () => ([])) const mockReq = { params: { type: 'invalid_type' }, @@ -134,10 +124,7 @@ test('getConfigs - invalid config type throws', async (t) => { }) test('getConfigs - missing config type throws', async (t) => { - const mockCtx = { - conf: { orks: [] }, - net_r0: { jRequest: async () => ([]) } - } + const mockCtx = createMockCtxWithOrks([], async () => ([])) const mockReq = { params: {}, @@ -154,10 +141,7 @@ test('getConfigs - missing config type throws', async (t) => { }) test('getConfigs - invalid query JSON throws', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { jRequest: async () => ([]) } - } + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => ([])) const mockReq = { params: { type: 'pool' }, @@ -174,10 +158,7 @@ test('getConfigs - invalid query JSON throws', async (t) => { }) test('getConfigs - invalid fields JSON throws', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { jRequest: async () => ([]) } - } + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => ([])) const mockReq = { params: { type: 'pool' }, @@ -194,10 +175,7 @@ test('getConfigs - invalid fields JSON throws', async (t) => { }) test('getConfigs - empty ork results', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { jRequest: async () => ([]) } - } + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => ([])) const mockReq = { params: { type: 'pool' }, @@ -211,12 +189,10 @@ test('getConfigs - empty ork results', async (t) => { }) test('getConfigs - handles error results from orks', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async () => ({ error: 'timeout' }) - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => ({ error: 'timeout' }) + ) const mockReq = { params: { type: 'pool' }, @@ -231,20 +207,15 @@ test('getConfigs - handles error results from orks', async (t) => { test('getConfigs - aggregates results from multiple orks', async (t) => { let callCount = 0 - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' }, - { rpcPublicKey: 'key2' } - ] - }, - net_r0: { - jRequest: async () => { - callCount++ - return [{ id: `config${callCount}`, name: `Config ${callCount}` }] - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }, { rpcPublicKey: 'key2' }], + async (key, method) => { + callCount++ + if (method === 'getConfigs') return [{ id: `config${callCount}`, name: `Config ${callCount}` }] + if (method === RPC_METHODS.LIST_THINGS) return [] + return [] } - } + ) const mockReq = { params: { type: 'pool' }, diff --git a/tests/unit/handlers/devices.handlers.test.js b/tests/unit/handlers/devices.handlers.test.js index 2668c06..a72a6ed 100644 --- a/tests/unit/handlers/devices.handlers.test.js +++ b/tests/unit/handlers/devices.handlers.test.js @@ -10,6 +10,7 @@ const { queryAndPaginate } = require('../../../workers/lib/server/handlers/devices.handlers') const { flattenRpcResults } = require('../../../workers/lib/utils') +const { createMockCtxWithOrks } = require('../helpers/mockHelpers') test('flattenRpcResults - flattens multi-ork arrays', (t) => { const results = [ @@ -109,15 +110,13 @@ test('queryAndPaginate - filters and paginates', (t) => { }) test('getContainers - happy path', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async () => [ - { id: 'c1', type: 'bitdeer-d40' }, - { id: 'c2', type: 'antbox-hydro' } - ] - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + { id: 'c1', type: 'bitdeer-d40' }, + { id: 'c2', type: 'antbox-hydro' } + ] + ) const result = await getContainers(mockCtx, { query: {} }) t.ok(result.containers, 'should return containers array') @@ -127,15 +126,13 @@ test('getContainers - happy path', async (t) => { }) test('getContainers - with filter', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async () => [ - { id: 'c1', type: 'bitdeer-d40', status: 'online' }, - { id: 'c2', type: 'antbox-hydro', status: 'offline' } - ] - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + { id: 'c1', type: 'bitdeer-d40', status: 'online' }, + { id: 'c2', type: 'antbox-hydro', status: 'offline' } + ] + ) const result = await getContainers(mockCtx, { query: { filter: '{"status":"online"}' } }) t.is(result.containers.length, 1, 'should filter containers') @@ -144,10 +141,7 @@ test('getContainers - with filter', async (t) => { }) test('getContainers - empty results', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { jRequest: async () => [] } - } + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => []) const result = await getContainers(mockCtx, { query: {} }) t.is(result.containers.length, 0, 'should return empty array') @@ -156,16 +150,14 @@ test('getContainers - empty results', async (t) => { }) test('getCabinets - happy path with grouping', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async () => [ - { id: 'd1', info: { pos: 'cab-A/slot1' }, tags: ['t-powermeter'] }, - { id: 'd2', info: { pos: 'cab-A/slot2' }, tags: ['t-sensor-temp'] }, - { id: 'd3', info: { pos: 'cab-B/slot1' }, tags: ['t-powermeter'] } - ] - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + { id: 'd1', info: { pos: 'cab-A/slot1' }, tags: ['t-powermeter'] }, + { id: 'd2', info: { pos: 'cab-A/slot2' }, tags: ['t-sensor-temp'] }, + { id: 'd3', info: { pos: 'cab-B/slot1' }, tags: ['t-powermeter'] } + ] + ) const result = await getCabinets(mockCtx, { query: {} }) t.ok(result.cabinets, 'should return cabinets array') @@ -179,10 +171,7 @@ test('getCabinets - happy path with grouping', async (t) => { }) test('getCabinets - empty results', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { jRequest: async () => [] } - } + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => []) const result = await getCabinets(mockCtx, { query: {} }) t.is(result.cabinets.length, 0, 'should return empty array') @@ -191,16 +180,14 @@ test('getCabinets - empty results', async (t) => { }) test('getCabinets - with pagination', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async () => [ - { id: 'd1', info: { pos: 'cab-A/slot1' } }, - { id: 'd2', info: { pos: 'cab-B/slot1' } }, - { id: 'd3', info: { pos: 'cab-C/slot1' } } - ] - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + { id: 'd1', info: { pos: 'cab-A/slot1' } }, + { id: 'd2', info: { pos: 'cab-B/slot1' } }, + { id: 'd3', info: { pos: 'cab-C/slot1' } } + ] + ) const result = await getCabinets(mockCtx, { query: { offset: '0', limit: '2' } }) t.is(result.total, 3, 'total should reflect all cabinets') @@ -209,16 +196,14 @@ test('getCabinets - with pagination', async (t) => { }) test('getCabinetById - happy path', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async () => [ - { id: 'd1', info: { pos: 'cab-A/slot1' } }, - { id: 'd2', info: { pos: 'cab-A/slot2' } }, - { id: 'd3', info: { pos: 'cab-B/slot1' } } - ] - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [ + { id: 'd1', info: { pos: 'cab-A/slot1' } }, + { id: 'd2', info: { pos: 'cab-A/slot2' } }, + { id: 'd3', info: { pos: 'cab-B/slot1' } } + ] + ) const result = await getCabinetById(mockCtx, { params: { id: 'cab-A' }, query: {} }) t.ok(result.cabinet, 'should return cabinet') @@ -228,14 +213,10 @@ test('getCabinetById - happy path', async (t) => { }) test('getCabinetById - not found', async (t) => { - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async () => [ - { id: 'd1', info: { pos: 'cab-A/slot1' } } - ] - } - } + const mockCtx = createMockCtxWithOrks( + [{ rpcPublicKey: 'key1' }], + async () => [{ id: 'd1', info: { pos: 'cab-A/slot1' } }] + ) try { await getCabinetById(mockCtx, { params: { id: 'nonexistent' }, query: {} }) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 2bae74b..0127a15 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -872,7 +872,7 @@ test('calculateDetailedRevenueSummary - handles empty log', (t) => { test('getHashRevenue - happy path', async (t) => { const dayTs = 1700006400000 - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -898,7 +898,7 @@ test('getHashRevenue - happy path', async (t) => { return {} } } - } + }) const mockReq = { query: { start: 1700000000000, end: 1700100000000, period: 'daily' } @@ -925,10 +925,10 @@ test('getHashRevenue - happy path', async (t) => { }) test('getHashRevenue - missing start throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getHashRevenue(mockCtx, { query: { end: 1700100000000 } }, {}) @@ -940,10 +940,10 @@ test('getHashRevenue - missing start throws', async (t) => { }) test('getHashRevenue - invalid range throws', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [] }, net_r0: { jRequest: async () => ({}) } - } + }) try { await getHashRevenue(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) @@ -955,10 +955,10 @@ test('getHashRevenue - invalid range throws', async (t) => { }) test('getHashRevenue - empty ork results', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, net_r0: { jRequest: async () => ({}) } - } + }) const result = await getHashRevenue(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) t.ok(result.log, 'should return log array') diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index ce5c522..7314e3e 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -1129,7 +1129,7 @@ test('getPowerModeTimeline - always uses t-miner tag', async (t) => { }) test('getPowerModeTimeline - returns all results without truncation', async (t) => { - const mockCtx = { + const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -1146,7 +1146,7 @@ test('getPowerModeTimeline - returns all results without truncation', async (t) return entries } } - } + }) const result = await getPowerModeTimeline(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js index d1f6a7b..ef3e9cb 100644 --- a/tests/unit/handlers/pools.handlers.test.js +++ b/tests/unit/handlers/pools.handlers.test.js @@ -1,6 +1,7 @@ 'use strict' const test = require('brittle') +const { RPC_METHODS, WORKER_TYPES, MINER_CATEGORIES } = require('../../../workers/lib/constants') const { getPools, flattenPoolStats, @@ -10,7 +11,8 @@ const { groupByBucket, getPoolStatsAggregate, processTransactionData, - calculateAggregateSummary + calculateAggregateSummary, + getPoolThingConfig } = require('../../../workers/lib/server/handlers/pools.handlers') const { withDataProxy } = require('../helpers/mockHelpers') @@ -478,3 +480,121 @@ test('calculateAggregateSummary - handles empty log', (t) => { t.is(summary.periodCount, 0, 'should be zero') t.pass() }) + +test('getPoolThingConfig - thing not found when no rack or info', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === RPC_METHODS.LIST_THINGS) return [] + return [] + } + } + }) + + const mockReq = { params: { id: 'thing-1' } } + + try { + await getPoolThingConfig(mockCtx, mockReq) + t.fail('should have thrown ERR_THING_NOT_FOUND') + } catch (err) { + t.is(err.message, 'ERR_THING_NOT_FOUND', 'should throw when thing missing rack/info') + } + t.pass() +}) + +test('getPoolThingConfig - miner thing returns poolConfig and overriddenConfig 0', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, params) => { + if (method === RPC_METHODS.LIST_THINGS && params?.query?.id) { + return [{ id: 'miner-1', rack: 'miner-am-s19xp', info: { container: 'unit-1', poolConfig: 'cfg1' } }] + } + return [] + } + } + }) + + const mockReq = { params: { id: 'miner-1' } } + const result = await getPoolThingConfig(mockCtx, mockReq) + + t.ok(result, 'should return result') + t.is(result.poolConfig, 'cfg1', 'should return poolConfig from info') + t.is(result.overriddenConfig, 0, 'should not fetch miners for miner thing') + t.pass() +}) + +test('getPoolThingConfig - maintenance container returns overriddenConfig 0', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, params) => { + if (method === RPC_METHODS.LIST_THINGS && params?.query?.id) { + return [{ id: 'container-1', rack: 'container-x', info: { container: MINER_CATEGORIES.MAINTENANCE, poolConfig: 'cfg2' } }] + } + return [] + } + } + }) + + const mockReq = { params: { id: 'container-1' } } + const result = await getPoolThingConfig(mockCtx, mockReq) + + t.is(result.poolConfig, 'cfg2', 'should return poolConfig') + t.is(result.overriddenConfig, 0, 'should not fetch miners for maintenance container') + t.pass() +}) + +test('getPoolThingConfig - container thing returns overriddenConfig count', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, params) => { + if (method === RPC_METHODS.LIST_THINGS && params?.query?.id) { + return [{ id: 'container-1', rack: 'container-unit-a', info: { container: 'unit-a', poolConfig: 'shared-cfg' } }] + } + if (method === RPC_METHODS.LIST_THINGS && params?.query?.tags) { + return [ + { id: 'm1', info: { poolConfig: 'shared-cfg' } }, + { id: 'm2', info: { poolConfig: 'shared-cfg' } }, + { id: 'm3', info: { poolConfig: 'other-cfg' } }, + { id: 'm4', info: { poolConfig: 'other-cfg' } } + ] + } + return [] + } + } + }) + + const mockReq = { params: { id: 'container-1' } } + const result = await getPoolThingConfig(mockCtx, mockReq) + + t.is(result.poolConfig, 'shared-cfg', 'should return container poolConfig') + t.is(result.overriddenConfig, 2, 'should count miners with same poolConfig') + t.pass() +}) + +test('getPoolThingConfig - container with no poolConfig returns null and zero overriden', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, params) => { + if (method === RPC_METHODS.LIST_THINGS && params?.query?.id) { + return [{ id: 'container-2', rack: 'container-unit-b', info: { container: 'unit-b' } }] + } + if (method === RPC_METHODS.LIST_THINGS && params?.type === WORKER_TYPES.MINER) { + return [] + } + return [] + } + } + }) + + const mockReq = { params: { id: 'container-2' } } + const result = await getPoolThingConfig(mockCtx, mockReq) + + t.is(result.poolConfig, null, 'should return null when no poolConfig') + t.is(result.overriddenConfig, 0, 'should be 0 when no miners match') + t.pass() +}) diff --git a/tests/unit/lib/utils.test.js b/tests/unit/lib/utils.test.js index 8ded2ef..9c185e0 100644 --- a/tests/unit/lib/utils.test.js +++ b/tests/unit/lib/utils.test.js @@ -8,9 +8,7 @@ const { isValidEmail, getRpcTimeout, getAuthTokenFromHeaders, - parseJsonQueryParam, - requestRpcEachLimit, - requestRpcMapLimit + parseJsonQueryParam } = require('../../../workers/lib/utils') const randomIPv4 = () => { @@ -439,166 +437,3 @@ test('parseJsonQueryParam - with custom error code', (t) => { t.pass() }) - -test('requestRpcEachLimit - basic functionality', async (t) => { - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' }, - { rpcPublicKey: 'key2' } - ] - }, - net_r0: { - jRequest: async (key, method, payload, opts) => { - return { success: true, key, method } - } - } - } - - const result = await requestRpcEachLimit(mockCtx, 'testMethod', { test: 'data' }) - - t.ok(Array.isArray(result), 'should return array') - t.is(result.length, 2, 'should return results for all orks') - t.ok(result[0].success, 'should return successful result') - - t.pass() -}) - -test('requestRpcEachLimit - with error handler', async (t) => { - const errorHandler = (res, resultsArray) => { - resultsArray.push({ processed: res }) - } - - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' } - ] - }, - net_r0: { - jRequest: async () => ({ data: 'test' }) - } - } - - const result = await requestRpcEachLimit(mockCtx, 'testMethod', {}, errorHandler) - - t.ok(Array.isArray(result), 'should return array') - t.is(result.length, 1, 'should have one result') - t.ok(result[0].processed, 'should use error handler') - - t.pass() -}) - -test('requestRpcEachLimit - handles errors gracefully', async (t) => { - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' } - ] - }, - net_r0: { - jRequest: async () => { - throw new Error('Network error') - } - } - } - - const result = await requestRpcEachLimit(mockCtx, 'testMethod', {}) - - t.ok(Array.isArray(result), 'should return array') - t.is(result.length, 1, 'should return error result') - t.ok(result[0].error, 'should include error in result') - - t.pass() -}) - -test('requestRpcEachLimit - with custom concurrency limit', async (t) => { - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' }, - { rpcPublicKey: 'key2' } - ], - rpcConcurrencyLimit: 1 - }, - net_r0: { - jRequest: async () => ({ success: true }) - } - } - - const result = await requestRpcEachLimit(mockCtx, 'testMethod', {}) - - t.ok(Array.isArray(result), 'should return array') - t.is(result.length, 2, 'should process all orks') - - t.pass() -}) - -test('requestRpcMapLimit - basic functionality', async (t) => { - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' }, - { rpcPublicKey: 'key2' } - ] - }, - net_r0: { - jRequest: async (key, method, payload, opts) => { - return { success: true, key } - } - } - } - - const result = await requestRpcMapLimit(mockCtx, 'testMethod', { test: 'data' }) - - t.ok(Array.isArray(result), 'should return array') - t.is(result.length, 2, 'should return results for all orks') - t.ok(result[0].success, 'should return successful result') - - t.pass() -}) - -test('requestRpcMapLimit - handles errors', async (t) => { - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' } - ] - }, - net_r0: { - jRequest: async () => { - throw new Error('Network error') - } - } - } - - try { - await requestRpcMapLimit(mockCtx, 'testMethod', {}) - t.fail('should throw error') - } catch (err) { - t.ok(err.message.includes('Network error'), 'should propagate error') - } - - t.pass() -}) - -test('requestRpcMapLimit - with custom timeout', async (t) => { - const mockCtx = { - conf: { - orks: [ - { rpcPublicKey: 'key1' } - ], - rpcTimeout: 30000 - }, - net_r0: { - jRequest: async (key, method, payload, opts) => { - t.is(opts.timeout, 30000, 'should use custom timeout') - return { success: true } - } - } - } - - await requestRpcMapLimit(mockCtx, 'testMethod', {}) - - t.pass() -}) diff --git a/tests/unit/routes/pools.routes.test.js b/tests/unit/routes/pools.routes.test.js index c5e3ee6..947e024 100644 --- a/tests/unit/routes/pools.routes.test.js +++ b/tests/unit/routes/pools.routes.test.js @@ -17,6 +17,7 @@ test('pools routes - route definitions', (t) => { t.ok(routeUrls.includes('/auth/pools'), 'should have pools route') t.ok(routeUrls.includes('/auth/pools/:pool/balance-history'), 'should have balance-history route') t.ok(routeUrls.includes('/auth/pool-stats/aggregate'), 'should have pool-stats aggregate route') + t.ok(routeUrls.includes('/auth/pools/config/:id'), 'should have pools thing config route') t.pass() }) diff --git a/tests/unit/services.poolManager.test.js b/tests/unit/services.poolManager.test.js index 3645ddc..e88a10b 100644 --- a/tests/unit/services.poolManager.test.js +++ b/tests/unit/services.poolManager.test.js @@ -369,4 +369,3 @@ test('poolManager:getPoolAlerts includes severity', async function (t) { t.is(result[0].severity, 'critical') t.is(result[0].type, 'all_pools_dead') }) - diff --git a/workers/lib/constants.js b/workers/lib/constants.js index b674103..459b562 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -125,6 +125,7 @@ const ENDPOINTS = { // Pools endpoints POOLS: '/auth/pools', POOLS_BALANCE_HISTORY: '/auth/pools/:pool/balance-history', + POOLS_THING_CONFIG: '/auth/pools/config/:id', // Pool stats endpoints POOL_STATS_AGGREGATE: '/auth/pool-stats/aggregate', diff --git a/workers/lib/server/handlers/auth.handlers.js b/workers/lib/server/handlers/auth.handlers.js index 79ca5dd..42904eb 100644 --- a/workers/lib/server/handlers/auth.handlers.js +++ b/workers/lib/server/handlers/auth.handlers.js @@ -1,7 +1,7 @@ 'use strict' const gLibUtilBase = require('lib-js-util-base') -const { parseJsonQueryParam, requestRpcMapLimit, getAuthTokenFromHeaders } = require('../../utils') +const { parseJsonQueryParam, getAuthTokenFromHeaders } = require('../../utils') async function getUserInfo (ctx, req) { return req._info.user @@ -27,7 +27,7 @@ async function extDataRoute (ctx, req, rep) { req.query.query = parseJsonQueryParam(req.query.query, 'ERR_QUERY_INVALID_JSON') } - return await requestRpcMapLimit(ctx, 'getWrkExtData', req.query) + return await ctx.dataProxy.requestDataMap('getWrkExtData', req.query) } module.exports = { diff --git a/workers/lib/server/handlers/configs.handlers.js b/workers/lib/server/handlers/configs.handlers.js index e6e996e..005cc6b 100644 --- a/workers/lib/server/handlers/configs.handlers.js +++ b/workers/lib/server/handlers/configs.handlers.js @@ -1,7 +1,6 @@ 'use strict' -const { requestRpcEachLimit } = require('../../utils') -const { CONFIG_TYPES } = require('../../constants') +const { CONFIG_TYPES, RPC_METHODS, WORKER_TYPES } = require('../../constants') const VALID_CONFIG_TYPES = Object.values(CONFIG_TYPES) @@ -33,13 +32,29 @@ async function getConfigs (ctx, req) { } } - return await requestRpcEachLimit(ctx, 'getConfigs', payload, (res, resultsArray) => { + const configs = await ctx.dataProxy.requestData('getConfigs', payload, (res, resultsArray) => { if (res.error) { console.error(new Date().toISOString(), res.error) } else if (Array.isArray(res)) { resultsArray.push(...res) } }) + + if (type !== CONFIG_TYPES.POOL) return configs + return fetchPoolConfigThings(ctx, configs) +} + +const fetchPoolConfigThings = async (ctx, configs) => { + const ids = configs.map(c => c.id) + const things = await ctx.dataProxy.requestData(RPC_METHODS.LIST_THINGS, { + query: { 'info.poolConfig': { $in: ids } }, + fields: { 'info.poolConfig': 1 } + }) + return configs.map(config => { + const containers = things?.[0]?.filter(t => t.info?.poolConfig === config.id && t.rack.startsWith(WORKER_TYPES.CONTAINER))?.length || 0 + const miners = things?.[0]?.filter(t => t.info?.poolConfig === config.id && t.rack.startsWith(WORKER_TYPES.MINER))?.length || 0 + return { ...config, containers, miners } + }) } module.exports = { diff --git a/workers/lib/server/handlers/devices.handlers.js b/workers/lib/server/handlers/devices.handlers.js index 81aa9f1..608fca9 100644 --- a/workers/lib/server/handlers/devices.handlers.js +++ b/workers/lib/server/handlers/devices.handlers.js @@ -7,12 +7,7 @@ const { WORKER_TAGS, DEVICE_LIST_FIELDS } = require('../../constants') -const { - requestRpcMapAllPages, - requestRpcMapLimit, - parseJsonQueryParam, - flattenRpcResults -} = require('../../utils') +const { parseJsonQueryParam, flattenRpcResults } = require('../../utils') const CABINET_TAGS_QUERY = { tags: { $in: [WORKER_TAGS.POWERMETER, WORKER_TAGS.TEMP_SENSOR] } } @@ -62,7 +57,7 @@ function queryAndPaginate (items, { filter, fields, sort, search, offset, limit async function getContainers (ctx, req) { const params = parseListQuery(req) - const results = await requestRpcMapLimit(ctx, RPC_METHODS.LIST_THINGS, { + const results = await ctx.dataProxy.requestDataMap(RPC_METHODS.LIST_THINGS, { query: { tags: { $in: [WORKER_TAGS.CONTAINER] } }, fields: DEVICE_LIST_FIELDS, ...(params.limit && { limit: params.limit }), @@ -82,7 +77,7 @@ async function getContainers (ctx, req) { async function getCabinets (ctx, req) { const { filter, sort, offset, limit } = parseListQuery(req) - const results = await requestRpcMapAllPages(ctx, RPC_METHODS.LIST_THINGS, { + const results = await ctx.dataProxy.requestDataAllPages(RPC_METHODS.LIST_THINGS, { query: CABINET_TAGS_QUERY, fields: DEVICE_LIST_FIELDS }) @@ -108,7 +103,7 @@ async function getCabinets (ctx, req) { async function getCabinetById (ctx, req) { const cabinetId = req.params.id - const results = await requestRpcMapAllPages(ctx, RPC_METHODS.LIST_THINGS, { + const results = await ctx.dataProxy.requestDataAllPages(RPC_METHODS.LIST_THINGS, { query: CABINET_TAGS_QUERY, fields: DEVICE_LIST_FIELDS }) diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index 9a3256f..444e90d 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -8,11 +8,7 @@ const { RPC_METHODS, GLOBAL_DATA_TYPES } = require('../../constants') -const { - getStartOfDay, - safeDiv, - runParallel -} = require('../../utils') +const { getStartOfDay, safeDiv, runParallel } = require('../../utils') const { aggregateByPeriod } = require('../../period.utils') const { validateStartEnd, @@ -987,12 +983,12 @@ async function getHashRevenue (ctx, req) { currentPriceResults, networkHashrateResults ] = await runParallel([ - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MINERPOOL, query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.TAIL_LOG_RANGE_AGGR, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [{ type: WORKER_TYPES.MINER, startDate, @@ -1002,17 +998,17 @@ async function getHashRevenue (ctx, req) { }] }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'prices', start, end } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'current_price' } }).then(r => cb(null, r)).catch(cb), - (cb) => requestRpcEachLimit(ctx, RPC_METHODS.GET_WRK_EXT_DATA, { + (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, query: { key: 'HISTORICAL_HASHRATE', start, end } }).then(r => cb(null, r)).catch(cb) diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 0cf0b3e..64dae54 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -288,6 +288,25 @@ function calculateAggregateSummary (log) { } } +const getPoolThingConfig = async (ctx, req) => { + const thing = await ctx.dataProxy.requestData(RPC_METHODS.LIST_THINGS, { + query: { id: req.params.id }, fields: { info: 1 } + }) + const rack = thing?.[0]?.[0]?.rack + const info = thing?.[0]?.[0]?.info + if (!rack || !info) throw new Error('ERR_THING_NOT_FOUND') + if (rack?.startsWith(WORKER_TYPES.MINER)) { + return { poolConfig: info?.poolConfig || null, overriddenConfig: 0 } + } + + const miners = await ctx.dataProxy.requestData(RPC_METHODS.LIST_THINGS, { + query: { tags: { $in: [`container-${info.container}`] } }, + fields: { 'info.poolConfig': 1 } + }) + const overriddenConfig = miners?.[0]?.filter(m => m.info?.poolConfig && m.info?.poolConfig !== info?.poolConfig)?.length || 0 + return { poolConfig: info?.poolConfig || null, overriddenConfig } +} + module.exports = { getPools, flattenPoolStats, @@ -297,5 +316,6 @@ module.exports = { groupByBucket, getPoolStatsAggregate, processTransactionData, - calculateAggregateSummary + calculateAggregateSummary, + getPoolThingConfig } diff --git a/workers/lib/server/routes/pools.routes.js b/workers/lib/server/routes/pools.routes.js index 097ee18..36ed79e 100644 --- a/workers/lib/server/routes/pools.routes.js +++ b/workers/lib/server/routes/pools.routes.js @@ -7,9 +7,10 @@ const { const { getPools, getPoolBalanceHistory, - getPoolStatsAggregate + getPoolStatsAggregate, + getPoolThingConfig } = require('../handlers/pools.handlers') -const { createCachedAuthRoute } = require('../lib/routeHelpers') +const { createCachedAuthRoute, createAuthRoute } = require('../lib/routeHelpers') module.exports = (ctx) => { const schemas = require('../schemas/pools.schemas.js') @@ -70,6 +71,14 @@ module.exports = (ctx) => { ENDPOINTS.POOL_STATS_AGGREGATE, getPoolStatsAggregate ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.POOLS_THING_CONFIG, + ...createAuthRoute( + ctx, + getPoolThingConfig + ) } ] } diff --git a/workers/lib/utils.js b/workers/lib/utils.js index b4051b6..44c12d3 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -1,7 +1,7 @@ 'use strict' const async = require('async') -const { RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, RPC_PAGE_LIMIT } = require('./constants') +const { RPC_TIMEOUT } = require('./constants') const { getStartOfDay } = require('./period.utils') const dateNowSec = () => Math.floor(Date.now() / 1000) @@ -72,96 +72,6 @@ const parseJsonQueryParam = (jsonString, errorCode = 'ERR_INVALID_JSON') => { } } -/** - * Executes RPC requests across multiple orks - * @param {Object} ctx - Context object - * @param {string} method - RPC method name - * @param {Object} payload - RPC payload - * @param {Function} errorHandler - Optional error handler function - * @returns {Promise} Array of results - */ -const requestRpcEachLimit = async (ctx, method, payload, errorHandler = null) => { - const results = [] - const concurrency = ctx.conf?.rpcConcurrencyLimit || RPC_CONCURRENCY_LIMIT - - await async.eachLimit(ctx.conf.orks, concurrency, async (store) => { - try { - const res = await ctx.net_r0.jRequest( - store.rpcPublicKey, - method, - payload, - { timeout: getRpcTimeout(ctx.conf) } - ) - if (errorHandler) { - errorHandler(res, results) - } else { - results.push(res) - } - } catch (err) { - if (errorHandler) { - errorHandler({ error: err.message }, results) - } else { - results.push({ error: err.message }) - } - } - }) - - return results -} - -/** - * Executes RPC requests across multiple orks - * @param {Object} ctx - Context object - * @param {string} method - RPC method name - * @param {Object} payload - RPC payload - * @returns {Promise} Array of results - */ -const requestRpcMapLimit = async (ctx, method, payload) => { - const concurrency = ctx.conf?.rpcConcurrencyLimit || RPC_CONCURRENCY_LIMIT - - return await async.mapLimit(ctx.conf.orks, concurrency, async (store) => { - return ctx.net_r0.jRequest( - store.rpcPublicKey, - method, - payload, - { timeout: getRpcTimeout(ctx.conf) } - ) - }) -} - -/** - * Paginates RPC requests across multiple orks, fetching all pages per ork - * @param {Object} ctx - Context object - * @param {string} method - RPC method name - * @param {Object} payload - RPC payload (limit/offset will be managed internally) - * @param {number} pageLimit - Items per page (default: RPC_PAGE_LIMIT) - * @returns {Promise} Array of results per ork (all pages concatenated) - */ -const requestRpcMapAllPages = async (ctx, method, payload, pageLimit = RPC_PAGE_LIMIT) => { - const concurrency = ctx.conf?.rpcConcurrencyLimit || RPC_CONCURRENCY_LIMIT - - return await async.mapLimit(ctx.conf.orks, concurrency, async (store) => { - const allItems = [] - let offset = 0 - - while (true) { - const batch = await ctx.net_r0.jRequest( - store.rpcPublicKey, - method, - { ...payload, limit: pageLimit, offset }, - { timeout: getRpcTimeout(ctx.conf) } - ) - - if (!Array.isArray(batch) || batch.length === 0) break - allItems.push(...batch) - if (batch.length < pageLimit) break - offset += pageLimit - } - - return allItems - }) -} - const runParallel = (tasks) => new Promise((resolve, reject) => { async.parallel(tasks, (err, results) => { @@ -236,9 +146,6 @@ module.exports = { getRpcTimeout, getAuthTokenFromHeaders, parseJsonQueryParam, - requestRpcEachLimit, - requestRpcMapLimit, - requestRpcMapAllPages, getStartOfDay, flattenRpcResults, safeDiv, From f7b4e1c32b60edd89d916a4ccc2dabe6f2f17ea2 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Sat, 14 Mar 2026 12:43:05 +0300 Subject: [PATCH 17/63] feat: add GET /auth/miners with getThingsCount (#33) --- config/common.json.example | 3 +- tests/integration/api.miners.test.js | 730 ++++++++++++++++++ tests/unit/handlers/miners.handlers.test.js | 529 +++++++++++++ tests/unit/lib/queryUtils.test.js | 277 +++++++ tests/unit/routes/miners.routes.test.js | 65 ++ workers/lib/constants.js | 88 ++- .../lib/server/handlers/miners.handlers.js | 180 +++++ workers/lib/server/index.js | 4 +- workers/lib/server/lib/queryUtils.js | 183 +++++ workers/lib/server/routes/miners.routes.js | 45 ++ 10 files changed, 2100 insertions(+), 4 deletions(-) create mode 100644 tests/integration/api.miners.test.js create mode 100644 tests/unit/handlers/miners.handlers.test.js create mode 100644 tests/unit/lib/queryUtils.test.js create mode 100644 tests/unit/routes/miners.routes.test.js create mode 100644 workers/lib/server/handlers/miners.handlers.js create mode 100644 workers/lib/server/lib/queryUtils.js create mode 100644 workers/lib/server/routes/miners.routes.js diff --git a/config/common.json.example b/config/common.json.example index 832e1b1..da9c9ea 100644 --- a/config/common.json.example +++ b/config/common.json.example @@ -14,7 +14,8 @@ "/auth/actions/:type": "30s", "/auth/actions/:type/:id": "30s", "/auth/global/data": "30s", - "/auth/site/status/live": "15s" + "/auth/site/status/live": "15s", + "/auth/miners": "15s" }, "featureConfig": { "comments": true, diff --git a/tests/integration/api.miners.test.js b/tests/integration/api.miners.test.js new file mode 100644 index 0000000..745c397 --- /dev/null +++ b/tests/integration/api.miners.test.js @@ -0,0 +1,730 @@ +'use strict' + +const test = require('brittle') +const fs = require('fs') +const { createWorker } = require('tether-svc-test-helper').worker +const { setTimeout: sleep } = require('timers/promises') +const HttpFacility = require('bfx-facs-http') +const { ENDPOINTS } = require('../../workers/lib/constants') + +test('Miners API', { timeout: 90000 }, async (main) => { + const baseDir = 'tests/integration' + let worker + let httpClient + const appNodePort = 5002 + const ip = '127.0.0.1' + const appNodeBaseUrl = `http://${ip}:${appNodePort}` + const readonlyUser = 'readonly-miners@test' + const siteOperatorUser = 'siteoperator-miners@test' + const encoding = 'json' + const invalidToken = 'invalid-token' + + main.teardown(async () => { + await httpClient.stop() + await worker.stop() + await sleep(2000) + fs.rmSync(`./${baseDir}/store`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/status`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/config`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/db`, { recursive: true, force: true }) + }) + + const mockMiners = [ + { + id: 'miner-001', + type: 'antminer-s19', + code: 'M001', + info: { + container: 'container-A', + serialNum: 'SN-001', + macAddress: 'AA:BB:CC:DD:EE:01', + pos: 'A1' + }, + tags: ['t-miner'], + rack: 'rack-1', + comments: [], + opts: { address: '192.168.1.100' }, + ts: Date.now() - 60000, + last: { + ts: Date.now(), + uptime: 86400, + alerts: [], + snap: { + model: 'Antminer S19 XP', + stats: { + status: 'online', + hashrate_mhs: 140000, + power_w: 3010, + efficiency_w_ths: 21.5, + temperature_c: 65 + }, + config: { + firmware_ver: '2024.01.01', + power_mode: 'normal', + led_status: 'off', + pool_config: { + url: 'stratum+tcp://btc.f2pool.com:3333', + user: 'tether.worker1' + } + } + } + } + }, + { + id: 'miner-002', + type: 'antminer-s19', + code: 'M002', + info: { + container: 'container-A', + serialNum: 'SN-002', + macAddress: 'AA:BB:CC:DD:EE:02', + pos: 'A2' + }, + tags: ['t-miner'], + rack: 'rack-1', + comments: [{ text: 'needs maintenance' }], + opts: { address: '192.168.1.101' }, + ts: Date.now() - 120000, + last: { + ts: Date.now(), + uptime: 172800, + alerts: [{ type: 'high_temp', severity: 'medium' }], + snap: { + model: 'Antminer S19 XP', + stats: { + status: 'online', + hashrate_mhs: 135000, + power_w: 2980, + efficiency_w_ths: 22.1, + temperature_c: 72 + }, + config: { + firmware_ver: '2024.01.01', + power_mode: 'normal', + led_status: 'off', + pool_config: { + url: 'stratum+tcp://btc.f2pool.com:3333', + user: 'tether.worker2' + } + } + } + } + }, + { + id: 'miner-003', + type: 'whatsminer-m50s', + code: 'M003', + info: { + container: 'container-B', + serialNum: 'SN-003', + macAddress: 'AA:BB:CC:DD:EE:03', + pos: 'B1' + }, + tags: ['t-miner'], + rack: 'rack-2', + comments: [], + opts: { address: '192.168.2.100' }, + ts: Date.now() - 180000, + last: { + ts: Date.now() - 300000, + uptime: 3600, + alerts: [], + snap: { + model: 'Whatsminer M50S', + stats: { + status: 'error', + hashrate_mhs: 0, + power_w: 50, + efficiency_w_ths: 0, + temperature_c: 30 + }, + config: { + firmware_ver: '2023.12.15', + power_mode: 'normal', + led_status: 'on', + pool_config: { + url: 'stratum+tcp://ocean.xyz:3333', + user: 'tether.worker3' + } + } + } + } + }, + { + id: 'miner-004', + type: 'antminer-s19', + code: 'M004', + info: { + container: 'container-B', + serialNum: 'SN-004', + macAddress: 'AA:BB:CC:DD:EE:04', + pos: 'B2' + }, + tags: ['t-miner'], + rack: 'rack-2', + comments: [], + opts: { address: '192.168.2.101' }, + ts: Date.now() - 240000, + last: { + ts: Date.now(), + uptime: 43200, + alerts: [], + snap: { + model: 'Antminer S19 XP', + stats: { + status: 'sleep', + hashrate_mhs: 0, + power_w: 10, + efficiency_w_ths: 0, + temperature_c: 25 + }, + config: { + firmware_ver: '2024.01.01', + power_mode: 'sleep', + led_status: 'off', + pool_config: { + url: 'stratum+tcp://btc.f2pool.com:3333', + user: 'tether.worker4' + } + } + } + } + }, + { + id: 'miner-005', + type: 'antminer-s19', + code: 'M005', + info: { + container: 'container-A', + serialNum: 'SN-005', + macAddress: 'AA:BB:CC:DD:EE:05', + pos: 'A3' + }, + tags: ['t-miner'], + rack: 'rack-1', + comments: [], + opts: { address: '192.168.1.102' }, + ts: Date.now() - 300000, + last: { + ts: Date.now(), + uptime: 259200, + alerts: [{ type: 'low_hashrate', severity: 'high' }], + snap: { + model: 'Antminer S19 XP', + stats: { + status: 'online', + hashrate_mhs: 120000, + power_w: 2900, + efficiency_w_ths: 24.2, + temperature_c: 68 + }, + config: { + firmware_ver: '2023.12.15', + power_mode: 'low', + led_status: 'off', + pool_config: { + url: 'stratum+tcp://btc.f2pool.com:3333', + user: 'tether.worker5' + } + } + } + } + } + ] + + const createConfig = () => { + if (!fs.existsSync(`./${baseDir}/config/facs`)) { + if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`) + fs.mkdirSync(`./${baseDir}/config/facs`) + } + if (!fs.existsSync(`./${baseDir}/db`)) fs.mkdirSync(`./${baseDir}/db`) + + const commonConf = { + dir_log: 'logs', + debug: 0, + orks: { 'cluster-1': { rpcPublicKey: '' } }, + cacheTiming: {}, + featureConfig: {} + } + const netConf = { r0: {} } + const httpdConf = { h0: {} } + const httpdOauthConf = { + h0: { + method: 'google', + credentials: { client: { id: 'i', secret: 's' } }, + users: [ + { email: readonlyUser }, + { email: siteOperatorUser, write: true } + ] + } + } + const authConf = require('../../config/facs/auth.config.json') + + fs.writeFileSync(`./${baseDir}/config/common.json`, JSON.stringify(commonConf)) + fs.writeFileSync(`./${baseDir}/config/facs/net.config.json`, JSON.stringify(netConf)) + fs.writeFileSync(`./${baseDir}/config/facs/httpd.config.json`, JSON.stringify(httpdConf)) + fs.writeFileSync(`./${baseDir}/config/facs/httpd-oauth2.config.json`, JSON.stringify(httpdOauthConf)) + fs.writeFileSync(`./${baseDir}/config/facs/auth.config.json`, JSON.stringify(authConf)) + } + + const startWorker = async () => { + worker = createWorker({ + env: 'test', + wtype: 'wrk-node-http-test', + rack: 'test-rack', + tmpdir: baseDir, + storeDir: 'test-store', + serviceRoot: `${process.cwd()}/${baseDir}`, + port: appNodePort + }) + + await worker.start() + worker.worker.net_r0.jRequest = (publicKey, method) => { + if (method === 'listThings') { + return Promise.resolve(mockMiners) + } + if (method === 'getThingsCount') { + return Promise.resolve(mockMiners.length) + } + if (method === 'getWrkExtData') { + return Promise.resolve([]) + } + return Promise.resolve({}) + } + } + + const createHttpClient = async () => { + httpClient = new HttpFacility({}, { ns: 'c0', timeout: 30000, debug: false }, { env: 'test' }) + await httpClient.start() + } + + const getTestToken = async (email) => { + worker.worker.authLib._auth.addHandlers({ + google: () => { return { email } } + }) + const token = await worker.worker.auth_a0.authCallbackHandler('google', { ip }) + return token + } + + const createAuthHeaders = async (userEmail) => { + const token = await getTestToken(userEmail) + return { Authorization: `Bearer ${token}` } + } + + createConfig() + await startWorker() + await createHttpClient() + await sleep(2000) + + const minersApi = `${appNodeBaseUrl}${ENDPOINTS.MINERS}` + + // --- Auth security tests --- + + await main.test('Api: miners - auth security', async (n) => { + await n.test('should fail for missing auth token', async (t) => { + try { + await httpClient.get(minersApi, { encoding }) + t.fail('Expected error for missing auth token') + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('should fail for invalid auth token', async (t) => { + const headers = { Authorization: `Bearer ${invalidToken}` } + try { + await httpClient.get(minersApi, { headers, encoding }) + t.fail('Expected error for invalid auth token') + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('should fail for readonly user (capCheck requires write)', async (t) => { + const headers = await createAuthHeaders(readonlyUser) + try { + await httpClient.get(minersApi, { headers, encoding }) + t.fail('Expected error for readonly user') + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('should succeed for site operator user (has actions:rw)', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + try { + await httpClient.get(minersApi, { headers, encoding }) + t.pass() + } catch (e) { + t.fail(`Expected success but got: ${e.message || e}`) + } + }) + }) + + // --- Response structure tests --- + + await main.test('Api: miners - response structure', async (n) => { + await n.test('should return paginated response with correct top-level fields', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const { body: data } = await httpClient.get(minersApi, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'data should be an array') + t.ok(typeof data.totalCount === 'number', 'totalCount should be a number') + t.ok(typeof data.offset === 'number', 'offset should be a number') + t.ok(typeof data.limit === 'number', 'limit should be a number') + t.ok(typeof data.hasMore === 'boolean', 'hasMore should be a boolean') + }) + + await n.test('should return all mock miners with default pagination', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const { body: data } = await httpClient.get(minersApi, { headers, encoding }) + + t.is(data.totalCount, 5, 'totalCount should be 5') + t.is(data.data.length, 5, 'data should have 5 items') + t.is(data.offset, 0, 'offset should default to 0') + t.is(data.hasMore, false, 'hasMore should be false when all items fit') + }) + + await n.test('each miner should have clean formatted fields', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const { body: data } = await httpClient.get(minersApi, { headers, encoding }) + const miner = data.data[0] + + t.ok(miner.id, 'should have id') + t.ok(miner.type, 'should have type') + t.ok(miner.model, 'should have model') + t.ok(miner.code, 'should have code') + t.ok(miner.ip, 'should have ip') + t.ok(miner.container, 'should have container') + t.ok(miner.rack, 'should have rack') + t.ok(miner.status !== undefined, 'should have status') + t.ok(typeof miner.hashrate === 'number', 'hashrate should be a number') + t.ok(typeof miner.power === 'number', 'power should be a number') + t.ok(typeof miner.efficiency === 'number', 'efficiency should be a number') + t.ok(miner.temperature !== undefined, 'should have temperature') + t.ok(miner.firmware, 'should have firmware') + t.ok(miner.powerMode, 'should have powerMode') + t.ok(miner.ledStatus !== undefined, 'should have ledStatus') + t.ok(miner.poolConfig, 'should have poolConfig') + t.ok(miner.lastSeen, 'should have lastSeen') + }) + + await n.test('formatted miner should have correct values from raw data', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const { body: data } = await httpClient.get(minersApi, { headers, encoding }) + + const miner = data.data.find(m => m.id === 'miner-001') + t.ok(miner, 'should find miner-001') + t.is(miner.id, 'miner-001') + t.is(miner.type, 'antminer-s19') + t.is(miner.model, 'Antminer S19 XP') + t.is(miner.code, 'M001') + t.is(miner.ip, '192.168.1.100') + t.is(miner.container, 'container-A') + t.is(miner.rack, 'rack-1') + t.is(miner.status, 'online') + t.is(miner.hashrate, 140000) + t.is(miner.power, 3010) + t.is(miner.efficiency, 21.5) + t.is(miner.temperature, 65) + t.is(miner.firmware, '2024.01.01') + t.is(miner.powerMode, 'normal') + }) + }) + + // --- Pagination tests --- + + await main.test('Api: miners - pagination', async (n) => { + await n.test('should respect limit parameter', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?limit=2` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.data.length, 2, 'should return only 2 items') + t.is(data.totalCount, 5, 'totalCount should still be 5') + t.is(data.limit, 2, 'limit should be 2') + t.is(data.hasMore, true, 'hasMore should be true') + }) + + await n.test('should respect offset parameter', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?offset=3&limit=10` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.data.length, 2, 'should return remaining 2 items') + t.is(data.totalCount, 5, 'totalCount should still be 5') + t.is(data.offset, 3, 'offset should be 3') + t.is(data.hasMore, false, 'hasMore should be false') + }) + + await n.test('should return empty page when offset exceeds total', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?offset=100` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.data.length, 0, 'should return 0 items') + t.is(data.totalCount, 5, 'totalCount should still be 5') + t.is(data.hasMore, false, 'hasMore should be false') + }) + + await n.test('should cap limit at MAX_LIMIT (200)', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?limit=500` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(data.limit <= 200, 'limit should be capped at 200') + }) + }) + + // --- Filter tests --- + // Note: Filtering is done on the ork side via mingo. The mock RPC returns + // all miners regardless of query. These tests verify the API accepts filter + // params correctly and returns valid responses. Filter logic is covered + // by unit tests in miners.handlers.test.js and queryUtils.test.js. + + await main.test('Api: miners - filtering', async (n) => { + await n.test('should accept filter param and return valid response', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const filter = JSON.stringify({ status: 'online' }) + const api = `${minersApi}?filter=${encodeURIComponent(filter)}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + t.ok(typeof data.totalCount === 'number', 'should have totalCount') + }) + + await n.test('should accept $or filter without error', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const filter = JSON.stringify({ $or: [{ status: 'error' }, { status: 'sleep' }] }) + const api = `${minersApi}?filter=${encodeURIComponent(filter)}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + }) + + await n.test('should return error for invalid filter JSON', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?filter=not-valid-json` + try { + await httpClient.get(api, { headers, encoding }) + t.fail('Expected error for invalid JSON') + } catch (e) { + t.ok(e.response.message.includes('ERR_FILTER_INVALID_JSON'), 'should return filter JSON error') + } + }) + }) + + // --- Sort tests --- + + await main.test('Api: miners - sorting', async (n) => { + await n.test('should sort by hashrate descending', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const sort = JSON.stringify({ hashrate: -1 }) + const api = `${minersApi}?sort=${encodeURIComponent(sort)}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(data.data.length > 1, 'should return multiple items') + for (let i = 1; i < data.data.length; i++) { + t.ok( + data.data[i - 1].hashrate >= data.data[i].hashrate, + `hashrate should be descending: ${data.data[i - 1].hashrate} >= ${data.data[i].hashrate}` + ) + } + }) + + await n.test('should sort by hashrate ascending', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const sort = JSON.stringify({ hashrate: 1 }) + const api = `${minersApi}?sort=${encodeURIComponent(sort)}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(data.data.length > 1, 'should return multiple items') + for (let i = 1; i < data.data.length; i++) { + t.ok( + data.data[i - 1].hashrate <= data.data[i].hashrate, + `hashrate should be ascending: ${data.data[i - 1].hashrate} <= ${data.data[i].hashrate}` + ) + } + }) + + await n.test('should return error for invalid sort JSON', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?sort=not-valid-json` + try { + await httpClient.get(api, { headers, encoding }) + t.fail('Expected error for invalid JSON') + } catch (e) { + t.ok(e.response.message.includes('ERR_SORT_INVALID_JSON'), 'should return sort JSON error') + } + }) + }) + + // --- Search tests --- + // Note: Search query is built into the RPC payload and executed on the ork. + // The mock returns all miners regardless. These tests verify the API + // accepts search params and the query is correctly built (unit-tested). + + await main.test('Api: miners - search', async (n) => { + await n.test('should accept search param and return valid response', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?search=192.168` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + t.ok(typeof data.totalCount === 'number', 'should have totalCount') + }) + + await n.test('should accept search combined with other params', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const sort = JSON.stringify({ hashrate: -1 }) + const api = `${minersApi}?search=miner&sort=${encodeURIComponent(sort)}&limit=3` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + t.ok(data.data.length <= 3, 'should respect limit') + }) + }) + + // --- Combined query param tests --- + + await main.test('Api: miners - combined query params', async (n) => { + await n.test('should accept filter, sort, and pagination together', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const filter = JSON.stringify({ status: 'online' }) + const sort = JSON.stringify({ hashrate: -1 }) + const api = `${minersApi}?filter=${encodeURIComponent(filter)}&sort=${encodeURIComponent(sort)}&limit=2&offset=0` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.limit, 2, 'limit should be 2') + t.ok(data.data.length <= 2, 'should return at most 2 items') + if (data.data.length > 1) { + t.ok(data.data[0].hashrate >= data.data[1].hashrate, 'should be sorted by hashrate desc') + } + }) + + await n.test('should accept all query params together without error', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const filter = JSON.stringify({ status: 'online' }) + const sort = JSON.stringify({ temperature: 1 }) + const fields = JSON.stringify({ status: 1, ip: 1, temperature: 1 }) + const api = `${minersApi}?filter=${encodeURIComponent(filter)}&sort=${encodeURIComponent(sort)}&fields=${encodeURIComponent(fields)}&search=192&limit=3&offset=0` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + t.ok(data.data.length <= 3, 'should respect limit') + t.is(data.offset, 0, 'offset should be 0') + // Verify projection: only requested fields + id should be present + if (data.data.length > 0) { + const miner = data.data[0] + t.ok(miner.id, 'should always include id') + t.ok(miner.status !== undefined, 'should include requested field: status') + t.ok(miner.ip !== undefined, 'should include requested field: ip') + t.is(miner.hashrate, undefined, 'should exclude non-requested field: hashrate') + t.is(miner.power, undefined, 'should exclude non-requested field: power') + t.is(miner.firmware, undefined, 'should exclude non-requested field: firmware') + } + }) + }) + + // --- overwriteCache tests --- + + await main.test('Api: miners - overwriteCache', async (n) => { + await n.test('should accept overwriteCache=true without error', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?overwriteCache=true` + try { + const { body: data } = await httpClient.get(api, { headers, encoding }) + t.ok(data.data, 'should return data') + t.pass() + } catch (e) { + t.fail(`Expected success but got: ${e.message || e}`) + } + }) + }) + + // --- Pool enrichment tests --- + + await main.test('Api: miners - pool enrichment', async (n) => { + await n.test('should include poolHashrate when poolStats feature is enabled', async (t) => { + worker.worker.conf.featureConfig = { poolStats: true } + + const originalJRequest = worker.worker.net_r0.jRequest + worker.worker.net_r0.jRequest = (publicKey, method) => { + if (method === 'listThings') { + return Promise.resolve(mockMiners) + } + if (method === 'getThingsCount') { + return Promise.resolve(mockMiners.length) + } + if (method === 'getWrkExtData') { + return Promise.resolve([{ + workers: { + 'miner-001': { hashrate: 139500 }, + M002: { hashrate: 134000 } + } + }]) + } + return Promise.resolve({}) + } + + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?overwriteCache=true` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + const miner1 = data.data.find(m => m.id === 'miner-001') + t.ok(miner1, 'should find miner-001') + t.is(miner1.poolHashrate, 139500, 'miner-001 should have poolHashrate from pool data') + + const miner2 = data.data.find(m => m.id === 'miner-002') + t.ok(miner2, 'should find miner-002') + t.is(miner2.poolHashrate, 134000, 'miner-002 should have poolHashrate matched by code') + + worker.worker.conf.featureConfig = {} + worker.worker.net_r0.jRequest = originalJRequest + }) + + await n.test('should not include poolHashrate when poolStats feature is disabled', async (t) => { + worker.worker.conf.featureConfig = {} + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?overwriteCache=true` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + const miner = data.data[0] + t.is(miner.poolHashrate, undefined, 'poolHashrate should not be present') + }) + }) + + // --- Error/edge case handling --- + + await main.test('Api: miners - edge cases', async (n) => { + await n.test('should handle empty RPC response gracefully', async (t) => { + const originalJRequest = worker.worker.net_r0.jRequest + worker.worker.net_r0.jRequest = (publicKey, method) => { + if (method === 'listThings') return Promise.resolve([]) + if (method === 'getThingsCount') return Promise.resolve(0) + return Promise.resolve({}) + } + + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?overwriteCache=true` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.data.length, 0, 'should return empty data array') + t.is(data.totalCount, 0, 'totalCount should be 0') + t.is(data.hasMore, false, 'hasMore should be false') + + worker.worker.net_r0.jRequest = originalJRequest + }) + + await n.test('should return error for invalid fields JSON', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?fields=not-valid-json` + try { + await httpClient.get(api, { headers, encoding }) + t.fail('Expected error for invalid JSON') + } catch (e) { + t.ok(e.response.message.includes('ERR_FIELDS_INVALID_JSON'), 'should return fields JSON error') + } + }) + }) +}) diff --git a/tests/unit/handlers/miners.handlers.test.js b/tests/unit/handlers/miners.handlers.test.js new file mode 100644 index 0000000..04419fa --- /dev/null +++ b/tests/unit/handlers/miners.handlers.test.js @@ -0,0 +1,529 @@ +'use strict' + +const test = require('brittle') +const { + listMiners, + formatMiner, + extractPoolWorkers, + buildOrkProjection +} = require('../../../workers/lib/server/handlers/miners.handlers') +const { + MINER_FIELD_MAP, + MINER_PROJECTION_MAP +} = require('../../../workers/lib/constants') + +function createMockMiner (overrides = {}) { + return { + id: 't-miner-antminer-192-168-1-101', + type: 'antminer-s19xp', + code: 'A101', + tags: ['t-miner'], + rack: 'rack-0', + info: { + container: 'bitdeer-4b', + pos: 'R3-S12', + serialNum: 'SN12345', + macAddress: 'AA:BB:CC:DD:EE:FF' + }, + opts: { address: '192.168.1.101' }, + last: { + snap: { + model: 'S19XP', + stats: { + status: 'mining', + hashrate_mhs: 140000000, + power_w: 3010, + temperature_c: 72, + efficiency_w_ths: 21.5 + }, + config: { + power_mode: 'normal', + firmware_ver: '2024.01.15', + led_status: 'normal', + pool_config: { url: 'stratum+tcp://pool.example.com', worker: 'worker1' } + } + }, + alerts: { critical: 0, high: 0, medium: 1 }, + uptime: 1209600000, + ts: 1709266500000 + }, + comments: [], + ...overrides + } +} + +function createMockCtx (miners, opts = {}) { + return { + conf: { + featureConfig: opts.featureConfig || {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + if (method === 'listThings') return [miners] + if (method === 'getThingsCount') return [opts.thingsCount ?? miners.length] + if (method === 'getWrkExtData') return opts.poolData || [[]] + return [{}] + } + } + } +} + +// --- formatMiner --- + +test('formatMiner - transforms raw miner to clean format', (t) => { + const raw = createMockMiner() + const result = formatMiner(raw, null) + + t.is(result.id, 't-miner-antminer-192-168-1-101') + t.is(result.type, 'antminer-s19xp') + t.is(result.model, 'S19XP') + t.is(result.code, 'A101') + t.is(result.ip, '192.168.1.101') + t.is(result.container, 'bitdeer-4b') + t.is(result.rack, 'rack-0') + t.is(result.position, 'R3-S12') + t.is(result.status, 'mining') + t.is(result.hashrate, 140000000) + t.is(result.power, 3010) + t.is(result.temperature, 72) + t.is(result.efficiency, 21.5) + t.is(result.firmware, '2024.01.15') + t.is(result.powerMode, 'normal') + t.is(result.ledStatus, 'normal') + t.is(result.serialNum, 'SN12345') + t.is(result.lastSeen, 1709266500000) + t.pass() +}) + +test('formatMiner - handles missing nested fields', (t) => { + const raw = { id: 'test', type: 'miner' } + const result = formatMiner(raw, null) + + t.is(result.id, 'test') + t.is(result.hashrate, 0) + t.is(result.power, 0) + t.is(result.efficiency, 0) + t.is(result.status, undefined) + t.is(result.ip, undefined) + t.pass() +}) + +test('formatMiner - enriches with pool hashrate', (t) => { + const raw = createMockMiner() + const poolWorkers = { + 't-miner-antminer-192-168-1-101': { hashrate: 139500000 } + } + const result = formatMiner(raw, poolWorkers) + + t.is(result.poolHashrate, 139500000) + t.pass() +}) + +test('formatMiner - enriches by code fallback', (t) => { + const raw = createMockMiner() + const poolWorkers = { + A101: { hashrate: 139500000 } + } + const result = formatMiner(raw, poolWorkers) + + t.is(result.poolHashrate, 139500000) + t.pass() +}) + +test('formatMiner - no poolHashrate when no match', (t) => { + const raw = createMockMiner() + const poolWorkers = { 'other-id': { hashrate: 100 } } + const result = formatMiner(raw, poolWorkers) + + t.is(result.poolHashrate, undefined) + t.pass() +}) + +// --- extractPoolWorkers --- + +test('extractPoolWorkers - builds worker lookup', (t) => { + const poolData = [ + [ + { + stats: { hashrate: 100 }, + workers: { + 'miner-1': { hashrate: 50 }, + 'miner-2': { hashrate: 50 } + } + } + ] + ] + const result = extractPoolWorkers(poolData) + + t.is(result['miner-1'].hashrate, 50) + t.is(result['miner-2'].hashrate, 50) + t.pass() +}) + +test('extractPoolWorkers - handles empty data', (t) => { + const result = extractPoolWorkers([]) + t.is(Object.keys(result).length, 0) + t.pass() +}) + +test('extractPoolWorkers - handles pools without workers', (t) => { + const poolData = [[{ stats: { hashrate: 100 } }]] + const result = extractPoolWorkers(poolData) + t.is(Object.keys(result).length, 0) + t.pass() +}) + +// --- listMiners --- + +test('listMiners - returns paginated response with formatted miners', async (t) => { + const miners = [createMockMiner(), createMockMiner({ id: 'miner-2', code: 'A102' })] + const ctx = createMockCtx(miners) + const req = { query: {} } + + const result = await listMiners(ctx, req) + + t.ok(result.data) + t.is(result.data.length, 2) + t.is(result.totalCount, 2) + t.is(result.offset, 0) + t.is(result.limit, 50) + t.is(result.hasMore, false) + t.is(result.data[0].id, 't-miner-antminer-192-168-1-101') + t.is(result.data[0].model, 'S19XP') + t.pass() +}) + +test('listMiners - applies pagination', async (t) => { + const miners = Array.from({ length: 10 }, (_, i) => + createMockMiner({ id: `miner-${i}`, code: `A${i}` }) + ) + const ctx = createMockCtx(miners) + const req = { query: { offset: 2, limit: 3 } } + + const result = await listMiners(ctx, req) + + t.is(result.data.length, 3) + t.is(result.totalCount, 10) + t.is(result.offset, 2) + t.is(result.limit, 3) + t.is(result.hasMore, true) + t.pass() +}) + +test('listMiners - enforces max limit of 200', async (t) => { + const ctx = createMockCtx([]) + const req = { query: { limit: 500 } } + + const result = await listMiners(ctx, req) + + t.is(result.limit, 200) + t.pass() +}) + +test('listMiners - parses filter JSON', async (t) => { + const capturedCalls = [] + const ctx = { + conf: { + featureConfig: {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + capturedCalls.push({ method, payload }) + if (method === 'getThingsCount') return [0] + return [[]] + } + } + } + const req = { query: { filter: '{"status":"error"}' } } + + await listMiners(ctx, req) + + const dataCall = capturedCalls.find(c => c.method === 'listThings') + t.ok(dataCall.payload.query.$and) + t.is(dataCall.payload.query.$and[0].tags.$in[0], 't-miner') + t.is(dataCall.payload.query.$and[1]['last.snap.stats.status'], 'error') + t.pass() +}) + +test('listMiners - builds search query', async (t) => { + const capturedCalls = [] + const ctx = { + conf: { + featureConfig: {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + capturedCalls.push({ method, payload }) + if (method === 'getThingsCount') return [0] + return [[]] + } + } + } + const req = { query: { search: '192.168' } } + + await listMiners(ctx, req) + + const dataCall = capturedCalls.find(c => c.method === 'listThings') + const lastCondition = dataCall.payload.query.$and[dataCall.payload.query.$and.length - 1] + t.ok(lastCondition.$or) + t.ok(lastCondition.$or.some(c => c.id?.$regex === '192.168')) + t.ok(lastCondition.$or.some(c => c['opts.address']?.$regex === '192.168')) + t.pass() +}) + +test('listMiners - handles empty ork results', async (t) => { + const ctx = createMockCtx([]) + const req = { query: {} } + + const result = await listMiners(ctx, req) + + t.is(result.data.length, 0) + t.is(result.totalCount, 0) + t.pass() +}) + +test('listMiners - throws on invalid filter JSON', async (t) => { + const ctx = createMockCtx([]) + const req = { query: { filter: 'not-json' } } + + try { + await listMiners(ctx, req) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_FILTER_INVALID_JSON') + } + t.pass() +}) + +test('listMiners - throws on invalid sort JSON', async (t) => { + const ctx = createMockCtx([]) + const req = { query: { sort: '{invalid' } } + + try { + await listMiners(ctx, req) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_SORT_INVALID_JSON') + } + t.pass() +}) + +test('MINER_FIELD_MAP - has expected field mappings', (t) => { + t.is(MINER_FIELD_MAP.status, 'last.snap.stats.status') + t.is(MINER_FIELD_MAP.hashrate, 'last.snap.stats.hashrate_mhs') + t.is(MINER_FIELD_MAP.ip, 'opts.address') + t.is(MINER_FIELD_MAP.container, 'info.container') + t.is(MINER_FIELD_MAP.model, 'last.snap.model') + t.pass() +}) + +test('listMiners - sends limit (offset+limit) to ork data query', async (t) => { + const capturedCalls = [] + const ctx = { + conf: { + featureConfig: {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + capturedCalls.push({ method, payload }) + if (method === 'getThingsCount') return [0] + return [[]] + } + } + } + const req = { query: { offset: 10, limit: 25 } } + + await listMiners(ctx, req) + + // Data payload should have limit = offset + limit = 35 + const dataCall = capturedCalls.find(c => c.method === 'listThings') + t.is(dataCall.payload.limit, 35) + t.pass() +}) + +test('listMiners - sends getThingsCount RPC for total count', async (t) => { + const capturedCalls = [] + const ctx = { + conf: { + featureConfig: {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + capturedCalls.push({ method, payload }) + if (method === 'getThingsCount') return [0] + return [[]] + } + } + } + const req = { query: {} } + + await listMiners(ctx, req) + + const countCall = capturedCalls.find(c => c.method === 'getThingsCount') + t.ok(countCall, 'should call getThingsCount') + t.ok(countCall.payload.query, 'count payload has query') + t.is(countCall.payload.status, 1, 'count payload has status: 1') + t.is(countCall.payload.fields, undefined, 'count payload has no fields projection') + t.is(countCall.payload.limit, undefined, 'count payload has no limit') + t.pass() +}) + +test('listMiners - totalCount reflects all matching items, not just overfetched page', async (t) => { + // Simulate: 50 miners exist, but user requests offset=0, limit=5 → overfetch 5 from ork + // The count query returns all 50, data query returns 5 (mock returns all, but real ork would limit) + const allMiners = Array.from({ length: 50 }, (_, i) => + createMockMiner({ id: `miner-${i}`, code: `A${i}` }) + ) + const ctx = createMockCtx(allMiners) + const req = { query: { offset: 0, limit: 5 } } + + const result = await listMiners(ctx, req) + + // totalCount should be 50 (from count query), data should be 5 (sliced) + t.is(result.totalCount, 50) + t.is(result.data.length, 5) + t.is(result.hasMore, true) + t.pass() +}) + +test('listMiners - makes one listThings + one getThingsCount RPC call', async (t) => { + let listThingsCount = 0 + let getThingsCountCount = 0 + const ctx = { + conf: { + featureConfig: {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + if (method === 'listThings') { listThingsCount++; return [[]] } + if (method === 'getThingsCount') { getThingsCountCount++; return [0] } + return [[]] + } + } + } + const req = { query: {} } + + await listMiners(ctx, req) + + t.is(listThingsCount, 1, 'one listThings call for data') + t.is(getThingsCountCount, 1, 'one getThingsCount call for count') + t.pass() +}) + +// --- Projection: clean field names --- + +test('buildOrkProjection - maps clean names to internal paths', (t) => { + const result = buildOrkProjection({ firmware: 1, ip: 1 }) + + t.is(result.id, 1, 'always includes id') + t.is(result.code, 1, 'always includes code') + t.is(result['last.snap.config.firmware_ver'], 1, 'maps firmware') + t.is(result['opts.address'], 1, 'maps ip') + t.is(result['last.snap.stats.hashrate_mhs'], undefined, 'does not include unrequested fields') + t.pass() +}) + +test('buildOrkProjection - includes sort fields for app-side sorting', (t) => { + const userFields = { status: 1 } + const mappedSort = { 'last.snap.stats.hashrate_mhs': -1 } + const result = buildOrkProjection(userFields, mappedSort) + + t.is(result['last.snap.stats.status'], 1, 'maps requested field') + t.is(result['last.snap.stats.hashrate_mhs'], 1, 'includes sort field') + t.pass() +}) + +test('buildOrkProjection - handles multi-path fields (model needs snap.model + type)', (t) => { + const result = buildOrkProjection({ model: 1 }) + + t.is(result['last.snap.model'], 1, 'includes primary path') + t.is(result.type, 1, 'includes fallback path') + t.pass() +}) + +test('buildOrkProjection - passes through unknown field names as-is', (t) => { + const result = buildOrkProjection({ 'some.custom.path': 1 }) + + t.is(result['some.custom.path'], 1, 'passes through raw path') + t.pass() +}) + +test('MINER_PROJECTION_MAP - covers all response fields', (t) => { + const expectedFields = [ + 'id', 'type', 'model', 'code', 'ip', 'container', 'rack', 'position', + 'status', 'hashrate', 'power', 'temperature', 'efficiency', 'uptime', + 'firmware', 'powerMode', 'ledStatus', 'poolConfig', 'alerts', + 'comments', 'serialNum', 'macAddress', 'lastSeen' + ] + for (const field of expectedFields) { + t.ok(MINER_PROJECTION_MAP[field], `should have mapping for ${field}`) + t.ok(Array.isArray(MINER_PROJECTION_MAP[field]), `${field} should be an array of paths`) + } + t.pass() +}) + +test('formatMiner - only includes requested fields when projection specified', (t) => { + const raw = createMockMiner() + const requestedFields = new Set(['firmware', 'ip', 'status']) + const result = formatMiner(raw, null, requestedFields) + + t.is(result.id, 't-miner-antminer-192-168-1-101', 'always includes id') + t.is(result.firmware, '2024.01.15', 'includes requested firmware') + t.is(result.ip, '192.168.1.101', 'includes requested ip') + t.is(result.status, 'mining', 'includes requested status') + t.is(result.hashrate, undefined, 'excludes unrequested hashrate') + t.is(result.power, undefined, 'excludes unrequested power') + t.is(result.efficiency, undefined, 'excludes unrequested efficiency') + t.is(result.model, undefined, 'excludes unrequested model') + t.is(result.container, undefined, 'excludes unrequested container') + t.pass() +}) + +test('formatMiner - returns all fields when no projection (null)', (t) => { + const raw = createMockMiner() + const result = formatMiner(raw, null, null) + + t.ok(result.hashrate !== undefined, 'includes hashrate') + t.ok(result.power !== undefined, 'includes power') + t.ok(result.efficiency !== undefined, 'includes efficiency') + t.ok(result.firmware !== undefined, 'includes firmware') + t.ok(result.model !== undefined, 'includes model') + t.pass() +}) + +test('listMiners - maps user fields to ork projection and filters response', async (t) => { + const capturedCalls = [] + const miners = [createMockMiner()] + const ctx = { + conf: { + featureConfig: {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + capturedCalls.push({ method, payload }) + if (method === 'getThingsCount') return [miners.length] + return [miners] + } + } + } + const req = { query: { fields: '{"firmware":1,"ip":1}' } } + + const result = await listMiners(ctx, req) + + // Check ork projection was mapped correctly + const dataCall = capturedCalls.find(c => c.method === 'listThings') + t.is(dataCall.payload.fields['last.snap.config.firmware_ver'], 1, 'maps firmware to ork path') + t.is(dataCall.payload.fields['opts.address'], 1, 'maps ip to ork path') + t.is(dataCall.payload.fields.id, 1, 'always includes id') + t.is(dataCall.payload.fields.code, 1, 'always includes code') + + // Check response only has requested fields + const miner = result.data[0] + t.ok(miner.id, 'always includes id in response') + t.ok(miner.firmware, 'includes requested firmware') + t.ok(miner.ip, 'includes requested ip') + t.is(miner.hashrate, undefined, 'excludes unrequested hashrate') + t.is(miner.power, undefined, 'excludes unrequested power') + t.is(miner.efficiency, undefined, 'excludes unrequested efficiency') + t.pass() +}) diff --git a/tests/unit/lib/queryUtils.test.js b/tests/unit/lib/queryUtils.test.js new file mode 100644 index 0000000..5231fed --- /dev/null +++ b/tests/unit/lib/queryUtils.test.js @@ -0,0 +1,277 @@ +'use strict' + +const test = require('brittle') +const { + getNestedValue, + mapFilterFields, + mapSortFields, + buildSearchQuery, + flattenOrkResults, + sortItems, + paginateResults +} = require('../../../workers/lib/server/lib/queryUtils') + +const FIELD_MAP = { + status: 'last.snap.stats.status', + hashrate: 'last.snap.stats.hashrate_mhs', + container: 'info.container', + ip: 'opts.address' +} + +// --- getNestedValue --- + +test('getNestedValue - gets simple key', (t) => { + t.is(getNestedValue({ a: 1 }, 'a'), 1) + t.pass() +}) + +test('getNestedValue - gets nested key', (t) => { + t.is(getNestedValue({ a: { b: { c: 42 } } }, 'a.b.c'), 42) + t.pass() +}) + +test('getNestedValue - returns undefined for missing path', (t) => { + t.is(getNestedValue({ a: 1 }, 'a.b.c'), undefined) + t.pass() +}) + +test('getNestedValue - handles null object', (t) => { + t.is(getNestedValue(null, 'a'), undefined) + t.pass() +}) + +// --- mapFilterFields --- + +test('mapFilterFields - maps simple equality', (t) => { + const result = mapFilterFields({ status: 'error' }, FIELD_MAP) + t.is(result['last.snap.stats.status'], 'error') + t.pass() +}) + +test('mapFilterFields - maps range operators', (t) => { + const result = mapFilterFields({ hashrate: { $gt: 0 } }, FIELD_MAP) + t.ok(result['last.snap.stats.hashrate_mhs']) + t.is(result['last.snap.stats.hashrate_mhs'].$gt, 0) + t.pass() +}) + +test('mapFilterFields - maps $and arrays', (t) => { + const result = mapFilterFields({ + $and: [ + { status: 'error' }, + { hashrate: { $gt: 0 } } + ] + }, FIELD_MAP) + t.ok(Array.isArray(result.$and)) + t.is(result.$and.length, 2) + t.ok(result.$and[0]['last.snap.stats.status']) + t.ok(result.$and[1]['last.snap.stats.hashrate_mhs']) + t.pass() +}) + +test('mapFilterFields - maps $or arrays', (t) => { + const result = mapFilterFields({ + $or: [{ status: 'error' }, { status: 'offline' }] + }, FIELD_MAP) + t.ok(Array.isArray(result.$or)) + t.is(result.$or[0]['last.snap.stats.status'], 'error') + t.is(result.$or[1]['last.snap.stats.status'], 'offline') + t.pass() +}) + +test('mapFilterFields - passes through unknown keys', (t) => { + const result = mapFilterFields({ 'last.snap.model': 'S19XP' }, FIELD_MAP) + t.is(result['last.snap.model'], 'S19XP') + t.pass() +}) + +test('mapFilterFields - handles null/undefined filter', (t) => { + t.is(mapFilterFields(null, FIELD_MAP), null) + t.is(mapFilterFields(undefined, FIELD_MAP), undefined) + t.pass() +}) + +test('mapFilterFields - handles $in operator in value', (t) => { + const result = mapFilterFields({ status: { $in: ['error', 'offline'] } }, FIELD_MAP) + t.ok(result['last.snap.stats.status'].$in) + t.is(result['last.snap.stats.status'].$in.length, 2) + t.pass() +}) + +test('mapFilterFields - handles combined AND/OR', (t) => { + const result = mapFilterFields({ + container: 'bitdeer-4b', + $or: [{ status: 'error' }, { status: 'offline' }] + }, FIELD_MAP) + t.is(result['info.container'], 'bitdeer-4b') + t.ok(Array.isArray(result.$or)) + t.pass() +}) + +// --- mapSortFields --- + +test('mapSortFields - maps sort keys', (t) => { + const result = mapSortFields({ hashrate: -1, status: 1 }, FIELD_MAP) + t.is(result['last.snap.stats.hashrate_mhs'], -1) + t.is(result['last.snap.stats.status'], 1) + t.pass() +}) + +test('mapSortFields - passes through unknown keys', (t) => { + const result = mapSortFields({ 'info.pos': 1 }, FIELD_MAP) + t.is(result['info.pos'], 1) + t.pass() +}) + +test('mapSortFields - handles null', (t) => { + t.is(mapSortFields(null, FIELD_MAP), null) + t.pass() +}) + +// --- buildSearchQuery --- + +test('buildSearchQuery - builds multi-field OR regex', (t) => { + const result = buildSearchQuery('192.168', ['id', 'opts.address', 'code']) + t.ok(result.$or) + t.is(result.$or.length, 3) + t.is(result.$or[0].id.$regex, '192.168') + t.is(result.$or[0].id.$options, 'i') + t.is(result.$or[1]['opts.address'].$regex, '192.168') + t.pass() +}) + +// --- flattenOrkResults --- + +test('flattenOrkResults - flattens multiple ork arrays', (t) => { + const results = [[{ id: 'a' }, { id: 'b' }], [{ id: 'c' }]] + const flat = flattenOrkResults(results) + t.is(flat.length, 3) + t.is(flat[0].id, 'a') + t.is(flat[2].id, 'c') + t.pass() +}) + +test('flattenOrkResults - handles empty arrays', (t) => { + t.is(flattenOrkResults([]).length, 0) + t.is(flattenOrkResults([[], []]).length, 0) + t.pass() +}) + +test('flattenOrkResults - handles non-array ork results', (t) => { + const results = [{ error: 'timeout' }, [{ id: 'a' }]] + const flat = flattenOrkResults(results) + t.is(flat.length, 1) + t.is(flat[0].id, 'a') + t.pass() +}) + +// --- sortItems --- + +test('sortItems - sorts ascending', (t) => { + const items = [{ v: 3 }, { v: 1 }, { v: 2 }] + sortItems(items, { v: 1 }) + t.is(items[0].v, 1) + t.is(items[1].v, 2) + t.is(items[2].v, 3) + t.pass() +}) + +test('sortItems - sorts descending', (t) => { + const items = [{ v: 1 }, { v: 3 }, { v: 2 }] + sortItems(items, { v: -1 }) + t.is(items[0].v, 3) + t.is(items[1].v, 2) + t.is(items[2].v, 1) + t.pass() +}) + +test('sortItems - sorts by nested path', (t) => { + const items = [ + { a: { b: 3 } }, + { a: { b: 1 } }, + { a: { b: 2 } } + ] + sortItems(items, { 'a.b': 1 }) + t.is(items[0].a.b, 1) + t.is(items[2].a.b, 3) + t.pass() +}) + +test('sortItems - handles null sort', (t) => { + const items = [{ v: 2 }, { v: 1 }] + sortItems(items, null) + t.is(items[0].v, 2) + t.pass() +}) + +test('sortItems - null values sort last', (t) => { + const items = [{ v: null }, { v: 2 }, { v: 1 }] + sortItems(items, { v: 1 }) + t.is(items[0].v, 1) + t.is(items[1].v, 2) + t.is(items[2].v, null) + t.pass() +}) + +test('sortItems - multi-key sort', (t) => { + const items = [ + { a: 1, b: 2 }, + { a: 1, b: 1 }, + { a: 2, b: 1 } + ] + sortItems(items, { a: 1, b: 1 }) + t.is(items[0].b, 1) + t.is(items[1].b, 2) + t.is(items[2].a, 2) + t.pass() +}) + +// --- paginateResults --- + +test('paginateResults - first page', (t) => { + const items = Array.from({ length: 100 }, (_, i) => ({ id: i })) + const result = paginateResults(items, 0, 10) + t.is(result.data.length, 10) + t.is(result.totalCount, 100) + t.is(result.offset, 0) + t.is(result.limit, 10) + t.is(result.hasMore, true) + t.is(result.data[0].id, 0) + t.pass() +}) + +test('paginateResults - middle page', (t) => { + const items = Array.from({ length: 100 }, (_, i) => ({ id: i })) + const result = paginateResults(items, 20, 10) + t.is(result.data.length, 10) + t.is(result.data[0].id, 20) + t.is(result.offset, 20) + t.is(result.hasMore, true) + t.pass() +}) + +test('paginateResults - last page', (t) => { + const items = Array.from({ length: 25 }, (_, i) => ({ id: i })) + const result = paginateResults(items, 20, 10) + t.is(result.data.length, 5) + t.is(result.totalCount, 25) + t.is(result.hasMore, false) + t.pass() +}) + +test('paginateResults - empty results', (t) => { + const result = paginateResults([], 0, 10) + t.is(result.data.length, 0) + t.is(result.totalCount, 0) + t.is(result.hasMore, false) + t.pass() +}) + +test('paginateResults - offset beyond total', (t) => { + const items = [{ id: 1 }] + const result = paginateResults(items, 50, 10) + t.is(result.data.length, 0) + t.is(result.totalCount, 1) + t.is(result.hasMore, false) + t.pass() +}) diff --git a/tests/unit/routes/miners.routes.test.js b/tests/unit/routes/miners.routes.test.js new file mode 100644 index 0000000..a887183 --- /dev/null +++ b/tests/unit/routes/miners.routes.test.js @@ -0,0 +1,65 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/miners.routes.js' + +test('miners routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'miners') + t.pass() +}) + +test('miners routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/miners'), 'should have miners route') + + t.pass() +}) + +test('miners routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const minersRoute = routes.find(r => r.url === '/auth/miners') + t.is(minersRoute.method, 'GET', 'miners route should be GET') + + t.pass() +}) + +test('miners routes - schema validation', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const minersRoute = routes.find(r => r.url === '/auth/miners') + t.ok(minersRoute.schema, 'should have schema') + t.ok(minersRoute.schema.querystring, 'should have querystring schema') + + const props = minersRoute.schema.querystring.properties + t.is(props.filter.type, 'string', 'filter should be string') + t.is(props.sort.type, 'string', 'sort should be string') + t.is(props.fields.type, 'string', 'fields should be string') + t.is(props.search.type, 'string', 'search should be string') + t.is(props.offset.type, 'integer', 'offset should be integer') + t.is(props.limit.type, 'integer', 'limit should be integer') + t.is(props.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + + t.pass() +}) + +test('miners routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'miners') + t.pass() +}) + +test('miners routes - onRequest functions (auth)', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.ok(typeof route.onRequest === 'function', `miners route ${route.url} should have onRequest function`) + }) + + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 459b562..6d052c3 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -159,7 +159,9 @@ const ENDPOINTS = { // Alerts endpoints ALERTS_SITE: '/auth/alerts/site', - ALERTS_HISTORY: '/auth/alerts/history' + ALERTS_HISTORY: '/auth/alerts/history', + + MINERS: '/auth/miners' } const HTTP_METHODS = { @@ -355,6 +357,82 @@ const CONFIG_TYPES = { POOL: 'pool' } +const MINER_FIELD_MAP = { + status: 'last.snap.stats.status', + hashrate: 'last.snap.stats.hashrate_mhs', + power: 'last.snap.stats.power_w', + efficiency: 'last.snap.stats.efficiency_w_ths', + temperature: 'last.snap.stats.temperature_c', + powerMode: 'last.snap.config.power_mode', + firmware: 'last.snap.config.firmware_ver', + model: 'last.snap.model', + ip: 'opts.address', + container: 'info.container', + rack: 'rack', + serialNum: 'info.serialNum', + macAddress: 'info.macAddress', + pool: 'last.snap.config.pool_config.url', + led: 'last.snap.config.led_status', + alerts: 'last.alerts' +} + +const MINER_PROJECTION_MAP = { + id: ['id'], + type: ['type'], + model: ['last.snap.model', 'type'], + code: ['code'], + ip: ['opts.address'], + container: ['info.container'], + rack: ['rack'], + position: ['info.pos'], + status: ['last.snap.stats.status'], + hashrate: ['last.snap.stats.hashrate_mhs'], + power: ['last.snap.stats.power_w'], + temperature: ['last.snap.stats.temperature_c'], + efficiency: ['last.snap.stats.efficiency_w_ths'], + uptime: ['last.uptime'], + firmware: ['last.snap.config.firmware_ver'], + powerMode: ['last.snap.config.power_mode'], + ledStatus: ['last.snap.config.led_status'], + poolConfig: ['last.snap.config.pool_config'], + alerts: ['last.alerts'], + comments: ['comments'], + serialNum: ['info.serialNum'], + macAddress: ['info.macAddress'], + lastSeen: ['last.ts', 'ts'] +} + +const MINER_SEARCH_FIELDS = [ + 'id', + 'opts.address', + 'info.serialNum', + 'info.macAddress', + 'info.container', + 'code', + 'type' +] + +const MINER_DEFAULT_FIELDS = { + id: 1, + type: 1, + code: 1, + info: 1, + tags: 1, + rack: 1, + comments: 1, + 'last.alerts': 1, + 'last.snap.stats': 1, + 'last.snap.config': 1, + 'last.snap.model': 1, + 'last.uptime': 1, + 'last.ts': 1, + 'opts.address': 1, + ts: 1 +} + +const MINER_MAX_LIMIT = 200 +const MINER_DEFAULT_LIMIT = 50 + module.exports = { SUPER_ADMIN_ROLE, GLOBAL_DATA_TYPES, @@ -400,5 +478,11 @@ module.exports = { SITE_ALERTS_SEARCH_FIELDS, HISTORY_FILTER_FIELDS, HISTORY_SEARCH_FIELDS, - DEVICE_LIST_FIELDS + DEVICE_LIST_FIELDS, + MINER_FIELD_MAP, + MINER_PROJECTION_MAP, + MINER_SEARCH_FIELDS, + MINER_DEFAULT_FIELDS, + MINER_MAX_LIMIT, + MINER_DEFAULT_LIMIT } diff --git a/workers/lib/server/handlers/miners.handlers.js b/workers/lib/server/handlers/miners.handlers.js new file mode 100644 index 0000000..d18a55b --- /dev/null +++ b/workers/lib/server/handlers/miners.handlers.js @@ -0,0 +1,180 @@ +'use strict' + +const { parseJsonQueryParam } = require('../../utils') +const { + MINER_FIELD_MAP, + MINER_PROJECTION_MAP, + MINER_SEARCH_FIELDS, + MINER_DEFAULT_FIELDS, + MINER_MAX_LIMIT, + MINER_DEFAULT_LIMIT +} = require('../../constants') +const { + mapFilterFields, + mapSortFields, + buildSearchQuery, + flattenOrkResults, + sortItems +} = require('../lib/queryUtils') + +/** + * Builds ork projection from user-requested clean field names. + * Always includes id and code (needed for pool matching). + * Includes sort field paths so app-side sorting works on projected data. + */ +function buildOrkProjection (userFields, mappedSort) { + const projection = { id: 1, code: 1 } + + for (const [field, value] of Object.entries(userFields)) { + if (value !== 1) continue + const paths = MINER_PROJECTION_MAP[field] + if (paths) { + for (const path of paths) { projection[path] = 1 } + } else { + projection[field] = value + } + } + + if (mappedSort) { + for (const path of Object.keys(mappedSort)) { + projection[path] = 1 + } + } + + return projection +} + +function formatMiner (raw, poolWorkers, requestedFields) { + const snap = raw.last?.snap || {} + const stats = snap.stats || {} + const config = snap.config || {} + + const include = (field) => !requestedFields || requestedFields.has(field) + + const miner = { id: raw.id } + + if (include('type')) miner.type = raw.type + if (include('model')) miner.model = snap.model || raw.type + if (include('code')) miner.code = raw.code + if (include('ip')) miner.ip = raw.opts?.address + if (include('container')) miner.container = raw.info?.container + if (include('rack')) miner.rack = raw.rack + if (include('position')) miner.position = raw.info?.pos + if (include('status')) miner.status = stats.status + if (include('hashrate')) miner.hashrate = stats.hashrate_mhs || 0 + if (include('power')) miner.power = stats.power_w || 0 + if (include('temperature')) miner.temperature = stats.temperature_c + if (include('efficiency')) miner.efficiency = stats.efficiency_w_ths || 0 + if (include('uptime')) miner.uptime = raw.last?.uptime + if (include('firmware')) miner.firmware = config.firmware_ver + if (include('powerMode')) miner.powerMode = config.power_mode + if (include('ledStatus')) miner.ledStatus = config.led_status + if (include('poolConfig')) miner.poolConfig = config.pool_config + if (include('alerts')) miner.alerts = raw.last?.alerts + if (include('comments')) miner.comments = raw.comments + if (include('serialNum')) miner.serialNum = raw.info?.serialNum + if (include('macAddress')) miner.macAddress = raw.info?.macAddress + if (include('lastSeen')) miner.lastSeen = raw.last?.ts || raw.ts + + if (poolWorkers && include('poolHashrate')) { + const poolWorker = poolWorkers[raw.id] || poolWorkers[raw.code] + if (poolWorker) { + miner.poolHashrate = poolWorker.hashrate || 0 + } + } + + return miner +} + +function extractPoolWorkers (poolDataResults) { + const workers = {} + for (const orkResult of poolDataResults) { + if (!Array.isArray(orkResult)) continue + for (const pool of orkResult) { + if (!pool || !pool.workers) continue + for (const [workerId, workerData] of Object.entries(pool.workers)) { + workers[workerId] = workerData + } + } + } + return workers +} + +async function listMiners (ctx, req) { + const userFilter = req.query.filter + ? parseJsonQueryParam(req.query.filter, 'ERR_FILTER_INVALID_JSON') + : {} + + const mappedFilter = mapFilterFields(userFilter, MINER_FIELD_MAP) + + const query = { + $and: [ + { tags: { $in: ['t-miner'] } }, + ...(Object.keys(mappedFilter).length ? [mappedFilter] : []), + ...(req.query.search ? [buildSearchQuery(req.query.search, MINER_SEARCH_FIELDS)] : []) + ] + } + + const mappedSort = req.query.sort + ? mapSortFields(parseJsonQueryParam(req.query.sort, 'ERR_SORT_INVALID_JSON'), MINER_FIELD_MAP) + : undefined + + const userFields = req.query.fields + ? parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') + : null + + const orkProjection = userFields + ? buildOrkProjection(userFields, mappedSort) + : MINER_DEFAULT_FIELDS + + const requestedFields = userFields + ? new Set(Object.keys(userFields).filter(k => userFields[k] === 1)) + : null + + const offset = req.query.offset || 0 + const limit = Math.min(req.query.limit || MINER_DEFAULT_LIMIT, MINER_MAX_LIMIT) + const fetchLimit = offset + limit + + const dataPayload = { query, fields: orkProjection, status: 1 } + if (mappedSort) { dataPayload.sort = mappedSort } + + const [orkResults, countResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('listThings', { ...dataPayload, limit: fetchLimit }), + ctx.dataProxy.requestDataMap('getThingsCount', { query, status: 1 }) + ]) + + let items = flattenOrkResults(orkResults) + const totalCount = countResults.reduce((acc, c) => acc + (c || 0), 0) + + if (mappedSort) { + items = sortItems(items, mappedSort) + } + + let poolWorkers = null + if (ctx.conf.featureConfig?.poolStats) { + try { + const poolData = await ctx.dataProxy.requestDataMap('getWrkExtData', { + type: 'minerpool', + query: { key: 'stats' } + }) + poolWorkers = extractPoolWorkers(poolData) + } catch { } + } + + const page = items.slice(offset, offset + limit) + + return { + data: page.map(miner => formatMiner(miner, poolWorkers, requestedFields)), + totalCount, + offset, + limit, + hasMore: offset + limit < totalCount + } +} + +module.exports = { + listMiners, + formatMiner, + extractPoolWorkers, + buildOrkProjection +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 7e61145..5b11952 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -16,6 +16,7 @@ const configsRoutes = require('./routes/configs.routes') const devicesRoutes = require('./routes/devices.routes') const metricsRoutes = require('./routes/metrics.routes') const alertsRoutes = require('./routes/alerts.routes') +const minersRoutes = require('./routes/miners.routes') /** * Collect all routes into a flat array for server injection. @@ -38,7 +39,8 @@ function routes (ctx) { ...configsRoutes(ctx), ...devicesRoutes(ctx), ...metricsRoutes(ctx), - ...alertsRoutes(ctx) + ...alertsRoutes(ctx), + ...minersRoutes(ctx) ] } diff --git a/workers/lib/server/lib/queryUtils.js b/workers/lib/server/lib/queryUtils.js new file mode 100644 index 0000000..bc0b9ff --- /dev/null +++ b/workers/lib/server/lib/queryUtils.js @@ -0,0 +1,183 @@ +'use strict' + +/** + * Shared query utilities for all list endpoints. + * Provides MongoDB-style filter field mapping, sort mapping, + * text search, multi-ork result flattening, sorting, and pagination. + */ + +const MONGO_OPERATORS = new Set([ + '$gt', '$gte', '$lt', '$lte', '$eq', '$ne', + '$in', '$nin', '$regex', '$options', '$exists', + '$elemMatch', '$not', '$type', '$size' +]) + +/** + * Gets a nested value from an object using a dot-separated path. + * + * @param {Object} obj - Source object + * @param {string} path - Dot-separated path (e.g. 'last.snap.stats.status') + * @returns {*} The value at the path, or undefined + */ +function getNestedValue (obj, path) { + const parts = path.split('.') + let current = obj + for (const part of parts) { + if (current == null) return undefined + current = current[part] + } + return current +} + +/** + * Recursively maps clean field names to internal dot-paths in a MongoDB-style filter. + * Handles $and, $or, $not, $elemMatch operators by recursing into their values. + * Unknown keys are passed through as-is (allows raw internal paths). + * + * @param {Object} filter - MongoDB-style filter object + * @param {Object} fieldMap - Map of clean name → internal path + * @returns {Object} Filter with mapped field names + * + * @example + * mapFilterFields({ status: 'error' }, { status: 'last.snap.stats.status' }) + * // → { 'last.snap.stats.status': 'error' } + * + * mapFilterFields({ $or: [{ status: 'error' }, { hashrate: { $gt: 0 } }] }, fieldMap) + * // → { $or: [{ 'last.snap.stats.status': 'error' }, { 'last.snap.stats.hashrate_mhs': { $gt: 0 } }] } + */ +function mapFilterFields (filter, fieldMap) { + if (!filter || typeof filter !== 'object') return filter + if (Array.isArray(filter)) return filter.map(f => mapFilterFields(f, fieldMap)) + + const mapped = {} + for (const [key, value] of Object.entries(filter)) { + if (key === '$and' || key === '$or') { + mapped[key] = Array.isArray(value) + ? value.map(f => mapFilterFields(f, fieldMap)) + : value + } else if (key === '$elemMatch' || key === '$not') { + mapped[key] = mapFilterFields(value, fieldMap) + } else if (MONGO_OPERATORS.has(key)) { + mapped[key] = value + } else { + const mappedKey = fieldMap[key] || key + mapped[mappedKey] = value + } + } + return mapped +} + +/** + * Maps clean field names to internal paths in a sort specification. + * + * @param {Object} sort - Sort spec: { field: 1 } (1=asc, -1=desc) + * @param {Object} fieldMap - Map of clean name → internal path + * @returns {Object} Sort with mapped field names + * + * @example + * mapSortFields({ hashrate: -1 }, { hashrate: 'last.snap.stats.hashrate_mhs' }) + * // → { 'last.snap.stats.hashrate_mhs': -1 } + */ +function mapSortFields (sort, fieldMap) { + if (!sort || typeof sort !== 'object') return sort + const mapped = {} + for (const [key, value] of Object.entries(sort)) { + mapped[fieldMap[key] || key] = value + } + return mapped +} + +/** + * Builds a text search query as a multi-field $or with $regex. + * + * @param {string} search - Search term + * @param {Array} searchFields - Internal field paths to search across + * @returns {Object} MongoDB-style $or query + * + * @example + * buildSearchQuery('192.168', ['id', 'opts.address']) + * // → { $or: [{ id: { $regex: '192.168', $options: 'i' } }, { 'opts.address': { $regex: '192.168', $options: 'i' } }] } + */ +function buildSearchQuery (search, searchFields) { + return { + $or: searchFields.map(field => ({ + [field]: { $regex: search, $options: 'i' } + })) + } +} + +/** + * Flattens multi-ork results into a single array. + * Each ork response for listThings is an array of items. + * requestRpcMapLimit returns [orkResult1, orkResult2, ...]. + * + * @param {Array} orkResults - Array of ork responses + * @returns {Array} Flattened array of all items + */ +function flattenOrkResults (orkResults) { + const items = [] + for (const orkResult of orkResults) { + if (Array.isArray(orkResult)) { + items.push(...orkResult) + } + } + return items +} + +/** + * Sorts items by a sort specification using internal dot-path fields. + * Handles multi-key sorting. Null/undefined values sort last. + * + * @param {Array} items - Items to sort + * @param {Object} sort - Sort spec: { 'internal.path': 1 or -1 } + * @returns {Array} Sorted items (mutates the original array) + */ +function sortItems (items, sort) { + if (!sort || typeof sort !== 'object' || Object.keys(sort).length === 0) return items + + const sortEntries = Object.entries(sort) + + return items.sort((a, b) => { + for (const [field, direction] of sortEntries) { + const aVal = getNestedValue(a, field) + const bVal = getNestedValue(b, field) + + if (aVal === bVal) continue + if (aVal == null) return direction + if (bVal == null) return -direction + + if (aVal < bVal) return -direction + if (aVal > bVal) return direction + } + return 0 + }) +} + +/** + * Creates a paginated response from a flat array of items. + * + * @param {Array} items - All matching items (already sorted) + * @param {number} offset - Pagination offset + * @param {number} limit - Page size + * @returns {Object} Paginated response + */ +function paginateResults (items, offset, limit) { + const page = items.slice(offset, offset + limit) + return { + data: page, + totalCount: items.length, + offset, + limit, + hasMore: offset + limit < items.length + } +} + +module.exports = { + getNestedValue, + mapFilterFields, + mapSortFields, + buildSearchQuery, + flattenOrkResults, + sortItems, + paginateResults +} diff --git a/workers/lib/server/routes/miners.routes.js b/workers/lib/server/routes/miners.routes.js new file mode 100644 index 0000000..38dca0e --- /dev/null +++ b/workers/lib/server/routes/miners.routes.js @@ -0,0 +1,45 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_CAPS, + AUTH_LEVELS +} = require('../../constants') +const { listMiners } = require('../handlers/miners.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.MINERS, + schema: { + querystring: { + type: 'object', + properties: { + filter: { type: 'string' }, + sort: { type: 'string' }, + fields: { type: 'string' }, + search: { type: 'string' }, + offset: { type: 'integer' }, + limit: { type: 'integer' } + } + } + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'miners', + req.query.filter, + req.query.sort, + req.query.fields, + req.query.search, + req.query.offset, + req.query.limit + ], + ENDPOINTS.MINERS, + listMiners, + [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] + ) + } +] From 5269caf361c2b409d7b3ac5199d01ff942031c93 Mon Sep 17 00:00:00 2001 From: tekwani Date: Sat, 14 Mar 2026 15:13:36 +0530 Subject: [PATCH 18/63] fix: config routes read permissions fix (#34) --- workers/lib/server/routes/configs.routes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workers/lib/server/routes/configs.routes.js b/workers/lib/server/routes/configs.routes.js index 77e6920..6852e16 100644 --- a/workers/lib/server/routes/configs.routes.js +++ b/workers/lib/server/routes/configs.routes.js @@ -35,7 +35,7 @@ module.exports = (ctx) => { (req) => ['configs', req.params.type, req.query.query, req.query.fields], ENDPOINTS.CONFIGS, getConfigs, - [AUTH_PERMISSIONS.POOL_CONFIG] + [`${AUTH_PERMISSIONS.POOL_CONFIG}:r`] ) } ] From c15cc031bd6028b14e822eec78bc3a54908e7a95 Mon Sep 17 00:00:00 2001 From: andretetherio Date: Sat, 14 Mar 2026 06:48:36 -0300 Subject: [PATCH 19/63] ci: improve coverage summary (NYC table) (#35) --- .github/workflows/ci.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 842a691..60c1eaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,11 +201,15 @@ jobs: EXIT=$? set -e echo "Running nyc report (text-summary)..." - npx --yes nyc report --reporter=text-summary 2>&1 | tee report-summary.txt - PCT=$(sed -n 's/^Lines[^:]*: *\([0-9.]*\)%.*/\1/p' report-summary.txt) - [ -z "$PCT" ] && PCT="—" + NYC_REPORT=$(npx --yes nyc report --reporter=text-summary 2>&1) + echo "$NYC_REPORT" + STMT=$(echo "$NYC_REPORT" | sed -n 's/^Statements[^:]*: *\([0-9.]*\)%.*/\1/p') + BRANCH=$(echo "$NYC_REPORT" | sed -n 's/^Branches[^:]*: *\([0-9.]*\)%.*/\1/p') + FN=$(echo "$NYC_REPORT" | sed -n 's/^Functions[^:]*: *\([0-9.]*\)%.*/\1/p') + LINES=$(echo "$NYC_REPORT" | sed -n 's/^Lines[^:]*: *\([0-9.]*\)%.*/\1/p') + [ -z "$STMT" ] && STMT="—"; [ -z "$BRANCH" ] && BRANCH="—"; [ -z "$FN" ] && FN="—"; [ -z "$LINES" ] && LINES="—" [ $EXIT -eq 0 ] && ICON="✅" || ICON="❌" - echo "coverage_result=$ICON Coverage ${PCT}% (min ${MIN}%)" >> "$GITHUB_OUTPUT" + echo "coverage_result=$ICON Stmt ${STMT}% | Branch ${BRANCH}% | Fn ${FN}% | Lines ${LINES}% (min ${MIN}%)" >> "$GITHUB_OUTPUT" exit $EXIT else echo "coverage_result=⚠️ No coverage report" >> "$GITHUB_OUTPUT" From b82a4b43b09f4d586a8c689956d5eab1f73614cb Mon Sep 17 00:00:00 2001 From: tekwani Date: Tue, 17 Mar 2026 23:45:53 +0530 Subject: [PATCH 20/63] feat: containers pool stats (#36) --- package.json | 4 +- tests/integration/api.miners.test.js | 730 ------------------ tests/integration/api.poolManager.test.js | 398 ---------- .../{api.security.test.js => api.test.js} | 459 ++++++++++- tests/integration/helpers/mock-data.js | 204 +++++ tests/unit/handlers/pools.handlers.test.js | 263 +++---- tests/unit/routes/miners.routes.test.js | 1 - tests/unit/routes/pools.routes.test.js | 24 +- workers/lib/constants.js | 22 +- workers/lib/server/controllers/poolManager.js | 59 -- workers/lib/server/handlers/pools.handlers.js | 126 +-- workers/lib/server/index.js | 2 - .../lib/server/routes/poolManager.routes.js | 120 --- workers/lib/server/routes/pools.routes.js | 25 +- workers/lib/server/services/poolManager.js | 263 ------- 15 files changed, 826 insertions(+), 1874 deletions(-) delete mode 100644 tests/integration/api.miners.test.js delete mode 100644 tests/integration/api.poolManager.test.js rename tests/integration/{api.security.test.js => api.test.js} (59%) create mode 100644 tests/integration/helpers/mock-data.js delete mode 100644 workers/lib/server/controllers/poolManager.js delete mode 100644 workers/lib/server/routes/poolManager.routes.js delete mode 100644 workers/lib/server/services/poolManager.js diff --git a/package.json b/package.json index 2dc6b1e..ca31d56 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "lint:fix": "standard --fix", "test": "npm run lint && npm run test:unit && npm run test:integration", "test:unit": "NODE_ENV=test brittle tests/unit/**/*.test.js", - "test:integration": "NODE_ENV=test npx brittle tests/integration/**/*.test.js", - "test:api": "NODE_ENV=test npx brittle tests/integration/api.security.test.js", + "test:integration": "NODE_ENV=test npx brittle tests/integration/*.test.js", + "test:api": "NODE_ENV=test npx brittle tests/integration/api.test.js", "test:ws": "NODE_ENV=test npx brittle tests/integration/ws.test.js", "test:watch": "brittle tests/unit/**/*.js --watch", "test:coverage": "brittle --coverage tests/unit/**/*.js", diff --git a/tests/integration/api.miners.test.js b/tests/integration/api.miners.test.js deleted file mode 100644 index 745c397..0000000 --- a/tests/integration/api.miners.test.js +++ /dev/null @@ -1,730 +0,0 @@ -'use strict' - -const test = require('brittle') -const fs = require('fs') -const { createWorker } = require('tether-svc-test-helper').worker -const { setTimeout: sleep } = require('timers/promises') -const HttpFacility = require('bfx-facs-http') -const { ENDPOINTS } = require('../../workers/lib/constants') - -test('Miners API', { timeout: 90000 }, async (main) => { - const baseDir = 'tests/integration' - let worker - let httpClient - const appNodePort = 5002 - const ip = '127.0.0.1' - const appNodeBaseUrl = `http://${ip}:${appNodePort}` - const readonlyUser = 'readonly-miners@test' - const siteOperatorUser = 'siteoperator-miners@test' - const encoding = 'json' - const invalidToken = 'invalid-token' - - main.teardown(async () => { - await httpClient.stop() - await worker.stop() - await sleep(2000) - fs.rmSync(`./${baseDir}/store`, { recursive: true, force: true }) - fs.rmSync(`./${baseDir}/status`, { recursive: true, force: true }) - fs.rmSync(`./${baseDir}/config`, { recursive: true, force: true }) - fs.rmSync(`./${baseDir}/db`, { recursive: true, force: true }) - }) - - const mockMiners = [ - { - id: 'miner-001', - type: 'antminer-s19', - code: 'M001', - info: { - container: 'container-A', - serialNum: 'SN-001', - macAddress: 'AA:BB:CC:DD:EE:01', - pos: 'A1' - }, - tags: ['t-miner'], - rack: 'rack-1', - comments: [], - opts: { address: '192.168.1.100' }, - ts: Date.now() - 60000, - last: { - ts: Date.now(), - uptime: 86400, - alerts: [], - snap: { - model: 'Antminer S19 XP', - stats: { - status: 'online', - hashrate_mhs: 140000, - power_w: 3010, - efficiency_w_ths: 21.5, - temperature_c: 65 - }, - config: { - firmware_ver: '2024.01.01', - power_mode: 'normal', - led_status: 'off', - pool_config: { - url: 'stratum+tcp://btc.f2pool.com:3333', - user: 'tether.worker1' - } - } - } - } - }, - { - id: 'miner-002', - type: 'antminer-s19', - code: 'M002', - info: { - container: 'container-A', - serialNum: 'SN-002', - macAddress: 'AA:BB:CC:DD:EE:02', - pos: 'A2' - }, - tags: ['t-miner'], - rack: 'rack-1', - comments: [{ text: 'needs maintenance' }], - opts: { address: '192.168.1.101' }, - ts: Date.now() - 120000, - last: { - ts: Date.now(), - uptime: 172800, - alerts: [{ type: 'high_temp', severity: 'medium' }], - snap: { - model: 'Antminer S19 XP', - stats: { - status: 'online', - hashrate_mhs: 135000, - power_w: 2980, - efficiency_w_ths: 22.1, - temperature_c: 72 - }, - config: { - firmware_ver: '2024.01.01', - power_mode: 'normal', - led_status: 'off', - pool_config: { - url: 'stratum+tcp://btc.f2pool.com:3333', - user: 'tether.worker2' - } - } - } - } - }, - { - id: 'miner-003', - type: 'whatsminer-m50s', - code: 'M003', - info: { - container: 'container-B', - serialNum: 'SN-003', - macAddress: 'AA:BB:CC:DD:EE:03', - pos: 'B1' - }, - tags: ['t-miner'], - rack: 'rack-2', - comments: [], - opts: { address: '192.168.2.100' }, - ts: Date.now() - 180000, - last: { - ts: Date.now() - 300000, - uptime: 3600, - alerts: [], - snap: { - model: 'Whatsminer M50S', - stats: { - status: 'error', - hashrate_mhs: 0, - power_w: 50, - efficiency_w_ths: 0, - temperature_c: 30 - }, - config: { - firmware_ver: '2023.12.15', - power_mode: 'normal', - led_status: 'on', - pool_config: { - url: 'stratum+tcp://ocean.xyz:3333', - user: 'tether.worker3' - } - } - } - } - }, - { - id: 'miner-004', - type: 'antminer-s19', - code: 'M004', - info: { - container: 'container-B', - serialNum: 'SN-004', - macAddress: 'AA:BB:CC:DD:EE:04', - pos: 'B2' - }, - tags: ['t-miner'], - rack: 'rack-2', - comments: [], - opts: { address: '192.168.2.101' }, - ts: Date.now() - 240000, - last: { - ts: Date.now(), - uptime: 43200, - alerts: [], - snap: { - model: 'Antminer S19 XP', - stats: { - status: 'sleep', - hashrate_mhs: 0, - power_w: 10, - efficiency_w_ths: 0, - temperature_c: 25 - }, - config: { - firmware_ver: '2024.01.01', - power_mode: 'sleep', - led_status: 'off', - pool_config: { - url: 'stratum+tcp://btc.f2pool.com:3333', - user: 'tether.worker4' - } - } - } - } - }, - { - id: 'miner-005', - type: 'antminer-s19', - code: 'M005', - info: { - container: 'container-A', - serialNum: 'SN-005', - macAddress: 'AA:BB:CC:DD:EE:05', - pos: 'A3' - }, - tags: ['t-miner'], - rack: 'rack-1', - comments: [], - opts: { address: '192.168.1.102' }, - ts: Date.now() - 300000, - last: { - ts: Date.now(), - uptime: 259200, - alerts: [{ type: 'low_hashrate', severity: 'high' }], - snap: { - model: 'Antminer S19 XP', - stats: { - status: 'online', - hashrate_mhs: 120000, - power_w: 2900, - efficiency_w_ths: 24.2, - temperature_c: 68 - }, - config: { - firmware_ver: '2023.12.15', - power_mode: 'low', - led_status: 'off', - pool_config: { - url: 'stratum+tcp://btc.f2pool.com:3333', - user: 'tether.worker5' - } - } - } - } - } - ] - - const createConfig = () => { - if (!fs.existsSync(`./${baseDir}/config/facs`)) { - if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`) - fs.mkdirSync(`./${baseDir}/config/facs`) - } - if (!fs.existsSync(`./${baseDir}/db`)) fs.mkdirSync(`./${baseDir}/db`) - - const commonConf = { - dir_log: 'logs', - debug: 0, - orks: { 'cluster-1': { rpcPublicKey: '' } }, - cacheTiming: {}, - featureConfig: {} - } - const netConf = { r0: {} } - const httpdConf = { h0: {} } - const httpdOauthConf = { - h0: { - method: 'google', - credentials: { client: { id: 'i', secret: 's' } }, - users: [ - { email: readonlyUser }, - { email: siteOperatorUser, write: true } - ] - } - } - const authConf = require('../../config/facs/auth.config.json') - - fs.writeFileSync(`./${baseDir}/config/common.json`, JSON.stringify(commonConf)) - fs.writeFileSync(`./${baseDir}/config/facs/net.config.json`, JSON.stringify(netConf)) - fs.writeFileSync(`./${baseDir}/config/facs/httpd.config.json`, JSON.stringify(httpdConf)) - fs.writeFileSync(`./${baseDir}/config/facs/httpd-oauth2.config.json`, JSON.stringify(httpdOauthConf)) - fs.writeFileSync(`./${baseDir}/config/facs/auth.config.json`, JSON.stringify(authConf)) - } - - const startWorker = async () => { - worker = createWorker({ - env: 'test', - wtype: 'wrk-node-http-test', - rack: 'test-rack', - tmpdir: baseDir, - storeDir: 'test-store', - serviceRoot: `${process.cwd()}/${baseDir}`, - port: appNodePort - }) - - await worker.start() - worker.worker.net_r0.jRequest = (publicKey, method) => { - if (method === 'listThings') { - return Promise.resolve(mockMiners) - } - if (method === 'getThingsCount') { - return Promise.resolve(mockMiners.length) - } - if (method === 'getWrkExtData') { - return Promise.resolve([]) - } - return Promise.resolve({}) - } - } - - const createHttpClient = async () => { - httpClient = new HttpFacility({}, { ns: 'c0', timeout: 30000, debug: false }, { env: 'test' }) - await httpClient.start() - } - - const getTestToken = async (email) => { - worker.worker.authLib._auth.addHandlers({ - google: () => { return { email } } - }) - const token = await worker.worker.auth_a0.authCallbackHandler('google', { ip }) - return token - } - - const createAuthHeaders = async (userEmail) => { - const token = await getTestToken(userEmail) - return { Authorization: `Bearer ${token}` } - } - - createConfig() - await startWorker() - await createHttpClient() - await sleep(2000) - - const minersApi = `${appNodeBaseUrl}${ENDPOINTS.MINERS}` - - // --- Auth security tests --- - - await main.test('Api: miners - auth security', async (n) => { - await n.test('should fail for missing auth token', async (t) => { - try { - await httpClient.get(minersApi, { encoding }) - t.fail('Expected error for missing auth token') - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('should fail for invalid auth token', async (t) => { - const headers = { Authorization: `Bearer ${invalidToken}` } - try { - await httpClient.get(minersApi, { headers, encoding }) - t.fail('Expected error for invalid auth token') - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('should fail for readonly user (capCheck requires write)', async (t) => { - const headers = await createAuthHeaders(readonlyUser) - try { - await httpClient.get(minersApi, { headers, encoding }) - t.fail('Expected error for readonly user') - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('should succeed for site operator user (has actions:rw)', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - try { - await httpClient.get(minersApi, { headers, encoding }) - t.pass() - } catch (e) { - t.fail(`Expected success but got: ${e.message || e}`) - } - }) - }) - - // --- Response structure tests --- - - await main.test('Api: miners - response structure', async (n) => { - await n.test('should return paginated response with correct top-level fields', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const { body: data } = await httpClient.get(minersApi, { headers, encoding }) - - t.ok(Array.isArray(data.data), 'data should be an array') - t.ok(typeof data.totalCount === 'number', 'totalCount should be a number') - t.ok(typeof data.offset === 'number', 'offset should be a number') - t.ok(typeof data.limit === 'number', 'limit should be a number') - t.ok(typeof data.hasMore === 'boolean', 'hasMore should be a boolean') - }) - - await n.test('should return all mock miners with default pagination', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const { body: data } = await httpClient.get(minersApi, { headers, encoding }) - - t.is(data.totalCount, 5, 'totalCount should be 5') - t.is(data.data.length, 5, 'data should have 5 items') - t.is(data.offset, 0, 'offset should default to 0') - t.is(data.hasMore, false, 'hasMore should be false when all items fit') - }) - - await n.test('each miner should have clean formatted fields', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const { body: data } = await httpClient.get(minersApi, { headers, encoding }) - const miner = data.data[0] - - t.ok(miner.id, 'should have id') - t.ok(miner.type, 'should have type') - t.ok(miner.model, 'should have model') - t.ok(miner.code, 'should have code') - t.ok(miner.ip, 'should have ip') - t.ok(miner.container, 'should have container') - t.ok(miner.rack, 'should have rack') - t.ok(miner.status !== undefined, 'should have status') - t.ok(typeof miner.hashrate === 'number', 'hashrate should be a number') - t.ok(typeof miner.power === 'number', 'power should be a number') - t.ok(typeof miner.efficiency === 'number', 'efficiency should be a number') - t.ok(miner.temperature !== undefined, 'should have temperature') - t.ok(miner.firmware, 'should have firmware') - t.ok(miner.powerMode, 'should have powerMode') - t.ok(miner.ledStatus !== undefined, 'should have ledStatus') - t.ok(miner.poolConfig, 'should have poolConfig') - t.ok(miner.lastSeen, 'should have lastSeen') - }) - - await n.test('formatted miner should have correct values from raw data', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const { body: data } = await httpClient.get(minersApi, { headers, encoding }) - - const miner = data.data.find(m => m.id === 'miner-001') - t.ok(miner, 'should find miner-001') - t.is(miner.id, 'miner-001') - t.is(miner.type, 'antminer-s19') - t.is(miner.model, 'Antminer S19 XP') - t.is(miner.code, 'M001') - t.is(miner.ip, '192.168.1.100') - t.is(miner.container, 'container-A') - t.is(miner.rack, 'rack-1') - t.is(miner.status, 'online') - t.is(miner.hashrate, 140000) - t.is(miner.power, 3010) - t.is(miner.efficiency, 21.5) - t.is(miner.temperature, 65) - t.is(miner.firmware, '2024.01.01') - t.is(miner.powerMode, 'normal') - }) - }) - - // --- Pagination tests --- - - await main.test('Api: miners - pagination', async (n) => { - await n.test('should respect limit parameter', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?limit=2` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.is(data.data.length, 2, 'should return only 2 items') - t.is(data.totalCount, 5, 'totalCount should still be 5') - t.is(data.limit, 2, 'limit should be 2') - t.is(data.hasMore, true, 'hasMore should be true') - }) - - await n.test('should respect offset parameter', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?offset=3&limit=10` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.is(data.data.length, 2, 'should return remaining 2 items') - t.is(data.totalCount, 5, 'totalCount should still be 5') - t.is(data.offset, 3, 'offset should be 3') - t.is(data.hasMore, false, 'hasMore should be false') - }) - - await n.test('should return empty page when offset exceeds total', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?offset=100` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.is(data.data.length, 0, 'should return 0 items') - t.is(data.totalCount, 5, 'totalCount should still be 5') - t.is(data.hasMore, false, 'hasMore should be false') - }) - - await n.test('should cap limit at MAX_LIMIT (200)', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?limit=500` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.ok(data.limit <= 200, 'limit should be capped at 200') - }) - }) - - // --- Filter tests --- - // Note: Filtering is done on the ork side via mingo. The mock RPC returns - // all miners regardless of query. These tests verify the API accepts filter - // params correctly and returns valid responses. Filter logic is covered - // by unit tests in miners.handlers.test.js and queryUtils.test.js. - - await main.test('Api: miners - filtering', async (n) => { - await n.test('should accept filter param and return valid response', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const filter = JSON.stringify({ status: 'online' }) - const api = `${minersApi}?filter=${encodeURIComponent(filter)}` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.ok(Array.isArray(data.data), 'should return data array') - t.ok(typeof data.totalCount === 'number', 'should have totalCount') - }) - - await n.test('should accept $or filter without error', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const filter = JSON.stringify({ $or: [{ status: 'error' }, { status: 'sleep' }] }) - const api = `${minersApi}?filter=${encodeURIComponent(filter)}` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.ok(Array.isArray(data.data), 'should return data array') - }) - - await n.test('should return error for invalid filter JSON', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?filter=not-valid-json` - try { - await httpClient.get(api, { headers, encoding }) - t.fail('Expected error for invalid JSON') - } catch (e) { - t.ok(e.response.message.includes('ERR_FILTER_INVALID_JSON'), 'should return filter JSON error') - } - }) - }) - - // --- Sort tests --- - - await main.test('Api: miners - sorting', async (n) => { - await n.test('should sort by hashrate descending', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const sort = JSON.stringify({ hashrate: -1 }) - const api = `${minersApi}?sort=${encodeURIComponent(sort)}` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.ok(data.data.length > 1, 'should return multiple items') - for (let i = 1; i < data.data.length; i++) { - t.ok( - data.data[i - 1].hashrate >= data.data[i].hashrate, - `hashrate should be descending: ${data.data[i - 1].hashrate} >= ${data.data[i].hashrate}` - ) - } - }) - - await n.test('should sort by hashrate ascending', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const sort = JSON.stringify({ hashrate: 1 }) - const api = `${minersApi}?sort=${encodeURIComponent(sort)}` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.ok(data.data.length > 1, 'should return multiple items') - for (let i = 1; i < data.data.length; i++) { - t.ok( - data.data[i - 1].hashrate <= data.data[i].hashrate, - `hashrate should be ascending: ${data.data[i - 1].hashrate} <= ${data.data[i].hashrate}` - ) - } - }) - - await n.test('should return error for invalid sort JSON', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?sort=not-valid-json` - try { - await httpClient.get(api, { headers, encoding }) - t.fail('Expected error for invalid JSON') - } catch (e) { - t.ok(e.response.message.includes('ERR_SORT_INVALID_JSON'), 'should return sort JSON error') - } - }) - }) - - // --- Search tests --- - // Note: Search query is built into the RPC payload and executed on the ork. - // The mock returns all miners regardless. These tests verify the API - // accepts search params and the query is correctly built (unit-tested). - - await main.test('Api: miners - search', async (n) => { - await n.test('should accept search param and return valid response', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?search=192.168` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.ok(Array.isArray(data.data), 'should return data array') - t.ok(typeof data.totalCount === 'number', 'should have totalCount') - }) - - await n.test('should accept search combined with other params', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const sort = JSON.stringify({ hashrate: -1 }) - const api = `${minersApi}?search=miner&sort=${encodeURIComponent(sort)}&limit=3` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.ok(Array.isArray(data.data), 'should return data array') - t.ok(data.data.length <= 3, 'should respect limit') - }) - }) - - // --- Combined query param tests --- - - await main.test('Api: miners - combined query params', async (n) => { - await n.test('should accept filter, sort, and pagination together', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const filter = JSON.stringify({ status: 'online' }) - const sort = JSON.stringify({ hashrate: -1 }) - const api = `${minersApi}?filter=${encodeURIComponent(filter)}&sort=${encodeURIComponent(sort)}&limit=2&offset=0` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.is(data.limit, 2, 'limit should be 2') - t.ok(data.data.length <= 2, 'should return at most 2 items') - if (data.data.length > 1) { - t.ok(data.data[0].hashrate >= data.data[1].hashrate, 'should be sorted by hashrate desc') - } - }) - - await n.test('should accept all query params together without error', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const filter = JSON.stringify({ status: 'online' }) - const sort = JSON.stringify({ temperature: 1 }) - const fields = JSON.stringify({ status: 1, ip: 1, temperature: 1 }) - const api = `${minersApi}?filter=${encodeURIComponent(filter)}&sort=${encodeURIComponent(sort)}&fields=${encodeURIComponent(fields)}&search=192&limit=3&offset=0` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.ok(Array.isArray(data.data), 'should return data array') - t.ok(data.data.length <= 3, 'should respect limit') - t.is(data.offset, 0, 'offset should be 0') - // Verify projection: only requested fields + id should be present - if (data.data.length > 0) { - const miner = data.data[0] - t.ok(miner.id, 'should always include id') - t.ok(miner.status !== undefined, 'should include requested field: status') - t.ok(miner.ip !== undefined, 'should include requested field: ip') - t.is(miner.hashrate, undefined, 'should exclude non-requested field: hashrate') - t.is(miner.power, undefined, 'should exclude non-requested field: power') - t.is(miner.firmware, undefined, 'should exclude non-requested field: firmware') - } - }) - }) - - // --- overwriteCache tests --- - - await main.test('Api: miners - overwriteCache', async (n) => { - await n.test('should accept overwriteCache=true without error', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?overwriteCache=true` - try { - const { body: data } = await httpClient.get(api, { headers, encoding }) - t.ok(data.data, 'should return data') - t.pass() - } catch (e) { - t.fail(`Expected success but got: ${e.message || e}`) - } - }) - }) - - // --- Pool enrichment tests --- - - await main.test('Api: miners - pool enrichment', async (n) => { - await n.test('should include poolHashrate when poolStats feature is enabled', async (t) => { - worker.worker.conf.featureConfig = { poolStats: true } - - const originalJRequest = worker.worker.net_r0.jRequest - worker.worker.net_r0.jRequest = (publicKey, method) => { - if (method === 'listThings') { - return Promise.resolve(mockMiners) - } - if (method === 'getThingsCount') { - return Promise.resolve(mockMiners.length) - } - if (method === 'getWrkExtData') { - return Promise.resolve([{ - workers: { - 'miner-001': { hashrate: 139500 }, - M002: { hashrate: 134000 } - } - }]) - } - return Promise.resolve({}) - } - - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?overwriteCache=true` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - const miner1 = data.data.find(m => m.id === 'miner-001') - t.ok(miner1, 'should find miner-001') - t.is(miner1.poolHashrate, 139500, 'miner-001 should have poolHashrate from pool data') - - const miner2 = data.data.find(m => m.id === 'miner-002') - t.ok(miner2, 'should find miner-002') - t.is(miner2.poolHashrate, 134000, 'miner-002 should have poolHashrate matched by code') - - worker.worker.conf.featureConfig = {} - worker.worker.net_r0.jRequest = originalJRequest - }) - - await n.test('should not include poolHashrate when poolStats feature is disabled', async (t) => { - worker.worker.conf.featureConfig = {} - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?overwriteCache=true` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - const miner = data.data[0] - t.is(miner.poolHashrate, undefined, 'poolHashrate should not be present') - }) - }) - - // --- Error/edge case handling --- - - await main.test('Api: miners - edge cases', async (n) => { - await n.test('should handle empty RPC response gracefully', async (t) => { - const originalJRequest = worker.worker.net_r0.jRequest - worker.worker.net_r0.jRequest = (publicKey, method) => { - if (method === 'listThings') return Promise.resolve([]) - if (method === 'getThingsCount') return Promise.resolve(0) - return Promise.resolve({}) - } - - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?overwriteCache=true` - const { body: data } = await httpClient.get(api, { headers, encoding }) - - t.is(data.data.length, 0, 'should return empty data array') - t.is(data.totalCount, 0, 'totalCount should be 0') - t.is(data.hasMore, false, 'hasMore should be false') - - worker.worker.net_r0.jRequest = originalJRequest - }) - - await n.test('should return error for invalid fields JSON', async (t) => { - const headers = await createAuthHeaders(siteOperatorUser) - const api = `${minersApi}?fields=not-valid-json` - try { - await httpClient.get(api, { headers, encoding }) - t.fail('Expected error for invalid JSON') - } catch (e) { - t.ok(e.response.message.includes('ERR_FIELDS_INVALID_JSON'), 'should return fields JSON error') - } - }) - }) -}) diff --git a/tests/integration/api.poolManager.test.js b/tests/integration/api.poolManager.test.js deleted file mode 100644 index 2bd5a87..0000000 --- a/tests/integration/api.poolManager.test.js +++ /dev/null @@ -1,398 +0,0 @@ -'use strict' - -const test = require('brittle') -const fs = require('fs') -const { createWorker } = require('tether-svc-test-helper').worker -const { setTimeout: sleep } = require('timers/promises') -const HttpFacility = require('bfx-facs-http') - -test('Pool Manager API', { timeout: 90000 }, async (main) => { - const baseDir = 'tests/integration' - let worker - let httpClient - const appNodePort = 5001 - const ip = '127.0.0.1' - const appNodeBaseUrl = `http://${ip}:${appNodePort}` - const testUser = 'poolmanager@test' - const encoding = 'json' - - main.teardown(async () => { - await httpClient.stop() - await worker.stop() - await sleep(2000) - fs.rmSync(`./${baseDir}/store`, { recursive: true, force: true }) - fs.rmSync(`./${baseDir}/status`, { recursive: true, force: true }) - fs.rmSync(`./${baseDir}/config`, { recursive: true, force: true }) - fs.rmSync(`./${baseDir}/db`, { recursive: true, force: true }) - }) - - const createConfig = () => { - if (!fs.existsSync(`./${baseDir}/config/facs`)) { - if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`) - fs.mkdirSync(`./${baseDir}/config/facs`) - } - if (!fs.existsSync(`./${baseDir}/db`)) fs.mkdirSync(`./${baseDir}/db`) - - const commonConf = { - dir_log: 'logs', - debug: 0, - orks: { 'cluster-1': { region: 'AB', rpcPublicKey: '' } }, - cacheTiming: {}, - featureConfig: {} - } - const netConf = { r0: {} } - const httpdConf = { h0: {} } - const httpdOauthConf = { - h0: { - method: 'google', - credentials: { client: { id: 'i', secret: 's' } }, - users: [{ email: testUser, write: true }] - } - } - const authConf = require('../../config/facs/auth.config.json') - fs.writeFileSync(`./${baseDir}/config/common.json`, JSON.stringify(commonConf)) - fs.writeFileSync(`./${baseDir}/config/facs/net.config.json`, JSON.stringify(netConf)) - fs.writeFileSync(`./${baseDir}/config/facs/httpd.config.json`, JSON.stringify(httpdConf)) - fs.writeFileSync(`./${baseDir}/config/facs/httpd-oauth2.config.json`, JSON.stringify(httpdOauthConf)) - fs.writeFileSync(`./${baseDir}/config/facs/auth.config.json`, JSON.stringify(authConf)) - } - - const mockMiners = [ - { - id: 'miner-001', - info: { model: 'Antminer S19 XP', ip_address: '192.168.1.100' }, - snap: { - ts: Date.now(), - config: { - pool_config: [ - { url: 'stratum+tcp://btc.f2pool.com:3333', username: 'tether.worker1' } - ] - }, - stats: { - status: 'mining', - pool_status: [{ pool: 'btc.f2pool.com:3333', status: 'Alive', accepted: 100, rejected: 1 }], - hashrate_mhs: { t_5m: 140000 } - } - }, - tags: { unit: 'unit-A', rack: 'rack-1' }, - alerts: {} - }, - { - id: 'miner-002', - info: { model: 'Antminer S19 XP', ip_address: '192.168.1.101' }, - snap: { - ts: Date.now(), - config: { - pool_config: [ - { url: 'stratum+tcp://btc.f2pool.com:3333', username: 'tether.worker2' } - ] - }, - stats: { - status: 'mining', - pool_status: [{ pool: 'btc.f2pool.com:3333', status: 'Alive', accepted: 150, rejected: 2 }], - hashrate_mhs: { t_5m: 145000 } - } - }, - tags: { unit: 'unit-A', rack: 'rack-1' }, - alerts: {} - }, - { - id: 'miner-003', - info: { model: 'Whatsminer M50S', ip_address: '192.168.1.102' }, - snap: { - ts: Date.now(), - config: { - pool_config: [ - { url: 'stratum+tcp://ocean.xyz:3333', username: 'tether.worker3' } - ] - }, - stats: { - status: 'mining', - pool_status: [{ pool: 'ocean.xyz:3333', status: 'Alive', accepted: 200, rejected: 3 }], - hashrate_mhs: { t_5m: 130000 } - } - }, - tags: { unit: 'unit-B', rack: 'rack-2' }, - alerts: { wrong_miner_pool: { ts: Date.now() } } - } - ] - - const startWorker = async () => { - worker = createWorker({ - env: 'test', - wtype: 'wrk-node-dashboard-test', - rack: 'test-rack', - tmpdir: baseDir, - storeDir: 'test-store', - serviceRoot: `${process.cwd()}/${baseDir}`, - port: appNodePort - }) - - await worker.start() - worker.worker.net_r0.jRequest = (publicKey, method, params) => { - if (method === 'listThings') { - if (params?.query?.id) { - const thing = mockMiners.find(m => m.id === params.query.id) - if (!thing) return Promise.resolve([]) - const withRackAndInfo = { - ...thing, - rack: thing.id.startsWith('miner-') ? 'miner-am-s19xp' : 'container-unit-a', - info: { ...thing.info, container: thing.tags?.unit || 'unit-A', poolConfig: thing.snap?.config?.pool_config ? 'stratum+tcp://btc.f2pool.com:3333' : null } - } - return Promise.resolve([withRackAndInfo]) - } - return Promise.resolve(mockMiners) - } - if (method === 'getWrkExtData') { - return Promise.resolve([{ - stats: [ - { - poolType: 'f2pool', - username: 'tether.worker1', - hashrate: 285000, - hashrate_1h: 280000, - hashrate_24h: 275000, - worker_count: 3, - active_workers_count: 2, - balance: 0.005, - unsettled: 0.001, - revenue_24h: 0.0002, - yearlyBalances: [], - timestamp: Date.now() - } - ] - }]) - } - return Promise.resolve([]) - } - } - - const createHttpClient = async () => { - httpClient = new HttpFacility({}, { ns: 'c0', timeout: 30000, debug: false }, { env: 'test' }) - await httpClient.start() - } - - const getTestToken = async (email) => { - worker.worker.authLib._auth.addHandlers({ - google: () => { return { email } } - }) - const token = await worker.worker.auth_a0.authCallbackHandler('google', { ip }) - return token - } - - createConfig() - await startWorker() - await createHttpClient() - await sleep(2000) - - const baseParams = 'regions=["AB"]' - - await main.test('Api: auth/pool-manager/stats', async (n) => { - const api = `${appNodeBaseUrl}/auth/pool-manager/stats?${baseParams}` - - await n.test('api should fail for missing auth token', async (t) => { - try { - await httpClient.get(api, { encoding }) - t.fail() - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('api should succeed and return stats', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - try { - const res = await httpClient.get(api, { headers, encoding }) - t.ok(res.body) - t.ok(typeof res.body.totalPools === 'number') - t.ok(typeof res.body.totalWorkers === 'number') - t.ok(typeof res.body.errors === 'number') - t.pass() - } catch (e) { - console.error('Stats error:', e) - t.fail() - } - }) - }) - - await main.test('Api: auth/pool-manager/pools', async (n) => { - const api = `${appNodeBaseUrl}/auth/pool-manager/pools?${baseParams}` - - await n.test('api should fail for missing auth token', async (t) => { - try { - await httpClient.get(api, { encoding }) - t.fail() - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('api should succeed and return pools list', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - try { - const res = await httpClient.get(api, { headers, encoding }) - t.ok(res.body) - t.ok(Array.isArray(res.body.pools)) - t.ok(typeof res.body.total === 'number') - if (res.body.pools.length > 0) { - t.ok(res.body.pools[0].pool) - t.ok(res.body.pools[0].name) - } - t.pass() - } catch (e) { - console.error('Pools error:', e) - t.fail() - } - }) - }) - - await main.test('Api: auth/pool-manager/miners', async (n) => { - const api = `${appNodeBaseUrl}/auth/pool-manager/miners?${baseParams}` - - await n.test('api should fail for missing auth token', async (t) => { - try { - await httpClient.get(api, { encoding }) - t.fail() - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('api should succeed and return paginated miners', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - try { - const res = await httpClient.get(api, { headers, encoding }) - t.ok(res.body) - t.ok(Array.isArray(res.body.miners)) - t.ok(typeof res.body.total === 'number') - t.ok(typeof res.body.page === 'number') - t.ok(typeof res.body.limit === 'number') - t.pass() - } catch (e) { - console.error('Miners error:', e) - t.fail() - } - }) - - await n.test('api should support pagination params', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - const paginatedApi = `${api}&page=1&limit=10` - try { - const res = await httpClient.get(paginatedApi, { headers, encoding }) - t.is(res.body.page, 1) - t.is(res.body.limit, 10) - t.pass() - } catch (e) { - console.error('Pagination error:', e) - t.fail() - } - }) - }) - - await main.test('Api: auth/pool-manager/units', async (n) => { - const api = `${appNodeBaseUrl}/auth/pool-manager/units?${baseParams}` - - await n.test('api should fail for missing auth token', async (t) => { - try { - await httpClient.get(api, { encoding }) - t.fail() - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('api should succeed and return units list', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - try { - const res = await httpClient.get(api, { headers, encoding }) - t.ok(res.body) - t.ok(Array.isArray(res.body.units)) - t.ok(typeof res.body.total === 'number') - t.pass() - } catch (e) { - console.error('Units error:', e) - t.fail() - } - }) - }) - - await main.test('Api: auth/pool-manager/alerts', async (n) => { - const api = `${appNodeBaseUrl}/auth/pool-manager/alerts?${baseParams}` - - await n.test('api should fail for missing auth token', async (t) => { - try { - await httpClient.get(api, { encoding }) - t.fail() - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('api should succeed and return alerts list', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - try { - const res = await httpClient.get(api, { headers, encoding }) - t.ok(res.body) - t.ok(Array.isArray(res.body.alerts)) - t.ok(typeof res.body.total === 'number') - if (res.body.alerts.length > 0) { - t.ok(res.body.alerts[0].type) - t.ok(res.body.alerts[0].minerId) - t.ok(res.body.alerts[0].severity) - } - t.pass() - } catch (e) { - console.error('Alerts error:', e) - t.fail() - } - }) - }) - - await main.test('Api: auth/pools/config/:id', async (n) => { - const thingId = 'miner-001' - const api = `${appNodeBaseUrl}/auth/pools/config/${thingId}` - - await n.test('api should fail for missing auth token', async (t) => { - try { - await httpClient.get(api, { encoding }) - t.fail() - } catch (e) { - t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) - } - }) - - await n.test('api should succeed and return pool config for thing', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - try { - const res = await httpClient.get(api, { headers, encoding }) - t.ok(res.body) - t.ok('poolConfig' in res.body) - t.ok(typeof res.body.overriddenConfig === 'number') - t.pass() - } catch (e) { - console.error('Pool thing config error:', e) - t.fail() - } - }) - - await n.test('api should return 404 for unknown thing id', async (t) => { - const token = await getTestToken(testUser) - const headers = { Authorization: `Bearer ${token}` } - const unknownApi = `${appNodeBaseUrl}/auth/pools/config/nonexistent-thing-id` - try { - await httpClient.get(unknownApi, { headers, encoding }) - t.fail() - } catch (e) { - t.ok(e.response?.message?.includes('ERR_THING_NOT_FOUND') || e.code === 'ERR_HTTP_REQUEST_FAILED' || e.statusCode === 404) - t.pass() - } - }) - }) -}) diff --git a/tests/integration/api.security.test.js b/tests/integration/api.test.js similarity index 59% rename from tests/integration/api.security.test.js rename to tests/integration/api.test.js index cca11c7..c78f44a 100644 --- a/tests/integration/api.security.test.js +++ b/tests/integration/api.test.js @@ -6,8 +6,9 @@ const { createWorker } = require('tether-svc-test-helper').worker const { setTimeout: sleep } = require('timers/promises') const HttpFacility = require('bfx-facs-http') const { ENDPOINTS } = require('../../workers/lib/constants') +const { MOCK_MINERS: mockMiners } = require('./helpers/mock-data') -test('Api security', { timeout: 90000 }, async (main) => { +test('Api', { timeout: 90000 }, async (main) => { const baseDir = 'tests/integration' let worker let httpClient @@ -69,7 +70,18 @@ test('Api security', { timeout: 90000 }, async (main) => { }) await worker.start() - worker.worker.net_r0.jRequest = () => ({}) + worker.worker.net_r0.jRequest = (publicKey, method) => { + if (method === 'listThings') { + return Promise.resolve(mockMiners) + } + if (method === 'getThingsCount') { + return Promise.resolve(mockMiners.length) + } + if (method === 'getWrkExtData') { + return Promise.resolve([]) + } + return Promise.resolve({}) + } } const createHttpClient = async () => { @@ -276,6 +288,390 @@ test('Api security', { timeout: 90000 }, async (main) => { await createUser(admin1, 'admin') await createUser(admin2, 'admin') + const minersApi = `${appNodeBaseUrl}${ENDPOINTS.MINERS}` + + await main.test('Api: miners - auth security', async (n) => { + await n.test('should fail for missing auth token', async (t) => { + try { + await httpClient.get(minersApi, { encoding }) + t.fail('Expected error for missing auth token') + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('should fail for invalid auth token', async (t) => { + const headers = { Authorization: `Bearer ${invalidToken}` } + try { + await httpClient.get(minersApi, { headers, encoding }) + t.fail('Expected error for invalid auth token') + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('should fail for readonly user (capCheck requires write)', async (t) => { + const headers = await createAuthHeaders(readonlyUser) + try { + await httpClient.get(minersApi, { headers, encoding }) + t.fail('Expected error for readonly user') + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) + + await n.test('should succeed for site operator user (has actions:rw)', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + try { + await httpClient.get(minersApi, { headers, encoding }) + t.pass() + } catch (e) { + t.fail(`Expected success but got: ${e.message || e}`) + } + }) + }) + + await main.test('Api: miners - response structure', async (n) => { + await n.test('should return paginated response with correct top-level fields', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const { body: data } = await httpClient.get(minersApi, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'data should be an array') + t.ok(typeof data.totalCount === 'number', 'totalCount should be a number') + t.ok(typeof data.offset === 'number', 'offset should be a number') + t.ok(typeof data.limit === 'number', 'limit should be a number') + t.ok(typeof data.hasMore === 'boolean', 'hasMore should be a boolean') + }) + + await n.test('should return all mock miners with default pagination', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const { body: data } = await httpClient.get(minersApi, { headers, encoding }) + + t.is(data.totalCount, 5, 'totalCount should be 5') + t.is(data.data.length, 5, 'data should have 5 items') + t.is(data.offset, 0, 'offset should default to 0') + t.is(data.hasMore, false, 'hasMore should be false when all items fit') + }) + + await n.test('each miner should have clean formatted fields', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const { body: data } = await httpClient.get(minersApi, { headers, encoding }) + const miner = data.data[0] + + t.ok(miner.id, 'should have id') + t.ok(miner.type, 'should have type') + t.ok(miner.model, 'should have model') + t.ok(miner.code, 'should have code') + t.ok(miner.ip, 'should have ip') + t.ok(miner.container, 'should have container') + t.ok(miner.rack, 'should have rack') + t.ok(miner.status !== undefined, 'should have status') + t.ok(typeof miner.hashrate === 'number', 'hashrate should be a number') + t.ok(typeof miner.power === 'number', 'power should be a number') + t.ok(typeof miner.efficiency === 'number', 'efficiency should be a number') + t.ok(miner.temperature !== undefined, 'should have temperature') + t.ok(miner.firmware, 'should have firmware') + t.ok(miner.powerMode, 'should have powerMode') + t.ok(miner.ledStatus !== undefined, 'should have ledStatus') + t.ok(miner.poolConfig, 'should have poolConfig') + t.ok(miner.lastSeen, 'should have lastSeen') + }) + + await n.test('formatted miner should have correct values from raw data', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const { body: data } = await httpClient.get(minersApi, { headers, encoding }) + + const miner = data.data.find(m => m.id === 'miner-001') + t.ok(miner, 'should find miner-001') + t.is(miner.id, 'miner-001') + t.is(miner.type, 'antminer-s19') + t.is(miner.model, 'Antminer S19 XP') + t.is(miner.code, 'M001') + t.is(miner.ip, '192.168.1.100') + t.is(miner.container, 'container-A') + t.is(miner.rack, 'rack-1') + t.is(miner.status, 'online') + t.is(miner.hashrate, 140000) + t.is(miner.power, 3010) + t.is(miner.efficiency, 21.5) + t.is(miner.temperature, 65) + t.is(miner.firmware, '2024.01.01') + t.is(miner.powerMode, 'normal') + }) + }) + + await main.test('Api: miners - pagination', async (n) => { + await n.test('should respect limit parameter', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?limit=2` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.data.length, 2, 'should return only 2 items') + t.is(data.totalCount, 5, 'totalCount should still be 5') + t.is(data.limit, 2, 'limit should be 2') + t.is(data.hasMore, true, 'hasMore should be true') + }) + + await n.test('should respect offset parameter', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?offset=3&limit=10` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.data.length, 2, 'should return remaining 2 items') + t.is(data.totalCount, 5, 'totalCount should still be 5') + t.is(data.offset, 3, 'offset should be 3') + t.is(data.hasMore, false, 'hasMore should be false') + }) + + await n.test('should return empty page when offset exceeds total', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?offset=100` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.data.length, 0, 'should return 0 items') + t.is(data.totalCount, 5, 'totalCount should still be 5') + t.is(data.hasMore, false, 'hasMore should be false') + }) + + await n.test('should cap limit at MAX_LIMIT (200)', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?limit=500` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(data.limit <= 200, 'limit should be capped at 200') + }) + }) + + await main.test('Api: miners - filtering', async (n) => { + await n.test('should accept filter param and return valid response', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const filter = JSON.stringify({ status: 'online' }) + const api = `${minersApi}?filter=${encodeURIComponent(filter)}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + t.ok(typeof data.totalCount === 'number', 'should have totalCount') + }) + + await n.test('should accept $or filter without error', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const filter = JSON.stringify({ $or: [{ status: 'error' }, { status: 'sleep' }] }) + const api = `${minersApi}?filter=${encodeURIComponent(filter)}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + }) + + await n.test('should return error for invalid filter JSON', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?filter=not-valid-json` + try { + await httpClient.get(api, { headers, encoding }) + t.fail('Expected error for invalid JSON') + } catch (e) { + t.ok(e.response.message.includes('ERR_FILTER_INVALID_JSON'), 'should return filter JSON error') + } + }) + }) + + await main.test('Api: miners - sorting', async (n) => { + await n.test('should sort by hashrate descending', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const sort = JSON.stringify({ hashrate: -1 }) + const api = `${minersApi}?sort=${encodeURIComponent(sort)}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(data.data.length > 1, 'should return multiple items') + for (let i = 1; i < data.data.length; i++) { + t.ok( + data.data[i - 1].hashrate >= data.data[i].hashrate, + `hashrate should be descending: ${data.data[i - 1].hashrate} >= ${data.data[i].hashrate}` + ) + } + }) + + await n.test('should sort by hashrate ascending', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const sort = JSON.stringify({ hashrate: 1 }) + const api = `${minersApi}?sort=${encodeURIComponent(sort)}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(data.data.length > 1, 'should return multiple items') + for (let i = 1; i < data.data.length; i++) { + t.ok( + data.data[i - 1].hashrate <= data.data[i].hashrate, + `hashrate should be ascending: ${data.data[i - 1].hashrate} <= ${data.data[i].hashrate}` + ) + } + }) + + await n.test('should return error for invalid sort JSON', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?sort=not-valid-json` + try { + await httpClient.get(api, { headers, encoding }) + t.fail('Expected error for invalid JSON') + } catch (e) { + t.ok(e.response.message.includes('ERR_SORT_INVALID_JSON'), 'should return sort JSON error') + } + }) + }) + + await main.test('Api: miners - search', async (n) => { + await n.test('should accept search param and return valid response', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?search=192.168` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + t.ok(typeof data.totalCount === 'number', 'should have totalCount') + }) + + await n.test('should accept search combined with other params', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const sort = JSON.stringify({ hashrate: -1 }) + const api = `${minersApi}?search=miner&sort=${encodeURIComponent(sort)}&limit=3` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + t.ok(data.data.length <= 3, 'should respect limit') + }) + }) + + await main.test('Api: miners - combined query params', async (n) => { + await n.test('should accept filter, sort, and pagination together', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const filter = JSON.stringify({ status: 'online' }) + const sort = JSON.stringify({ hashrate: -1 }) + const api = `${minersApi}?filter=${encodeURIComponent(filter)}&sort=${encodeURIComponent(sort)}&limit=2&offset=0` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.limit, 2, 'limit should be 2') + t.ok(data.data.length <= 2, 'should return at most 2 items') + if (data.data.length > 1) { + t.ok(data.data[0].hashrate >= data.data[1].hashrate, 'should be sorted by hashrate desc') + } + }) + + await n.test('should accept all query params together without error', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const filter = JSON.stringify({ status: 'online' }) + const sort = JSON.stringify({ temperature: 1 }) + const fields = JSON.stringify({ status: 1, ip: 1, temperature: 1 }) + const api = `${minersApi}?filter=${encodeURIComponent(filter)}&sort=${encodeURIComponent(sort)}&fields=${encodeURIComponent(fields)}&search=192&limit=3&offset=0` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.ok(Array.isArray(data.data), 'should return data array') + t.ok(data.data.length <= 3, 'should respect limit') + t.is(data.offset, 0, 'offset should be 0') + if (data.data.length > 0) { + const miner = data.data[0] + t.ok(miner.id, 'should always include id') + t.ok(miner.status !== undefined, 'should include requested field: status') + t.ok(miner.ip !== undefined, 'should include requested field: ip') + t.is(miner.hashrate, undefined, 'should exclude non-requested field: hashrate') + t.is(miner.power, undefined, 'should exclude non-requested field: power') + t.is(miner.firmware, undefined, 'should exclude non-requested field: firmware') + } + }) + }) + + await main.test('Api: miners - overwriteCache', async (n) => { + await n.test('should accept overwriteCache=true without error', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?overwriteCache=true` + try { + const { body: data } = await httpClient.get(api, { headers, encoding }) + t.ok(data.data, 'should return data') + t.pass() + } catch (e) { + t.fail(`Expected success but got: ${e.message || e}`) + } + }) + }) + + await main.test('Api: miners - pool enrichment', async (n) => { + await n.test('should include poolHashrate when poolStats feature is enabled', async (t) => { + worker.worker.conf.featureConfig = { poolStats: true } + + const originalJRequest = worker.worker.net_r0.jRequest + worker.worker.net_r0.jRequest = (publicKey, method) => { + if (method === 'listThings') { + return Promise.resolve(mockMiners) + } + if (method === 'getThingsCount') { + return Promise.resolve(mockMiners.length) + } + if (method === 'getWrkExtData') { + return Promise.resolve([{ + workers: { + 'miner-001': { hashrate: 139500 }, + M002: { hashrate: 134000 } + } + }]) + } + return Promise.resolve({}) + } + + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?overwriteCache=true` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + const miner1 = data.data.find(m => m.id === 'miner-001') + t.ok(miner1, 'should find miner-001') + t.is(miner1.poolHashrate, 139500, 'miner-001 should have poolHashrate from pool data') + + const miner2 = data.data.find(m => m.id === 'miner-002') + t.ok(miner2, 'should find miner-002') + t.is(miner2.poolHashrate, 134000, 'miner-002 should have poolHashrate matched by code') + + worker.worker.conf.featureConfig = {} + worker.worker.net_r0.jRequest = originalJRequest + }) + + await n.test('should not include poolHashrate when poolStats feature is disabled', async (t) => { + worker.worker.conf.featureConfig = {} + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?overwriteCache=true` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + const miner = data.data[0] + t.is(miner.poolHashrate, undefined, 'poolHashrate should not be present') + }) + }) + + await main.test('Api: miners - edge cases', async (n) => { + await n.test('should handle empty RPC response gracefully', async (t) => { + const originalJRequest = worker.worker.net_r0.jRequest + worker.worker.net_r0.jRequest = (publicKey, method) => { + if (method === 'listThings') return Promise.resolve([]) + if (method === 'getThingsCount') return Promise.resolve(0) + return Promise.resolve({}) + } + + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?overwriteCache=true` + const { body: data } = await httpClient.get(api, { headers, encoding }) + + t.is(data.data.length, 0, 'should return empty data array') + t.is(data.totalCount, 0, 'totalCount should be 0') + t.is(data.hasMore, false, 'hasMore should be false') + + worker.worker.net_r0.jRequest = originalJRequest + }) + + await n.test('should return error for invalid fields JSON', async (t) => { + const headers = await createAuthHeaders(siteOperatorUser) + const api = `${minersApi}?fields=not-valid-json` + try { + await httpClient.get(api, { headers, encoding }) + t.fail('Expected error for invalid JSON') + } catch (e) { + t.ok(e.response.message.includes('ERR_FIELDS_INVALID_JSON'), 'should return fields JSON error') + } + }) + }) + await main.test('Api: list-things', async (n) => { const api = `${appNodeBaseUrl}${ENDPOINTS.LIST_THINGS}` await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) @@ -286,6 +682,65 @@ test('Api security', { timeout: 90000 }, async (main) => { await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) }) + await main.test('Api: get pools stats/containers', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.POOLS_CONTAINERS_STATS}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get pools stats/containers - response structure', async (n) => { + await n.test('returns array of container stats with container and overriddenConfig', async (t) => { + const headers = await createAuthHeaders(readonlyUser) + const api = `${appNodeBaseUrl}${ENDPOINTS.POOLS_CONTAINERS_STATS}` + const { body: data } = await httpClient.get(api, { headers, encoding }) + t.ok(Array.isArray(data), 'response should be array') + data.forEach((item, i) => { + t.ok(item.container !== undefined, `item ${i} should have container`) + t.ok(item.overriddenConfig !== undefined, `item ${i} should have overriddenConfig`) + t.ok(Number.isInteger(item.overriddenConfig), `item ${i} overriddenConfig should be integer`) + }) + t.pass() + }) + }) + + await main.test('Api: get pools config by id', async (n) => { + await testGetEndpointSecurity(n, httpClient, `${appNodeBaseUrl}${ENDPOINTS.POOLS_THING_CONFIG.replace(':id', 'miner-001')}`, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get pools config by id - response and not found', async (n) => { + const poolsConfigApi = (id) => `${appNodeBaseUrl}${ENDPOINTS.POOLS_THING_CONFIG.replace(':id', id)}` + + await n.test('returns 200 with poolConfig and overriddenConfig when thing exists', async (t) => { + const headers = await createAuthHeaders(readonlyUser) + const api = poolsConfigApi('miner-001') + const { body: data } = await httpClient.get(api, { headers, encoding }) + t.ok(data.poolConfig !== undefined, 'response should have poolConfig') + t.ok(data.overriddenConfig !== undefined, 'response should have overriddenConfig') + t.ok(Number.isInteger(data.overriddenConfig), 'overriddenConfig should be integer') + t.pass() + }) + + await n.test('returns error when thing not found', async (t) => { + const originalJRequest = worker.worker.net_r0.jRequest + worker.worker.net_r0.jRequest = (publicKey, method, params) => { + if (method === 'listThings' && params?.query?.id === 'nonexistent-thing-id') { + return Promise.resolve([]) + } + return originalJRequest(publicKey, method, params) + } + + const headers = await createAuthHeaders(readonlyUser) + const api = poolsConfigApi('nonexistent-thing-id') + try { + await httpClient.get(api, { headers, encoding }) + t.fail('Expected error for non-existent thing') + } catch (e) { + t.ok(e.response?.message?.includes('ERR_THING_NOT_FOUND'), 'should return ERR_THING_NOT_FOUND') + } + worker.worker.net_r0.jRequest = originalJRequest + t.pass() + }) + }) + await main.test('Api: post thing-comment', async (n) => { const api = `${appNodeBaseUrl}${ENDPOINTS.THING_COMMENT}` const body = { thingId: 1, rackId: 1, comment: 'test' } diff --git a/tests/integration/helpers/mock-data.js b/tests/integration/helpers/mock-data.js new file mode 100644 index 0000000..5cebfa1 --- /dev/null +++ b/tests/integration/helpers/mock-data.js @@ -0,0 +1,204 @@ +'use strict' + +const MOCK_MINERS = [{ + id: 'miner-001', + type: 'antminer-s19', + code: 'M001', + info: { + container: 'container-A', + serialNum: 'SN-001', + macAddress: 'AA:BB:CC:DD:EE:01', + pos: 'A1' + }, + tags: ['t-miner'], + rack: 'rack-1', + comments: [], + opts: { address: '192.168.1.100' }, + ts: Date.now() - 60000, + last: { + ts: Date.now(), + uptime: 86400, + alerts: [], + snap: { + model: 'Antminer S19 XP', + stats: { + status: 'online', + hashrate_mhs: 140000, + power_w: 3010, + efficiency_w_ths: 21.5, + temperature_c: 65 + }, + config: { + firmware_ver: '2024.01.01', + power_mode: 'normal', + led_status: 'off', + pool_config: { + url: 'stratum+tcp://btc.f2pool.com:3333', + user: 'tether.worker1' + } + } + } + } +}, +{ + id: 'miner-002', + type: 'antminer-s19', + code: 'M002', + info: { + container: 'container-A', + serialNum: 'SN-002', + macAddress: 'AA:BB:CC:DD:EE:02', + pos: 'A2' + }, + tags: ['t-miner'], + rack: 'rack-1', + comments: [{ text: 'needs maintenance' }], + opts: { address: '192.168.1.101' }, + ts: Date.now() - 120000, + last: { + ts: Date.now(), + uptime: 172800, + alerts: [{ type: 'high_temp', severity: 'medium' }], + snap: { + model: 'Antminer S19 XP', + stats: { + status: 'online', + hashrate_mhs: 135000, + power_w: 2980, + efficiency_w_ths: 22.1, + temperature_c: 72 + }, + config: { + firmware_ver: '2024.01.01', + power_mode: 'normal', + led_status: 'off', + pool_config: { + url: 'stratum+tcp://btc.f2pool.com:3333', + user: 'tether.worker2' + } + } + } + } +}, +{ + id: 'miner-003', + type: 'whatsminer-m50s', + code: 'M003', + info: { + container: 'container-B', + serialNum: 'SN-003', + macAddress: 'AA:BB:CC:DD:EE:03', + pos: 'B1' + }, + tags: ['t-miner'], + rack: 'rack-2', + comments: [], + opts: { address: '192.168.2.100' }, + ts: Date.now() - 180000, + last: { + ts: Date.now() - 300000, + uptime: 3600, + alerts: [], + snap: { + model: 'Whatsminer M50S', + stats: { + status: 'error', + hashrate_mhs: 0, + power_w: 50, + efficiency_w_ths: 0, + temperature_c: 30 + }, + config: { + firmware_ver: '2023.12.15', + power_mode: 'normal', + led_status: 'on', + pool_config: { + url: 'stratum+tcp://ocean.xyz:3333', + user: 'tether.worker3' + } + } + } + } +}, +{ + id: 'miner-004', + type: 'antminer-s19', + code: 'M004', + info: { + container: 'container-B', + serialNum: 'SN-004', + macAddress: 'AA:BB:CC:DD:EE:04', + pos: 'B2' + }, + tags: ['t-miner'], + rack: 'rack-2', + comments: [], + opts: { address: '192.168.2.101' }, + ts: Date.now() - 240000, + last: { + ts: Date.now(), + uptime: 43200, + alerts: [], + snap: { + model: 'Antminer S19 XP', + stats: { + status: 'sleep', + hashrate_mhs: 0, + power_w: 10, + efficiency_w_ths: 0, + temperature_c: 25 + }, + config: { + firmware_ver: '2024.01.01', + power_mode: 'sleep', + led_status: 'off', + pool_config: { + url: 'stratum+tcp://btc.f2pool.com:3333', + user: 'tether.worker4' + } + } + } + } +}, +{ + id: 'miner-005', + type: 'antminer-s19', + code: 'M005', + info: { + container: 'container-A', + serialNum: 'SN-005', + macAddress: 'AA:BB:CC:DD:EE:05', + pos: 'A3' + }, + tags: ['t-miner'], + rack: 'rack-1', + comments: [], + opts: { address: '192.168.1.102' }, + ts: Date.now() - 300000, + last: { + ts: Date.now(), + uptime: 259200, + alerts: [{ type: 'low_hashrate', severity: 'high' }], + snap: { + model: 'Antminer S19 XP', + stats: { + status: 'online', + hashrate_mhs: 120000, + power_w: 2900, + efficiency_w_ths: 24.2, + temperature_c: 68 + }, + config: { + firmware_ver: '2023.12.15', + power_mode: 'low', + led_status: 'off', + pool_config: { + url: 'stratum+tcp://btc.f2pool.com:3333', + user: 'tether.worker5' + } + } + } + } +} +] +module.exports = { MOCK_MINERS } diff --git a/tests/unit/handlers/pools.handlers.test.js b/tests/unit/handlers/pools.handlers.test.js index ef3e9cb..bdcf819 100644 --- a/tests/unit/handlers/pools.handlers.test.js +++ b/tests/unit/handlers/pools.handlers.test.js @@ -9,10 +9,8 @@ const { getPoolBalanceHistory, flattenTransactionResults, groupByBucket, - getPoolStatsAggregate, - processTransactionData, - calculateAggregateSummary, - getPoolThingConfig + getPoolThingConfig, + getPoolStatsContainers } = require('../../../workers/lib/server/handlers/pools.handlers') const { withDataProxy } = require('../helpers/mockHelpers') @@ -329,158 +327,6 @@ test('groupByBucket - handles missing timestamps', (t) => { t.pass() }) -test('getPoolStatsAggregate - happy path', async (t) => { - const mockCtx = withDataProxy({ - conf: { - orks: [{ rpcPublicKey: 'key1' }] - }, - net_r0: { - jRequest: async () => { - return [{ - ts: '1700006400000', - transactions: [{ - username: 'user1', - changed_balance: 0.001, - mining_extra: { hash_rate: 611000000000000 } - }] - }] - } - } - }) - - const mockReq = { - query: { start: 1700000000000, end: 1700100000000, range: 'daily' } - } - - const result = await getPoolStatsAggregate(mockCtx, mockReq, {}) - t.ok(result.log, 'should return log array') - t.ok(result.summary, 'should return summary') - t.ok(Array.isArray(result.log), 'log should be array') - t.ok(result.log.length > 0, 'should have entries') - t.ok(result.log[0].revenueBTC > 0, 'should have revenue') - t.pass() -}) - -test('getPoolStatsAggregate - with pool filter', async (t) => { - let capturedPayload = null - const mockCtx = withDataProxy({ - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async (key, method, payload) => { - capturedPayload = payload - return [{ - ts: '1700006400000', - transactions: [ - { username: 'user1', changed_balance: 0.001 }, - { username: 'user2', changed_balance: 0.002 } - ] - }] - } - } - }) - - const mockReq = { - query: { start: 1700000000000, end: 1700100000000, pool: 'user1' } - } - - const result = await getPoolStatsAggregate(mockCtx, mockReq, {}) - t.ok(result.log, 'should return log') - t.is(capturedPayload.query.pool, 'user1', 'should pass pool filter in RPC payload') - t.pass() -}) - -test('getPoolStatsAggregate - missing start throws', async (t) => { - const mockCtx = { - conf: { orks: [] }, - net_r0: { jRequest: async () => ({}) } - } - - try { - await getPoolStatsAggregate(mockCtx, { query: { end: 1700100000000 } }, {}) - t.fail('should have thrown') - } catch (err) { - t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error') - } - t.pass() -}) - -test('getPoolStatsAggregate - invalid range throws', async (t) => { - const mockCtx = { - conf: { orks: [] }, - net_r0: { jRequest: async () => ({}) } - } - - try { - await getPoolStatsAggregate(mockCtx, { query: { start: 1700100000000, end: 1700000000000 } }, {}) - t.fail('should have thrown') - } catch (err) { - t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error') - } - t.pass() -}) - -test('getPoolStatsAggregate - empty ork results', async (t) => { - const mockCtx = withDataProxy({ - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { jRequest: async () => ({}) } - }) - - const result = await getPoolStatsAggregate(mockCtx, { query: { start: 1700000000000, end: 1700100000000 } }, {}) - t.ok(result.log, 'should return log array') - t.is(result.log.length, 0, 'log should be empty') - t.pass() -}) - -test('processTransactionData - processes valid transactions', (t) => { - const results = [ - [{ - ts: '1700006400000', - transactions: [ - { username: 'user1', changed_balance: 0.001, mining_extra: { hash_rate: 500000 } }, - { username: 'user2', changed_balance: 0.002, mining_extra: { hash_rate: 600000 } } - ] - }] - ] - const daily = processTransactionData(results) - t.ok(typeof daily === 'object', 'should return object') - const keys = Object.keys(daily) - t.ok(keys.length > 0, 'should have entries') - const entry = daily[keys[0]] - t.ok(entry.revenueBTC > 0, 'should have revenue') - t.ok(entry.hashrate > 0, 'should have hashrate') - t.pass() -}) - -test('processTransactionData - handles error results', (t) => { - const results = [{ error: 'timeout' }] - const daily = processTransactionData(results) - t.is(Object.keys(daily).length, 0, 'should be empty') - t.pass() -}) - -test('calculateAggregateSummary - calculates from log', (t) => { - const log = [ - { revenueBTC: 0.5, hashrate: 100, workerCount: 5, balance: 50000 }, - { revenueBTC: 0.3, hashrate: 200, workerCount: 10, balance: 60000 } - ] - - const summary = calculateAggregateSummary(log) - t.is(summary.totalRevenueBTC, 0.8, 'should sum revenue') - t.is(summary.avgHashrate, 150, 'should avg hashrate') - t.is(summary.avgWorkerCount, 7.5, 'should avg workers') - t.is(summary.latestBalance, 60000, 'should take latest balance') - t.is(summary.periodCount, 2, 'should count periods') - t.pass() -}) - -test('calculateAggregateSummary - handles empty log', (t) => { - const summary = calculateAggregateSummary([]) - t.is(summary.totalRevenueBTC, 0, 'should be zero') - t.is(summary.avgHashrate, 0, 'should be zero') - t.is(summary.periodCount, 0, 'should be zero') - t.pass() -}) - test('getPoolThingConfig - thing not found when no rack or info', async (t) => { const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, @@ -598,3 +444,108 @@ test('getPoolThingConfig - container with no poolConfig returns null and zero ov t.is(result.overriddenConfig, 0, 'should be 0 when no miners match') t.pass() }) + +// --- getPoolStatsContainers --- + +test('getPoolStatsContainers - returns container stats with overriddenConfig', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, params) => { + if (method !== 'listThings') return [] + if (params?.query?.tags?.$in?.includes('t-container')) { + return [ + { info: { container: 'unit-a', poolConfig: 'shared-cfg' } }, + { info: { container: 'unit-b', poolConfig: 'other-cfg' } } + ] + } + if (params?.query?.['info.container']?.$in) { + return [ + { info: { container: 'unit-a', poolConfig: 'shared-cfg' } }, + { info: { container: 'unit-a', poolConfig: 'other-cfg' } }, + { info: { container: 'unit-b', poolConfig: 'other-cfg' } } + ] + } + return [] + } + } + }) + + const result = await getPoolStatsContainers(mockCtx, {}) + + t.ok(Array.isArray(result), 'should return array') + t.is(result.length, 2, 'should return one entry per container') + const unitA = result.find(r => r.container === 'unit-a') + const unitB = result.find(r => r.container === 'unit-b') + t.ok(unitA, 'should have unit-a') + t.ok(unitB, 'should have unit-b') + t.is(unitA.overriddenConfig, 1, 'unit-a should have 1 miner with overridden config') + t.is(unitB.overriddenConfig, 0, 'unit-b should have 0 overridden') + t.pass() +}) + +test('getPoolStatsContainers - container without poolConfig returns overriddenConfig 0', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, params) => { + if (method !== 'listThings') return [] + if (params?.query?.tags?.$in?.includes('t-container')) { + return [ + { info: { container: 'unit-a' } }, + { info: { container: 'unit-b', poolConfig: 'cfg' } } + ] + } + if (params?.query?.['info.container']?.$in) { + return [{ info: { container: 'unit-b', poolConfig: 'cfg' } }] + } + return [] + } + } + }) + + const result = await getPoolStatsContainers(mockCtx, {}) + + t.is(result.length, 2, 'should return two entries') + const unitA = result.find(r => r.container === 'unit-a') + const unitB = result.find(r => r.container === 'unit-b') + t.is(unitA.overriddenConfig, 0, 'container without poolConfig should have overriddenConfig 0') + t.is(unitB.overriddenConfig, 0, 'unit-b with no overrides should be 0') + t.pass() +}) + +test('getPoolStatsContainers - empty containers returns empty array', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'listThings') return [] + return [] + } + } + }) + + const result = await getPoolStatsContainers(mockCtx, {}) + + t.ok(Array.isArray(result), 'should return array') + t.is(result.length, 0, 'should be empty') + t.pass() +}) + +test('getPoolStatsContainers - handles empty containers from RPC', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'listThings') return [] + return [] + } + } + }) + + const result = await getPoolStatsContainers(mockCtx, {}) + + t.ok(Array.isArray(result), 'should return array') + t.is(result.length, 0, 'should be empty when no containers') + t.pass() +}) diff --git a/tests/unit/routes/miners.routes.test.js b/tests/unit/routes/miners.routes.test.js index a887183..47f3e06 100644 --- a/tests/unit/routes/miners.routes.test.js +++ b/tests/unit/routes/miners.routes.test.js @@ -43,7 +43,6 @@ test('miners routes - schema validation', (t) => { t.is(props.search.type, 'string', 'search should be string') t.is(props.offset.type, 'integer', 'offset should be integer') t.is(props.limit.type, 'integer', 'limit should be integer') - t.is(props.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') t.pass() }) diff --git a/tests/unit/routes/pools.routes.test.js b/tests/unit/routes/pools.routes.test.js index 947e024..f91a771 100644 --- a/tests/unit/routes/pools.routes.test.js +++ b/tests/unit/routes/pools.routes.test.js @@ -5,6 +5,8 @@ const { testModuleStructure, testHandlerFunctions, testOnRequestFunctions } = re const { createRoutesForTest } = require('../helpers/mockHelpers') const ROUTES_PATH = '../../../workers/lib/server/routes/pools.routes.js' +const POOLS_CONFIG_ROUTE_URL = '/auth/pools/config/:id' +const POOLS_STATS_CONTAINERS_ROUTE_URL = '/auth/pools/stats/containers' test('pools routes - module structure', (t) => { testModuleStructure(t, ROUTES_PATH, 'pools') @@ -16,8 +18,8 @@ test('pools routes - route definitions', (t) => { const routeUrls = routes.map(route => route.url) t.ok(routeUrls.includes('/auth/pools'), 'should have pools route') t.ok(routeUrls.includes('/auth/pools/:pool/balance-history'), 'should have balance-history route') - t.ok(routeUrls.includes('/auth/pool-stats/aggregate'), 'should have pool-stats aggregate route') t.ok(routeUrls.includes('/auth/pools/config/:id'), 'should have pools thing config route') + t.ok(routeUrls.includes('/auth/pools/stats/containers'), 'should have pools stats containers route') t.pass() }) @@ -40,3 +42,23 @@ test('pools routes - onRequest functions', (t) => { testOnRequestFunctions(t, routes, 'pools') t.pass() }) + +test('pools routes - GET /auth/pools/config/:id (pools thing config)', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + const configRoute = routes.find(r => r.url === POOLS_CONFIG_ROUTE_URL) + t.ok(configRoute, 'should have pools thing config route') + t.is(configRoute.method, 'GET', 'pools config route should be GET') + t.ok(typeof configRoute.handler === 'function', 'pools config route should have handler') + t.ok(typeof configRoute.onRequest === 'function', 'pools config route should have onRequest (auth)') + t.pass() +}) + +test('pools routes - GET /auth/pools/stats/containers', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + const statsRoute = routes.find(r => r.url === POOLS_STATS_CONTAINERS_ROUTE_URL) + t.ok(statsRoute, 'should have pools stats containers route') + t.is(statsRoute.method, 'GET', 'pools stats containers route should be GET') + t.ok(typeof statsRoute.handler === 'function', 'pools stats containers route should have handler') + t.ok(typeof statsRoute.onRequest === 'function', 'pools stats containers route should have onRequest (auth)') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 6d052c3..3600540 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -126,16 +126,8 @@ const ENDPOINTS = { POOLS: '/auth/pools', POOLS_BALANCE_HISTORY: '/auth/pools/:pool/balance-history', POOLS_THING_CONFIG: '/auth/pools/config/:id', + POOLS_CONTAINERS_STATS: '/auth/pools/stats/containers', - // Pool stats endpoints - POOL_STATS_AGGREGATE: '/auth/pool-stats/aggregate', - - // Pool Manager endpoints - POOL_MANAGER_STATS: '/auth/pool-manager/stats', - POOL_MANAGER_POOLS: '/auth/pool-manager/pools', - POOL_MANAGER_MINERS: '/auth/pool-manager/miners', - POOL_MANAGER_UNITS: '/auth/pool-manager/units', - POOL_MANAGER_ALERTS: '/auth/pool-manager/alerts', SITE_STATUS_LIVE: '/auth/site/status/live', // Generic Config endpoints (type passed as parameter) @@ -228,14 +220,6 @@ const WORKER_TYPES = { ELECTRICITY: 'electricity' } -const CACHE_KEYS = { - POOL_MANAGER_STATS: 'pool-manager/stats', - POOL_MANAGER_POOLS: 'pool-manager/pools', - POOL_MANAGER_MINERS: 'pool-manager/miners', - POOL_MANAGER_UNITS: 'pool-manager/units', - POOL_MANAGER_ALERTS: 'pool-manager/alerts' -} - const SEVERITY_LEVELS = new Set(['critical', 'high', 'medium', 'low']) const ALERTS_DEFAULT_LIMIT = 100 @@ -373,7 +357,8 @@ const MINER_FIELD_MAP = { macAddress: 'info.macAddress', pool: 'last.snap.config.pool_config.url', led: 'last.snap.config.led_status', - alerts: 'last.alerts' + alerts: 'last.alerts', + poolConfig: 'info.poolConfig' } const MINER_PROJECTION_MAP = { @@ -455,7 +440,6 @@ module.exports = { GET_HISTORICAL_LOGS, RPC_METHODS, WORKER_TYPES, - CACHE_KEYS, POOL_ALERT_TYPES, MINER_POOL_STATUS, AGGR_FIELDS, diff --git a/workers/lib/server/controllers/poolManager.js b/workers/lib/server/controllers/poolManager.js deleted file mode 100644 index badca8b..0000000 --- a/workers/lib/server/controllers/poolManager.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict' - -const poolManagerService = require('../services/poolManager') - -const getStats = async (ctx, req) => { - return poolManagerService.getPoolStats(ctx) -} - -const getPools = async (ctx, req) => { - const pools = await poolManagerService.getPoolConfigs(ctx) - - return { - pools, - total: pools.length - } -} - -const getMiners = async (ctx, req) => { - const filters = { - search: req.query.search, - status: req.query.status, - poolUrl: req.query.poolUrl, - model: req.query.model, - page: parseInt(req.query.page) || 1, - limit: parseInt(req.query.limit) || 50 - } - - return poolManagerService.getMinersWithPools(ctx, filters) -} - -const getUnits = async (ctx, req) => { - const units = await poolManagerService.getUnitsWithPoolData(ctx) - - return { - units, - total: units.length - } -} - -const getAlerts = async (ctx, req) => { - const filters = { - limit: parseInt(req.query.limit) || 50 - } - - const alerts = await poolManagerService.getPoolAlerts(ctx, filters) - - return { - alerts, - total: alerts.length - } -} - -module.exports = { - getStats, - getPools, - getMiners, - getUnits, - getAlerts -} diff --git a/workers/lib/server/handlers/pools.handlers.js b/workers/lib/server/handlers/pools.handlers.js index 64dae54..0af8b32 100644 --- a/workers/lib/server/handlers/pools.handlers.js +++ b/workers/lib/server/handlers/pools.handlers.js @@ -4,15 +4,14 @@ const mingo = require('mingo') const { RPC_METHODS, WORKER_TYPES, - PERIOD_TYPES, MINERPOOL_EXT_DATA_KEYS, - RANGE_BUCKETS + RANGE_BUCKETS, + MINER_FIELD_MAP } = require('../../constants') const { parseJsonQueryParam, getStartOfDay } = require('../../utils') -const { aggregateByPeriod } = require('../../period.utils') async function getPools (ctx, req) { const filter = req.query.query ? parseJsonQueryParam(req.query.query, 'ERR_QUERY_INVALID_JSON') : null @@ -192,102 +191,6 @@ function groupByBucket (entries, bucketSize) { return buckets } -async function getPoolStatsAggregate (ctx, req) { - const start = Number(req.query.start) - const end = Number(req.query.end) - const range = req.query.range || PERIOD_TYPES.DAILY - const poolFilter = req.query.pool || null - - if (!start || !end) { - throw new Error('ERR_MISSING_START_END') - } - - if (start >= end) { - throw new Error('ERR_INVALID_DATE_RANGE') - } - - const transactionResults = await ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { - type: WORKER_TYPES.MINERPOOL, - query: { key: MINERPOOL_EXT_DATA_KEYS.TRANSACTIONS, start, end, pool: poolFilter } - }) - - const dailyData = processTransactionData(transactionResults) - - const log = Object.entries(dailyData) - .sort(([a], [b]) => Number(a) - Number(b)) - .map(([ts, data]) => ({ - ts: Number(ts), - balance: data.revenueBTC, - hashrate: data.hashCount > 0 ? data.hashrate / data.hashCount : 0, - workerCount: 0, - revenueBTC: data.revenueBTC - })) - - const aggregated = aggregateByPeriod(log, range) - const summary = calculateAggregateSummary(aggregated) - - return { log: aggregated, summary } -} - -function processTransactionData (results) { - const daily = {} - for (const res of results) { - if (res.error || !res) continue - const data = Array.isArray(res) ? res : (res.data || res.result || []) - if (!Array.isArray(data)) continue - - for (const entry of data) { - if (!entry) continue - const ts = getStartOfDay(Number(entry.ts)) - if (!ts) continue - - const txs = entry.transactions || [] - if (!Array.isArray(txs) || txs.length === 0) continue - - for (const tx of txs) { - if (!tx) continue - - if (!daily[ts]) daily[ts] = { revenueBTC: 0, hashrate: 0, hashCount: 0 } - daily[ts].revenueBTC += Math.abs(tx.changed_balance || 0) - if (tx.mining_extra?.hash_rate) { - daily[ts].hashrate += tx.mining_extra.hash_rate - daily[ts].hashCount++ - } - } - } - } - return daily -} - -function calculateAggregateSummary (log) { - if (!log.length) { - return { - totalRevenueBTC: 0, - avgHashrate: 0, - avgWorkerCount: 0, - latestBalance: 0, - periodCount: 0 - } - } - - const totals = log.reduce((acc, entry) => { - acc.revenueBTC += entry.revenueBTC || 0 - acc.hashrate += entry.hashrate || 0 - acc.workerCount += entry.workerCount || 0 - return acc - }, { revenueBTC: 0, hashrate: 0, workerCount: 0 }) - - const latest = log[log.length - 1] - - return { - totalRevenueBTC: totals.revenueBTC, - avgHashrate: totals.hashrate / log.length, - avgWorkerCount: totals.workerCount / log.length, - latestBalance: latest.balance || 0, - periodCount: log.length - } -} - const getPoolThingConfig = async (ctx, req) => { const thing = await ctx.dataProxy.requestData(RPC_METHODS.LIST_THINGS, { query: { id: req.params.id }, fields: { info: 1 } @@ -307,6 +210,25 @@ const getPoolThingConfig = async (ctx, req) => { return { poolConfig: info?.poolConfig || null, overriddenConfig } } +const getPoolStatsContainers = async (ctx, req) => { + const fields = { [MINER_FIELD_MAP.container]: 1, [MINER_FIELD_MAP.poolConfig]: 1 } + const containers = await ctx.dataProxy.requestData(RPC_METHODS.LIST_THINGS, { + fields, query: { tags: { $in: ['t-container'] } } + }) + const containerIds = containers?.[0]?.filter(m => m.info?.poolConfig).map(m => m.info.container) + const miners = await ctx.dataProxy.requestData(RPC_METHODS.LIST_THINGS, { + fields, query: { [MINER_FIELD_MAP.container]: { $in: containerIds } } + }) + + return containers?.[0]?.map(data => { + if (!data.info?.poolConfig) return { container: data.info.container, overriddenConfig: 0 } + return { + container: data.info.container, + overriddenConfig: miners?.[0]?.filter(m => m.info?.poolConfig && m.info?.container === data.info?.container && m.info?.poolConfig !== data.info?.poolConfig)?.length || 0 + } + }) +} + module.exports = { getPools, flattenPoolStats, @@ -314,8 +236,6 @@ module.exports = { getPoolBalanceHistory, flattenTransactionResults, groupByBucket, - getPoolStatsAggregate, - processTransactionData, - calculateAggregateSummary, - getPoolThingConfig + getPoolThingConfig, + getPoolStatsContainers } diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 5b11952..9539e57 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -10,7 +10,6 @@ const settingsRoutes = require('./routes/settings.routes') const wsRoutes = require('./routes/ws.routes') const financeRoutes = require('./routes/finance.routes') const poolsRoutes = require('./routes/pools.routes') -const poolManagerRoutes = require('./routes/poolManager.routes') const siteRoutes = require('./routes/site.routes') const configsRoutes = require('./routes/configs.routes') const devicesRoutes = require('./routes/devices.routes') @@ -34,7 +33,6 @@ function routes (ctx) { ...wsRoutes(ctx), ...financeRoutes(ctx), ...poolsRoutes(ctx), - ...poolManagerRoutes(ctx), ...siteRoutes(ctx), ...configsRoutes(ctx), ...devicesRoutes(ctx), diff --git a/workers/lib/server/routes/poolManager.routes.js b/workers/lib/server/routes/poolManager.routes.js deleted file mode 100644 index 5552307..0000000 --- a/workers/lib/server/routes/poolManager.routes.js +++ /dev/null @@ -1,120 +0,0 @@ -'use strict' - -const { - getStats, - getPools, - getMiners, - getUnits, - getAlerts -} = require('../controllers/poolManager') -const { ENDPOINTS, HTTP_METHODS } = require('../../constants') -const { createCachedAuthRoute } = require('../lib/routeHelpers') - -const POOL_MANAGER_MINERS_SCHEMA = { - querystring: { - type: 'object', - properties: { - search: { type: 'string' }, - status: { type: 'string' }, - poolUrl: { type: 'string' }, - model: { type: 'string' }, - page: { type: 'integer' }, - limit: { type: 'integer' }, - overwriteCache: { type: 'boolean' } - } - } -} - -const POOL_MANAGER_ALERTS_SCHEMA = { - querystring: { - type: 'object', - properties: { - limit: { type: 'integer' }, - overwriteCache: { type: 'boolean' } - } - } -} - -const POOL_MANAGER_CACHE_SCHEMA = { - querystring: { - type: 'object', - properties: { - overwriteCache: { type: 'boolean' } - } - } -} - -module.exports = (ctx) => { - return [ - { - method: HTTP_METHODS.GET, - url: ENDPOINTS.POOL_MANAGER_STATS, - schema: POOL_MANAGER_CACHE_SCHEMA, - ...createCachedAuthRoute( - ctx, - ['pool-manager/stats'], - ENDPOINTS.POOL_MANAGER_STATS, - getStats - ) - }, - - { - method: HTTP_METHODS.GET, - url: ENDPOINTS.POOL_MANAGER_POOLS, - schema: POOL_MANAGER_CACHE_SCHEMA, - ...createCachedAuthRoute( - ctx, - ['pool-manager/pools'], - ENDPOINTS.POOL_MANAGER_POOLS, - getPools - ) - }, - - { - method: HTTP_METHODS.GET, - url: ENDPOINTS.POOL_MANAGER_MINERS, - schema: POOL_MANAGER_MINERS_SCHEMA, - ...createCachedAuthRoute( - ctx, - (req) => [ - 'pool-manager/miners', - req.query.search, - req.query.status, - req.query.poolUrl, - req.query.model, - req.query.page, - req.query.limit - ], - ENDPOINTS.POOL_MANAGER_MINERS, - getMiners - ) - }, - - { - method: HTTP_METHODS.GET, - url: ENDPOINTS.POOL_MANAGER_UNITS, - schema: POOL_MANAGER_CACHE_SCHEMA, - ...createCachedAuthRoute( - ctx, - ['pool-manager/units'], - ENDPOINTS.POOL_MANAGER_UNITS, - getUnits - ) - }, - - { - method: HTTP_METHODS.GET, - url: ENDPOINTS.POOL_MANAGER_ALERTS, - schema: POOL_MANAGER_ALERTS_SCHEMA, - ...createCachedAuthRoute( - ctx, - (req) => [ - 'pool-manager/alerts', - req.query.limit - ], - ENDPOINTS.POOL_MANAGER_ALERTS, - getAlerts - ) - } - ] -} diff --git a/workers/lib/server/routes/pools.routes.js b/workers/lib/server/routes/pools.routes.js index 36ed79e..ef3279d 100644 --- a/workers/lib/server/routes/pools.routes.js +++ b/workers/lib/server/routes/pools.routes.js @@ -7,8 +7,8 @@ const { const { getPools, getPoolBalanceHistory, - getPoolStatsAggregate, - getPoolThingConfig + getPoolThingConfig, + getPoolStatsContainers } = require('../handlers/pools.handlers') const { createCachedAuthRoute, createAuthRoute } = require('../lib/routeHelpers') @@ -55,29 +55,18 @@ module.exports = (ctx) => { }, { method: HTTP_METHODS.GET, - url: ENDPOINTS.POOL_STATS_AGGREGATE, - schema: { - querystring: schemas.query.poolStatsAggregate - }, - ...createCachedAuthRoute( + url: ENDPOINTS.POOLS_THING_CONFIG, + ...createAuthRoute( ctx, - (req) => [ - 'pool-stats/aggregate', - req.query.start, - req.query.end, - req.query.range, - req.query.pool - ], - ENDPOINTS.POOL_STATS_AGGREGATE, - getPoolStatsAggregate + getPoolThingConfig ) }, { method: HTTP_METHODS.GET, - url: ENDPOINTS.POOLS_THING_CONFIG, + url: ENDPOINTS.POOLS_CONTAINERS_STATS, ...createAuthRoute( ctx, - getPoolThingConfig + getPoolStatsContainers ) } ] diff --git a/workers/lib/server/services/poolManager.js b/workers/lib/server/services/poolManager.js deleted file mode 100644 index 38883a2..0000000 --- a/workers/lib/server/services/poolManager.js +++ /dev/null @@ -1,263 +0,0 @@ -'use strict' - -const { - LIST_THINGS, - WORKER_TYPES, - POOL_ALERT_TYPES, - RPC_METHODS, - MINERPOOL_EXT_DATA_KEYS -} = require('../../constants') - -const getPoolStats = async (ctx) => { - const pools = await _fetchPoolStats(ctx) - - const totalWorkers = pools.reduce((sum, p) => sum + (p.workerCount || 0), 0) - const activeWorkers = pools.reduce((sum, p) => sum + (p.activeWorkerCount || 0), 0) - const totalHashrate = pools.reduce((sum, p) => sum + (p.hashrate || 0), 0) - const totalBalance = pools.reduce((sum, p) => sum + (p.balance || 0), 0) - - return { - totalPools: pools.length, - totalWorkers, - activeWorkers, - totalHashrate, - totalBalance, - errors: totalWorkers - activeWorkers - } -} - -const getPoolConfigs = async (ctx) => { - return _fetchPoolStats(ctx) -} - -const getMinersWithPools = async (ctx, filters = {}) => { - const { search, model, page = 1, limit = 50 } = filters - - const results = await ctx.dataProxy.requestDataAllPages(LIST_THINGS, { - type: WORKER_TYPES.MINER, - query: {}, - fields: { id: 1, code: 1, type: 1, info: 1, address: 1 } - }) - - let allMiners = [] - - results.forEach((clusterData) => { - if (!Array.isArray(clusterData)) return - - clusterData.forEach((thing) => { - if (!thing?.type?.startsWith('miner-')) return - - allMiners.push({ - id: thing.id, - code: thing.code, - type: thing.type, - model: _extractModelFromType(thing.type), - container: thing.info?.container || null, - ipAddress: thing.address || null, - serialNum: thing.info?.serialNum || null, - nominalHashrate: thing.info?.nominalHashrateMhs || 0 - }) - }) - }) - - if (search) { - const s = search.toLowerCase() - allMiners = allMiners.filter(m => - m.id.toLowerCase().includes(s) || - (m.code && m.code.toLowerCase().includes(s)) || - (m.serialNum && m.serialNum.toLowerCase().includes(s)) || - (m.ipAddress && m.ipAddress.includes(s)) - ) - } - - if (model) { - const m = model.toLowerCase() - allMiners = allMiners.filter(miner => - miner.model.toLowerCase().includes(m) || - miner.type.toLowerCase().includes(m) - ) - } - - const total = allMiners.length - const startIdx = (page - 1) * limit - const paginatedMiners = allMiners.slice(startIdx, startIdx + limit) - - return { - miners: paginatedMiners, - total, - page, - limit, - totalPages: Math.ceil(total / limit) - } -} - -const getUnitsWithPoolData = async (ctx) => { - const results = await ctx.dataProxy.requestDataAllPages(LIST_THINGS, { - type: WORKER_TYPES.MINER, - query: {}, - fields: { id: 1, type: 1, info: 1 } - }) - - const unitsMap = new Map() - - results.forEach((clusterData) => { - if (!Array.isArray(clusterData)) return - - clusterData.forEach((thing) => { - if (!thing?.type?.startsWith('miner-')) return - - const container = thing.info?.container || 'unassigned' - - if (!unitsMap.has(container)) { - unitsMap.set(container, { - name: container, - miners: [], - totalNominalHashrate: 0 - }) - } - - const unitData = unitsMap.get(container) - unitData.miners.push(thing.id) - unitData.totalNominalHashrate += thing.info?.nominalHashrateMhs || 0 - }) - }) - - return Array.from(unitsMap.values()).map((unit) => ({ - name: unit.name, - minersCount: unit.miners.length, - nominalHashrate: unit.totalNominalHashrate - })) -} - -const getPoolAlerts = async (ctx, filters = {}) => { - const { limit = 50 } = filters - - const results = await ctx.dataProxy.requestDataAllPages(LIST_THINGS, { - type: WORKER_TYPES.MINER, - query: {}, - fields: { id: 1, code: 1, type: 1, info: 1, alerts: 1 } - }) - - const alerts = [] - - results.forEach((clusterData) => { - if (!Array.isArray(clusterData)) return - - clusterData.forEach((thing) => { - const minerAlerts = thing?.alerts || {} - - POOL_ALERT_TYPES.forEach((alertType) => { - if (minerAlerts[alertType]) { - alerts.push({ - id: `${thing.id}-${alertType}`, - type: alertType, - minerId: thing.id, - code: thing.code, - container: thing.info?.container || null, - severity: _getAlertSeverity(alertType), - message: _getAlertMessage(alertType, thing.code || thing.id), - timestamp: minerAlerts[alertType]?.ts || Date.now() - }) - } - }) - }) - }) - - alerts.sort((a, b) => b.timestamp - a.timestamp) - return alerts.slice(0, limit) -} - -async function _fetchPoolStats (ctx) { - const results = await ctx.dataProxy.requestDataMap(RPC_METHODS.GET_WRK_EXT_DATA, { - type: 'minerpool', - query: { key: MINERPOOL_EXT_DATA_KEYS.STATS } - }) - - const pools = [] - const seen = new Set() - - for (const orkResult of results) { - if (!orkResult || orkResult.error) continue - const items = Array.isArray(orkResult) ? orkResult : [] - - for (const item of items) { - if (!item) continue - const stats = item.stats || [] - 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 pools -} - -function _extractModelFromType (type) { - if (!type) return 'Unknown' - const models = { - 'miner-am': 'Antminer', - 'miner-wm': 'Whatsminer', - 'miner-av': 'Avalon' - } - - for (const [prefix, brand] of Object.entries(models)) { - if (type.startsWith(prefix)) { - const suffix = type.slice(prefix.length + 1).toUpperCase() - return suffix ? `${brand} ${suffix}` : brand - } - } - - return type -} - -function _getAlertSeverity (alertType) { - const severityMap = { - all_pools_dead: 'critical', - wrong_miner_pool: 'critical', - wrong_miner_subaccount: 'critical', - wrong_worker_name: 'medium', - ip_worker_name: 'medium' - } - return severityMap[alertType] || 'low' -} - -function _getAlertMessage (alertType, minerLabel) { - const messageMap = { - all_pools_dead: `All pools are dead - ${minerLabel}`, - wrong_miner_pool: `Pool URL mismatch - ${minerLabel}`, - wrong_miner_subaccount: `Wrong pool subaccount - ${minerLabel}`, - wrong_worker_name: `Incorrect worker name - ${minerLabel}`, - ip_worker_name: `Worker name uses IP address - ${minerLabel}` - } - return messageMap[alertType] || `Pool alert - ${minerLabel}` -} - -module.exports = { - getPoolStats, - getPoolConfigs, - getMinersWithPools, - getUnitsWithPoolData, - getPoolAlerts -} From 4a9783b6d155a9339fa61b91762634f8e6f1f026 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 18 Mar 2026 19:04:47 +0300 Subject: [PATCH 21/63] fix: add schema bounds to prevent negative limit/offset bypass (#37) --- workers/lib/server/schemas/alerts.schemas.js | 8 ++++---- workers/lib/server/schemas/finance.schemas.js | 4 ++-- workers/lib/server/schemas/metrics.schemas.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/workers/lib/server/schemas/alerts.schemas.js b/workers/lib/server/schemas/alerts.schemas.js index 47bb212..637e697 100644 --- a/workers/lib/server/schemas/alerts.schemas.js +++ b/workers/lib/server/schemas/alerts.schemas.js @@ -8,8 +8,8 @@ const schemas = { filter: { type: 'string' }, sort: { type: 'string' }, search: { type: 'string' }, - offset: { type: 'integer' }, - limit: { type: 'integer' }, + offset: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1 }, overwriteCache: { type: 'boolean' } } }, @@ -21,8 +21,8 @@ const schemas = { filter: { type: 'string' }, search: { type: 'string' }, sort: { type: 'string' }, - offset: { type: 'integer' }, - limit: { type: 'integer' }, + offset: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1 }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index 2e40745..5bf2666 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -66,8 +66,8 @@ const schemas = { hashRevenue: { type: 'object', properties: { - start: { type: 'integer' }, - end: { type: 'integer' }, + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, overwriteCache: { type: 'boolean' } }, diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index c21404c..8cbc39c 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -79,7 +79,7 @@ const schemas = { properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 0 }, - limit: { type: 'integer' }, + limit: { type: 'integer', minimum: 1, maximum: 1000 }, overwriteCache: { type: 'boolean' } } } From da736861ec37527c462c8b91eff4d11544ebd1f0 Mon Sep 17 00:00:00 2001 From: andretetherio Date: Thu, 19 Mar 2026 10:46:06 -0300 Subject: [PATCH 22/63] ci: move away from pull_request_target (#38) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60c1eaf..c2098f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: PR - Test & Validate (Local) on: push: branches: [main, staging, dev, develop] - pull_request_target: + pull_request: branches: [main, staging, dev, develop] types: [opened, reopened, synchronize] workflow_dispatch: @@ -19,7 +19,7 @@ jobs: dependency-review: name: 📋 Dependency Review runs-on: ubuntu-latest - if: github.event_name == 'pull_request_target' || github.event_name == 'pull_request' + if: github.event_name == 'pull_request' permissions: contents: read pull-requests: read From 5f88cacaa1888290aed83549c635fe878a46e5a9 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 9 Apr 2026 09:34:17 +0300 Subject: [PATCH 23/63] feat: add groups stats endpoint and racks filter for metrics (#47) * feat: add groups stats endpoint and racks filter for metrics Add GET /auth/groups/stats endpoint for live rack-group aggregation (efficiency, hashrate, power, miner counts). Extend hashrate and consumption metrics with optional racks query parameter for filtering by rack groups via ORK RPC. * feat: add groups stats endpoint and racks filter for metrics Add GET /auth/groups/stats endpoint for live rack-group aggregation (efficiency, hashrate, power, miner counts). Extend hashrate and consumption metrics with optional racks query parameter for filtering by rack groups via ORK RPC. * fix: rename racks filter to containers for metrics endpoints * refactor: deduplicate parseContainers, extractKeyEntry and use AGGR_FIELDS constants * fix: remove non-functional containers filter from hashrate/consumption tailLogCustomRangeAggr RPC method does not support containers param, so remove the dead code from getHashrate/getConsumption handlers, their schemas, route cache keys, and related tests. * fix: handle noAuth mode in capCheck capCheck crashes in noAuth mode when routes pass permissions because ctx.authLib is undefined. Add early return guard matching authCheck. * Revert "fix: handle noAuth mode in capCheck" This reverts commit 2d4d9929545e3791a6cfe3f15263bfeb27fb0b8d. * test: improve branch coverage for metrics handlers Add tests for uncovered branches: temperature rolling avg, container history prefix match, non-object guard branches, and groupRange config paths in getPowerMode/getTemperature. * chore: remove unnecessary comment --- tests/unit/handlers/groups.handlers.test.js | 284 ++++++++++++++++++ tests/unit/handlers/metrics.handlers.test.js | 141 +++++++++ tests/unit/lib/queryUtils.test.js | 27 ++ tests/unit/routes/groups.routes.test.js | 55 ++++ workers/lib/constants.js | 13 +- workers/lib/metrics.utils.js | 8 + .../lib/server/handlers/groups.handlers.js | 92 ++++++ .../lib/server/handlers/metrics.handlers.js | 1 - workers/lib/server/handlers/site.handlers.js | 17 +- workers/lib/server/index.js | 4 +- workers/lib/server/lib/queryUtils.js | 9 +- workers/lib/server/routes/groups.routes.js | 34 +++ workers/lib/server/schemas/groups.schemas.js | 16 + 13 files changed, 681 insertions(+), 20 deletions(-) create mode 100644 tests/unit/handlers/groups.handlers.test.js create mode 100644 tests/unit/routes/groups.routes.test.js create mode 100644 workers/lib/server/handlers/groups.handlers.js create mode 100644 workers/lib/server/routes/groups.routes.js create mode 100644 workers/lib/server/schemas/groups.schemas.js diff --git a/tests/unit/handlers/groups.handlers.test.js b/tests/unit/handlers/groups.handlers.test.js new file mode 100644 index 0000000..42c0920 --- /dev/null +++ b/tests/unit/handlers/groups.handlers.test.js @@ -0,0 +1,284 @@ +'use strict' + +const test = require('brittle') +const { + getGroupStats, + composeGroupStats, + sumGroupedField +} = require('../../../workers/lib/server/handlers/groups.handlers') +const { extractKeyEntry } = require('../../../workers/lib/metrics.utils') +const { withDataProxy } = require('../helpers/mockHelpers') + +// ==================== extractKeyEntry Tests ==================== + +test('extractKeyEntry - returns entry at index', (t) => { + const orkResult = [[{ hashrate: 100 }], [{ power: 200 }]] + const entry = extractKeyEntry(orkResult, 0) + t.alike(entry, { hashrate: 100 }, 'should return first key entry') + t.pass() +}) + +test('extractKeyEntry - returns null for non-array', (t) => { + t.is(extractKeyEntry(null, 0), null, 'null input returns null') + t.is(extractKeyEntry({}, 0), null, 'object input returns null') + t.pass() +}) + +test('extractKeyEntry - returns null for empty key result', (t) => { + t.is(extractKeyEntry([[]], 0), null, 'empty array returns null') + t.is(extractKeyEntry([], 0), null, 'missing index returns null') + t.pass() +}) + +// ==================== sumGroupedField Tests ==================== + +test('sumGroupedField - sums values for matching containers', (t) => { + const grouped = { 'C-01': 100, 'C-02': 200, 'C-03': 300 } + t.is(sumGroupedField(grouped, ['C-01', 'C-03']), 400, 'should sum matching containers') + t.pass() +}) + +test('sumGroupedField - returns 0 for non-matching containers', (t) => { + const grouped = { 'C-01': 100 } + t.is(sumGroupedField(grouped, ['C-99']), 0, 'should return 0 for missing containers') + t.pass() +}) + +test('sumGroupedField - handles null/undefined input', (t) => { + t.is(sumGroupedField(null, ['C-01']), 0, 'null returns 0') + t.is(sumGroupedField(undefined, ['C-01']), 0, 'undefined returns 0') + t.pass() +}) + +// ==================== composeGroupStats Tests ==================== + +test('composeGroupStats - aggregates container-grouped data across orks', (t) => { + const results = [ + [ + [{ + hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 50000, 'C-02': 30000 }, + power_w_container_group_sum_aggr: { 'C-01': 5000, 'C-02': 3000 }, + power_mode_low_cnt: { 'C-01': 2, 'C-02': 1 }, + power_mode_normal_cnt: { 'C-01': 5, 'C-02': 4 }, + power_mode_high_cnt: { 'C-01': 3, 'C-02': 3 }, + offline_cnt: { 'C-01': 1, 'C-02': 0 }, + error_cnt: { 'C-01': 0, 'C-02': 1 }, + not_mining_cnt: { 'C-01': 0, 'C-02': 0 }, + power_mode_sleep_cnt: { 'C-01': 1, 'C-02': 0 } + }] + ] + ] + + const stats = composeGroupStats(results, ['C-01', 'C-02']) + t.is(stats.hashrateMhs, 80000, 'should sum hashrate for both containers') + t.is(stats.powerW, 8000, 'should sum power for both containers') + t.is(stats.onlineCount, 18, 'should sum online miners (low+normal+high)') + t.is(stats.minerCount, 21, 'should sum all miners across all statuses') + t.ok(typeof stats.efficiency === 'number', 'should have efficiency') + t.pass() +}) + +test('composeGroupStats - filters to requested containers only', (t) => { + const results = [ + [ + [{ + hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 50000, 'C-02': 30000, 'C-03': 20000 }, + power_w_container_group_sum_aggr: { 'C-01': 5000, 'C-02': 3000, 'C-03': 2000 }, + power_mode_normal_cnt: { 'C-01': 10, 'C-02': 8, 'C-03': 6 }, + power_mode_low_cnt: {}, + power_mode_high_cnt: {}, + offline_cnt: {}, + error_cnt: {}, + not_mining_cnt: {}, + power_mode_sleep_cnt: {} + }] + ] + ] + + const stats = composeGroupStats(results, ['C-01']) + t.is(stats.hashrateMhs, 50000, 'should only include C-01 hashrate') + t.is(stats.powerW, 5000, 'should only include C-01 power') + t.is(stats.onlineCount, 10, 'should only include C-01 miners') + t.pass() +}) + +test('composeGroupStats - empty results', (t) => { + const stats = composeGroupStats([], ['C-01']) + t.is(stats.hashrateMhs, 0, 'hashrate should be 0') + t.is(stats.powerW, 0, 'power should be 0') + t.is(stats.minerCount, 0, 'miner count should be 0') + t.is(stats.onlineCount, 0, 'online count should be 0') + t.is(stats.efficiency, 0, 'efficiency should be 0 when no hashrate') + t.pass() +}) + +test('composeGroupStats - zero hashrate gives zero efficiency', (t) => { + const results = [ + [ + [{ + hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 0 }, + power_w_container_group_sum_aggr: { 'C-01': 5000 }, + power_mode_low_cnt: {}, + power_mode_normal_cnt: {}, + power_mode_high_cnt: {}, + offline_cnt: { 'C-01': 2 }, + error_cnt: {}, + not_mining_cnt: {}, + power_mode_sleep_cnt: {} + }] + ] + ] + + const stats = composeGroupStats(results, ['C-01']) + t.is(stats.efficiency, 0, 'efficiency should be 0 with zero hashrate') + t.pass() +}) + +test('composeGroupStats - handles missing fields gracefully', (t) => { + const results = [ + [ + [{}] + ] + ] + + const stats = composeGroupStats(results, ['C-01']) + t.is(stats.hashrateMhs, 0, 'missing fields default to 0') + t.is(stats.powerW, 0, 'missing power defaults to 0') + t.pass() +}) + +test('composeGroupStats - multi-ork aggregation', (t) => { + const results = [ + [ + [{ + hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 40000 }, + power_w_container_group_sum_aggr: { 'C-01': 4000 }, + power_mode_normal_cnt: { 'C-01': 8 }, + power_mode_low_cnt: {}, + power_mode_high_cnt: {}, + offline_cnt: { 'C-01': 1 }, + error_cnt: {}, + not_mining_cnt: {}, + power_mode_sleep_cnt: {} + }] + ], + [ + [{ + hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 20000 }, + power_w_container_group_sum_aggr: { 'C-01': 2000 }, + power_mode_normal_cnt: { 'C-01': 4 }, + power_mode_low_cnt: {}, + power_mode_high_cnt: {}, + offline_cnt: {}, + error_cnt: {}, + not_mining_cnt: {}, + power_mode_sleep_cnt: {} + }] + ] + ] + + const stats = composeGroupStats(results, ['C-01']) + t.is(stats.hashrateMhs, 60000, 'should sum hashrate across orks') + t.is(stats.powerW, 6000, 'should sum power across orks') + t.is(stats.onlineCount, 12, 'should sum online across orks') + t.is(stats.minerCount, 13, 'should sum all miners across orks') + t.pass() +}) + +// ==================== getGroupStats Tests ==================== + +test('getGroupStats - happy path', async (t) => { + const mockCtx = withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [ + [{ + hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 60000, 'C-02': 40000 }, + power_w_container_group_sum_aggr: { 'C-01': 6000, 'C-02': 4000 }, + power_mode_normal_cnt: { 'C-01': 12, 'C-02': 8 }, + power_mode_low_cnt: {}, + power_mode_high_cnt: {}, + offline_cnt: { 'C-01': 1 }, + error_cnt: {}, + not_mining_cnt: {}, + power_mode_sleep_cnt: {} + }] + ] + } + } + }) + + const mockReq = { query: { containers: 'C-01,C-02' } } + const result = await getGroupStats(mockCtx, mockReq) + + t.is(result.hashrateMhs, 100000, 'should have hashrate for both containers') + t.is(result.powerW, 10000, 'should have power for both containers') + t.is(result.minerCount, 21, 'should have miner count') + t.is(result.onlineCount, 20, 'should have online count') + t.ok(typeof result.efficiency === 'number', 'should have efficiency') + t.pass() +}) + +test('getGroupStats - missing containers throws', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + }) + + try { + await getGroupStats(mockCtx, { query: {} }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_CONTAINERS', 'should throw missing containers error') + } + t.pass() +}) + +test('getGroupStats - empty containers string throws', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [] }, + net_r0: { jRequest: async () => ({}) } + }) + + try { + await getGroupStats(mockCtx, { query: { containers: '' } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_CONTAINERS', 'should throw for empty containers') + } + t.pass() +}) + +test('getGroupStats - filters to requested containers', async (t) => { + const mockCtx = withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return [ + [{ + hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 50000, 'C-02': 30000, 'C-03': 20000 }, + power_w_container_group_sum_aggr: { 'C-01': 5000, 'C-02': 3000, 'C-03': 2000 }, + power_mode_normal_cnt: { 'C-01': 10, 'C-02': 8, 'C-03': 6 }, + power_mode_low_cnt: {}, + power_mode_high_cnt: {}, + offline_cnt: {}, + error_cnt: {}, + not_mining_cnt: {}, + power_mode_sleep_cnt: {} + }] + ] + } + } + }) + + const result = await getGroupStats(mockCtx, { query: { containers: 'C-01' } }) + t.is(result.hashrateMhs, 50000, 'should only include C-01 hashrate') + t.is(result.powerW, 5000, 'should only include C-01 power') + t.is(result.onlineCount, 10, 'should only include C-01 miners') + t.pass() +}) diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index 7314e3e..69f3c5c 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -1626,3 +1626,144 @@ test('processContainerHistoryData - sorts by timestamp', (t) => { t.ok(log[0].ts < log[1].ts, 'entries should be sorted ascending') t.pass() }) + +test('processTemperatureData - rolling avg for same container across entries', (t) => { + const results = [[ + { + ts: 1700006400000, + temperature_c_group_max_aggr: { cont1: 65 }, + temperature_c_group_avg_aggr: { cont1: 55 } + }, + { + ts: 1700006400000, + temperature_c_group_max_aggr: { cont1: 70 }, + temperature_c_group_avg_aggr: { cont1: 60 } + } + ]] + + const points = processTemperatureData(results, '1D', null) + const key = Object.keys(points)[0] + t.is(points[key].containers.cont1.maxC, 70, 'should take max of both entries') + t.ok(points[key].containers.cont1.avgC > 55, 'should compute rolling avg') + t.pass() +}) + +test('processContainerHistoryData - prefix match fallback', (t) => { + const results = [[ + { + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'bitdeer-9a-sensor1': { humidity: 45 } + } + } + ]] + const log = processContainerHistoryData(results, 'bitdeer-9a') + t.is(log.length, 1, 'should match via prefix') + t.is(log[0].humidity, 45, 'should return prefixed container data') + t.pass() +}) + +test('processContainerHistoryData - no match skips entry', (t) => { + const results = [[ + { + ts: 1700006400000, + container_specific_stats_group_aggr: { + 'other-container': { humidity: 45 } + } + } + ]] + const log = processContainerHistoryData(results, 'bitdeer-9a') + t.is(log.length, 0, 'should skip non-matching entries') + t.pass() +}) + +test('processContainerSensorSnapshot - handles non-object aggrData', (t) => { + const results = [[ + { + container_specific_stats_group_aggr: 'invalid' + } + ]] + const result = processContainerSensorSnapshot(results, 'cont1') + t.is(result, null, 'should return null for non-object aggrData') + t.pass() +}) + +test('processTemperatureData - handles non-object maxObj', (t) => { + const results = [[{ + ts: 1700006400000, + temperature_c_group_max_aggr: 'invalid', + temperature_c_group_avg_aggr: {} + }]] + + const points = processTemperatureData(results, '1D', null) + const key = Object.keys(points)[0] + t.is(points[key].siteMaxC, null, 'should not process non-object maxObj') + t.pass() +}) + +test('processPowerModeData - handles non-object powerModeObj', (t) => { + const results = [[{ + ts: 1700006400000, + power_mode_group_aggr: 'invalid', + status_group_aggr: {} + }]] + + const points = processPowerModeData(results, '1D') + const key = Object.keys(points)[0] + t.is(points[key].normal, 0, 'should not process non-object powerModeObj') + t.pass() +}) + +test('getPowerMode - sets groupRange for multi-day range', async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, params) => { + capturedPayload = params + return [] + } + } + }) + + const threeDaysMs = 3 * 24 * 60 * 60 * 1000 + await getPowerMode(mockCtx, { + query: { start: 1700000000000, end: 1700000000000 + threeDaysMs } + }) + + t.is(capturedPayload.groupRange, '1D', 'should set groupRange for multi-day range') + t.pass() +}) + +test('getTemperature - sets groupRange for multi-day range', async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, params) => { + capturedPayload = params + return [] + } + } + }) + + const threeDaysMs = 3 * 24 * 60 * 60 * 1000 + await getTemperature(mockCtx, { + query: { start: 1700000000000, end: 1700000000000 + threeDaysMs } + }) + + t.is(capturedPayload.groupRange, '1D', 'should set groupRange for multi-day range') + t.pass() +}) + +test('processPowerModeTimelineData - handles non-object powerModeObj', (t) => { + const results = [[{ + ts: 1700006400000, + power_mode_group_aggr: 'invalid', + status_group_aggr: {} + }]] + + const log = processPowerModeTimelineData(results, null) + t.is(log.length, 0, 'should skip non-object powerModeObj entries') + t.pass() +}) diff --git a/tests/unit/lib/queryUtils.test.js b/tests/unit/lib/queryUtils.test.js index 5231fed..7e09896 100644 --- a/tests/unit/lib/queryUtils.test.js +++ b/tests/unit/lib/queryUtils.test.js @@ -8,6 +8,7 @@ const { buildSearchQuery, flattenOrkResults, sortItems, + parseContainers, paginateResults } = require('../../../workers/lib/server/lib/queryUtils') @@ -275,3 +276,29 @@ test('paginateResults - offset beyond total', (t) => { t.is(result.hasMore, false) t.pass() }) + +// ==================== parseContainers Tests ==================== + +test('parseContainers - parses comma-separated containers', (t) => { + const result = parseContainers({ query: { containers: 'C-01,C-02,C-03' } }) + t.alike(result, ['C-01', 'C-02', 'C-03'], 'should split on commas') + t.pass() +}) + +test('parseContainers - trims whitespace', (t) => { + const result = parseContainers({ query: { containers: 'C-01 , C-02 , C-03' } }) + t.alike(result, ['C-01', 'C-02', 'C-03'], 'should trim spaces') + t.pass() +}) + +test('parseContainers - returns undefined when no containers', (t) => { + t.is(parseContainers({ query: {} }), undefined, 'missing containers returns undefined') + t.is(parseContainers({ query: { containers: '' } }), undefined, 'empty string returns undefined') + t.pass() +}) + +test('parseContainers - single container', (t) => { + const result = parseContainers({ query: { containers: 'C-01' } }) + t.alike(result, ['C-01'], 'single container returns array with one element') + t.pass() +}) diff --git a/tests/unit/routes/groups.routes.test.js b/tests/unit/routes/groups.routes.test.js new file mode 100644 index 0000000..c1c3682 --- /dev/null +++ b/tests/unit/routes/groups.routes.test.js @@ -0,0 +1,55 @@ +'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/groups.routes.js' + +test('groups routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'groups') + t.pass() +}) + +test('groups routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/miners/groups/stats'), 'should have miners groups stats route') + + t.pass() +}) + +test('groups 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('groups routes - schema integration', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.ok(route.schema, `route ${route.url} should have schema`) + t.ok(route.schema.querystring, `route ${route.url} should have querystring schema`) + t.ok(typeof route.schema.querystring === 'object', `route ${route.url} querystring should be object`) + }) + + t.pass() +}) + +test('groups routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'groups') + t.pass() +}) + +test('groups routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testOnRequestFunctions(t, routes, 'groups') + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 3600540..40ce362 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -149,6 +149,9 @@ const ENDPOINTS = { METRICS_CONTAINER_TELEMETRY: '/auth/metrics/containers/:id', METRICS_CONTAINER_HISTORY: '/auth/metrics/containers/:id/history', + // Groups endpoints + MINERS_GROUPS_STATS: '/auth/miners/groups/stats', + // Alerts endpoints ALERTS_SITE: '/auth/alerts/site', ALERTS_HISTORY: '/auth/alerts/history', @@ -270,6 +273,7 @@ const MINER_CATEGORIES = { } const LOG_KEYS = { + STAT_RTD: 'stat-rtd', STAT_3H: 'stat-3h', STAT_5M: 'stat-5m' } @@ -300,7 +304,14 @@ const AGGR_FIELDS = { OFFLINE_CNT: 'offline_cnt', SLEEP_CNT: 'power_mode_sleep_cnt', MAINTENANCE_CNT: 'maintenance_type_cnt', - CONTAINER_SPECIFIC_STATS: 'container_specific_stats_group_aggr' + CONTAINER_SPECIFIC_STATS: 'container_specific_stats_group_aggr', + HASHRATE_1M_CONTAINER_GROUP_SUM: 'hashrate_mhs_1m_container_group_sum_aggr', + POWER_W_CONTAINER_GROUP_SUM: 'power_w_container_group_sum_aggr', + POWER_MODE_LOW_CNT: 'power_mode_low_cnt', + POWER_MODE_NORMAL_CNT: 'power_mode_normal_cnt', + POWER_MODE_HIGH_CNT: 'power_mode_high_cnt', + ERROR_CNT: 'error_cnt', + NOT_MINING_CNT: 'not_mining_cnt' } const PERIOD_TYPES = { diff --git a/workers/lib/metrics.utils.js b/workers/lib/metrics.utils.js index 92f334b..ffe2dff 100644 --- a/workers/lib/metrics.utils.js +++ b/workers/lib/metrics.utils.js @@ -100,6 +100,13 @@ function getIntervalConfig (interval) { } } +function extractKeyEntry (orkResult, keyIndex) { + if (!Array.isArray(orkResult)) return null + const keyResult = orkResult[keyIndex] + if (!Array.isArray(keyResult) || keyResult.length === 0) return null + return keyResult[0] || null +} + module.exports = { parseEntryTs, validateStartEnd, @@ -107,6 +114,7 @@ module.exports = { forEachRangeAggrItem, sumObjectValues, extractContainerFromMinerKey, + extractKeyEntry, resolveInterval, getIntervalConfig } diff --git a/workers/lib/server/handlers/groups.handlers.js b/workers/lib/server/handlers/groups.handlers.js new file mode 100644 index 0000000..f530841 --- /dev/null +++ b/workers/lib/server/handlers/groups.handlers.js @@ -0,0 +1,92 @@ +'use strict' + +const { + LOG_KEYS, + WORKER_TYPES, + WORKER_TAGS, + AGGR_FIELDS +} = require('../../constants') +const { extractKeyEntry } = require('../../metrics.utils') +const { parseContainers } = require('../lib/queryUtils') + +function sumGroupedField (grouped, containers) { + if (!grouped || typeof grouped !== 'object') return 0 + let total = 0 + for (const id of containers) { + total += grouped[id] || 0 + } + return total +} + +async function getGroupStats (ctx, req) { + const containers = parseContainers(req) + if (!containers || !containers.length) { + throw new Error('ERR_MISSING_CONTAINERS') + } + + const tailLogPayload = { + keys: [ + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } + ], + limit: 1, + aggrFields: { + [AGGR_FIELDS.HASHRATE_1M_CONTAINER_GROUP_SUM]: 1, + [AGGR_FIELDS.POWER_W_CONTAINER_GROUP_SUM]: 1, + [AGGR_FIELDS.POWER_MODE_LOW_CNT]: 1, + [AGGR_FIELDS.POWER_MODE_NORMAL_CNT]: 1, + [AGGR_FIELDS.POWER_MODE_HIGH_CNT]: 1, + [AGGR_FIELDS.OFFLINE_CNT]: 1, + [AGGR_FIELDS.ERROR_CNT]: 1, + [AGGR_FIELDS.NOT_MINING_CNT]: 1, + [AGGR_FIELDS.SLEEP_CNT]: 1 + } + } + + const results = await ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload) + return composeGroupStats(results, containers) +} + +function composeGroupStats (results, containers) { + let hashrateMhs = 0 + let powerW = 0 + let onlineCount = 0 + let minerCount = 0 + + for (const orkResult of results) { + const minerEntry = extractKeyEntry(orkResult, 0) + if (!minerEntry) continue + + hashrateMhs += sumGroupedField(minerEntry[AGGR_FIELDS.HASHRATE_1M_CONTAINER_GROUP_SUM], containers) + powerW += sumGroupedField(minerEntry[AGGR_FIELDS.POWER_W_CONTAINER_GROUP_SUM], containers) + + const low = sumGroupedField(minerEntry[AGGR_FIELDS.POWER_MODE_LOW_CNT], containers) + const normal = sumGroupedField(minerEntry[AGGR_FIELDS.POWER_MODE_NORMAL_CNT], containers) + const high = sumGroupedField(minerEntry[AGGR_FIELDS.POWER_MODE_HIGH_CNT], containers) + const offline = sumGroupedField(minerEntry[AGGR_FIELDS.OFFLINE_CNT], containers) + const error = sumGroupedField(minerEntry[AGGR_FIELDS.ERROR_CNT], containers) + const notMining = sumGroupedField(minerEntry[AGGR_FIELDS.NOT_MINING_CNT], containers) + const sleep = sumGroupedField(minerEntry[AGGR_FIELDS.SLEEP_CNT], containers) + + onlineCount += low + normal + high + minerCount += low + normal + high + offline + error + notMining + sleep + } + + const hashrateThs = hashrateMhs / 1000000 + const efficiency = hashrateThs > 0 + ? Math.round((powerW / hashrateThs) * 10) / 10 + : 0 + + return { + efficiency, + hashrateMhs, + powerW, + minerCount, + onlineCount + } +} + +module.exports = { + getGroupStats, + composeGroupStats, + sumGroupedField +} diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index c7c5420..fa7dbdc 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -25,7 +25,6 @@ const { resolveInterval, getIntervalConfig } = require('../../metrics.utils') - async function getHashrate (ctx, req) { const { start, end } = validateStartEnd(req) diff --git a/workers/lib/server/handlers/site.handlers.js b/workers/lib/server/handlers/site.handlers.js index 3e46224..b6bb873 100644 --- a/workers/lib/server/handlers/site.handlers.js +++ b/workers/lib/server/handlers/site.handlers.js @@ -1,21 +1,6 @@ 'use strict' -/** - * Extracts the latest entry from a tail-log key result. - * tailLogMulti returns results per key in order. - * Each ork result is an array of key results, each key result is an array of entries. - * With limit=1, each key result has at most 1 entry. - * - * @param {Array} orkResult - Single ork's tailLogMulti response - * @param {number} keyIndex - Index of the key in the keys array - * @returns {Object|null} The latest entry for that key, or null - */ -function extractKeyEntry (orkResult, keyIndex) { - if (!Array.isArray(orkResult)) return null - const keyResult = orkResult[keyIndex] - if (!Array.isArray(keyResult) || keyResult.length === 0) return null - return keyResult[0] || null -} +const { extractKeyEntry } = require('../../metrics.utils') /** * Aggregates miner stats from tailLogMulti results across all orks. diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 9539e57..6440ed8 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -16,6 +16,7 @@ const devicesRoutes = require('./routes/devices.routes') const metricsRoutes = require('./routes/metrics.routes') const alertsRoutes = require('./routes/alerts.routes') const minersRoutes = require('./routes/miners.routes') +const groupsRoutes = require('./routes/groups.routes') /** * Collect all routes into a flat array for server injection. @@ -38,7 +39,8 @@ function routes (ctx) { ...devicesRoutes(ctx), ...metricsRoutes(ctx), ...alertsRoutes(ctx), - ...minersRoutes(ctx) + ...minersRoutes(ctx), + ...groupsRoutes(ctx) ] } diff --git a/workers/lib/server/lib/queryUtils.js b/workers/lib/server/lib/queryUtils.js index bc0b9ff..37280cd 100644 --- a/workers/lib/server/lib/queryUtils.js +++ b/workers/lib/server/lib/queryUtils.js @@ -172,6 +172,12 @@ function paginateResults (items, offset, limit) { } } +function parseContainers (req) { + const raw = req.query.containers + if (!raw) return undefined + return raw.split(',').map(c => c.trim()).filter(Boolean) +} + module.exports = { getNestedValue, mapFilterFields, @@ -179,5 +185,6 @@ module.exports = { buildSearchQuery, flattenOrkResults, sortItems, - paginateResults + paginateResults, + parseContainers } diff --git a/workers/lib/server/routes/groups.routes.js b/workers/lib/server/routes/groups.routes.js new file mode 100644 index 0000000..75d1fe5 --- /dev/null +++ b/workers/lib/server/routes/groups.routes.js @@ -0,0 +1,34 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_CAPS, + AUTH_LEVELS +} = require('../../constants') +const { getGroupStats } = require('../handlers/groups.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + const schemas = require('../schemas/groups.schemas.js') + + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.MINERS_GROUPS_STATS, + schema: { + querystring: schemas.query.groupsStats + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'miners/groups/stats', + req.query.containers + ], + ENDPOINTS.MINERS_GROUPS_STATS, + getGroupStats, + [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] + ) + } + ] +} diff --git a/workers/lib/server/schemas/groups.schemas.js b/workers/lib/server/schemas/groups.schemas.js new file mode 100644 index 0000000..62054e2 --- /dev/null +++ b/workers/lib/server/schemas/groups.schemas.js @@ -0,0 +1,16 @@ +'use strict' + +const schemas = { + query: { + groupsStats: { + type: 'object', + properties: { + containers: { type: 'string' }, + overwriteCache: { type: 'boolean' } + }, + required: ['containers'] + } + } +} + +module.exports = schemas From c38b5c6f8be1569005bbe5299416767e24388fb5 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:13:07 +0530 Subject: [PATCH 24/63] Add new auth/cooling system api (#51) * Add new auth/cooling system api * Fix review comments, make the tag generic for dcs device and update endpoint path * Fix * remove device name * fix --- config/common.json.example | 11 +- .../handlers/coolingSystem.handlers.test.js | 559 ++++++++++++++++ .../unit/routes/coolingSystem.routes.test.js | 63 ++ workers/lib/constants.js | 113 +++- .../server/handlers/coolingSystem.handlers.js | 616 ++++++++++++++++++ workers/lib/server/index.js | 4 +- .../lib/server/routes/coolingSystem.routes.js | 40 ++ 7 files changed, 1401 insertions(+), 5 deletions(-) create mode 100644 tests/unit/handlers/coolingSystem.handlers.test.js create mode 100644 tests/unit/routes/coolingSystem.routes.test.js create mode 100644 workers/lib/server/handlers/coolingSystem.handlers.js create mode 100644 workers/lib/server/routes/coolingSystem.routes.js diff --git a/config/common.json.example b/config/common.json.example index da9c9ea..ee7f2a1 100644 --- a/config/common.json.example +++ b/config/common.json.example @@ -15,7 +15,8 @@ "/auth/actions/:type/:id": "30s", "/auth/global/data": "30s", "/auth/site/status/live": "15s", - "/auth/miners": "15s" + "/auth/miners": "15s", + "/auth/dcs/cooling-system": "15s" }, "featureConfig": { "comments": true, @@ -32,7 +33,13 @@ "showMinerConsumptionDashboard": false, "totalSystemConsumptionHeader": false, "energyProvision": true, - "admeStats": true + "admeStats": true, + "alertsHistoricalLogEnabled": true, + "isReportingEnabled": true, + "centralDCSSetup": { + "enabled": false, + "tag": "t-dcs" + } }, "ttl": 300, "site": "SITE_NAME", diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js new file mode 100644 index 0000000..97e7a37 --- /dev/null +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -0,0 +1,559 @@ +'use strict' + +const test = require('brittle') +const { + getCoolingSystemData, + isCentralDCSEnabled, + getDCSTag, + getFieldProjection, + extractDcsThing, + buildCoolingViewData, + buildMinersCircuit1View, + buildMinersCircuit2View, + buildHvacCircuit1View, + buildHvacAmbientView +} = require('../../../workers/lib/server/handlers/coolingSystem.handlers') +const { COOLING_SYSTEM_PROJECTIONS } = require('../../../workers/lib/constants') + +// Sample enriched equipment data (as provided by the DCS worker) +// All data includes units - app-node is completely agnostic +const createMockEquipment = () => ({ + pumps: [ + { equipment: 'B-7513', circuit: 'MINER_LOOP', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 42.3, unit: 'A' }, Trip: false }, + { equipment: 'B-7514', circuit: 'MINER_LOOP', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 41.8, unit: 'A' }, Trip: false }, + { equipment: 'B-7515', circuit: 'MINER_LOOP', status: 'Standby', FbkRunOut: false, speed: { value: 0, unit: '%' }, current: { value: 0, unit: 'A' }, Trip: false }, + { equipment: 'B-7516', circuit: 'COOLING_TOWER', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 48.1, unit: 'A' }, Trip: false }, + { equipment: 'B-7517', circuit: 'COOLING_TOWER', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 47.6, unit: 'A' }, Trip: false }, + { equipment: 'B-7501', circuit: 'HVAC_RETURN', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 15.2, unit: 'A' }, Trip: false }, + { equipment: 'B-7502', circuit: 'HVAC_SUPPLY', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 14.8, unit: 'A' }, Trip: false }, + { equipment: 'B-7503', circuit: 'HVAC_CONDENSER', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 22.1, unit: 'A' }, Trip: false } + ], + temperatures: [ + { equipment: 'TS-7513', value: 37.1, unit: '°C' }, + { equipment: 'TS-7514', value: 37.3, unit: '°C' }, + { equipment: 'TS-7515', value: 44.6, unit: '°C' }, + { equipment: 'TS-7516', value: 45.0, unit: '°C' }, + { equipment: 'TS-7521', value: 37.0, unit: '°C' }, + { equipment: 'TS-7522', value: 37.1, unit: '°C' }, + { equipment: 'TS-7501', value: 7.2, unit: '°C' }, + { equipment: 'TS-7502', value: 13.8, unit: '°C' } + ], + pressures: [ + { equipment: 'PIT-7502', value: 2.9, unit: 'bar' }, + { equipment: 'PIT-7503', value: 1.4, unit: 'bar' }, + { equipment: 'PIT-7504', value: 2.7, unit: 'bar' }, + { equipment: 'PIT-7505', value: 1.3, unit: 'bar' }, + { equipment: 'PIT-7501', value: 3.1, unit: 'bar' } + ], + flows: [ + { equipment: 'FIT-7513', value: 192.4, unit: 'm³/h' }, + { equipment: 'FIT-7514', value: 191.6, unit: 'm³/h' }, + { equipment: 'FIT-7501', value: 35.3, unit: 'm³/h' }, + { equipment: 'FIT-7502', value: 35.3, unit: 'm³/h' } + ], + levels: [ + { equipment: 'LIT-7501', value: 88, unit: '%' }, + { equipment: 'LIT-7502', value: 82, unit: '%' }, + { equipment: 'LIT-7503', value: 76, unit: '%' } + ], + heat_exchangers: [ + { equipment: 'TC-7502', is_active: true, miner_side_out_temp: { value: 37.0, unit: '°C' }, tower_side_in_temp: { value: 29.1, unit: '°C' }, tower_side_out_temp: { value: 36.8, unit: '°C' }, tcv_position: { value: 45, unit: '%' } }, + { equipment: 'TC-7501', is_active: true, miner_side_out_temp: { value: 37.1, unit: '°C' }, tower_side_in_temp: { value: 29.2, unit: '°C' }, tower_side_out_temp: { value: 36.9, unit: '°C' }, tcv_position: { value: 55, unit: '%' } } + ], + cooling_towers: [ + { equipment: 'TR-7501', is_running: true, fan_status: 'Running', fan_power: { value: 60, unit: 'kW' }, level: { value: 82, unit: '%' }, vibration: { value: 0.8, unit: 'mm/s', status: 'Normal' } }, + { equipment: 'TR-7502', is_running: true, fan_status: 'Running', fan_power: { value: 45, unit: 'kW' }, level: { value: 85, unit: '%' }, vibration: { value: 0.6, unit: 'mm/s', status: 'Normal' } } + ], + valves: [ + { equipment: 'PCV-7502', position: { value: 12, unit: '%' } }, + { equipment: 'PCV-7501', position: { value: 15, unit: '%' } }, + { equipment: 'TCV-7501', position: { value: 55, unit: '%' } }, + { equipment: 'TCV-7502', position: { value: 45, unit: '%' } }, + { equipment: 'LCV-7501', position: { value: 25, unit: '%' } }, + { equipment: 'LCV-7502', position: { value: 0, unit: '%' } } + ], + tanks: [ + { equipment: 'TQ-7501' }, + { equipment: 'TQ-7502' } + ], + chillers: [ + { equipment: 'CH-7501', is_running: true, mode: 'Auto', cooling_capacity: { value: 275, unit: 'kW' }, power_consumption: { value: 180, unit: 'kW' }, evaporator_temp: { value: 7.2, unit: '°C' }, condenser_temp: { value: 13.8, unit: '°C' } } + ], + fan_coils: [ + { equipment: 'FC-7513', is_running: true, temperature: { value: 27.5, unit: '°C' }, valve_position: { value: 45, unit: '%' } }, + { equipment: 'FC-7514', is_running: true, temperature: { value: 27.3, unit: '°C' }, valve_position: { value: 42, unit: '%' } }, + { equipment: 'FC-7529', is_running: true, temperature: { value: 27.8, unit: '°C' }, valve_position: { value: 48, unit: '%' } }, + { equipment: 'FC-7530', is_running: false, temperature: { value: 28.1, unit: '°C' }, valve_position: { value: 0, unit: '%' } } + ], + humidity_sensors: [ + { equipment: 'HT-7501', value: 42.5, unit: '%' }, + { equipment: 'HT-7502', value: 43.2, unit: '%' }, + { equipment: 'HT-7503', value: 41.8, unit: '%' }, + { equipment: 'HT-7504', value: 44.1, unit: '%' }, + { equipment: 'HT-7505', value: 55.1, unit: '%' } + ], + vibration_sensors: [ + { equipment: 'VT-7501', value: 0.6, unit: 'mm/s', status: 'Normal' }, + { equipment: 'VT-7503', value: 0.8, unit: 'mm/s', status: 'Normal' } + ], + flow_switches: [ + { equipment: 'FS-7501', is_active: true }, + { equipment: 'FS-7502', is_active: true } + ] +}) + +// Sample config data (site-specific cooling system configuration) +// All labels, defaults, and metadata come from config +const createMockConfig = () => ({ + cooling_system: { + miner_loop: { + name: 'Circuit 1 - Miner Loop', + description: 'Cooling Water', + water_type: 'Cooling Water', + defaults: { + supply_temp: { value: 37, unit: '°C' }, + return_temp: { value: 47, unit: '°C' } + }, + line1: { + name: 'LINE 1', + groups: 'Groups 1-8', + supply_temp_sensor: 'TS-7513', + return_temp_sensor: 'TS-7515', + supply_pressure_sensor: 'PIT-7502', + return_pressure_sensor: 'PIT-7503', + supply_flow_sensor: 'FIT-7513' + }, + line2: { + name: 'LINE 2', + groups: 'Groups 9-16', + supply_temp_sensor: 'TS-7514', + return_temp_sensor: 'TS-7516', + supply_pressure_sensor: 'PIT-7504', + return_pressure_sensor: 'PIT-7505', + supply_flow_sensor: 'FIT-7514' + }, + control_valves: { + pressure_bypass: 'PCV-7502' + } + }, + cooling_tower_loop: { + name: 'Circuit 2 - Cooling Tower Loop', + description: 'Filtered Water', + water_type: 'Filtered Water', + makeup: { + tank: 'TQ-7501', + level_sensor: 'LIT-7503', + level_control_valve: 'LCV-7502' + } + }, + hvac_chilled_water: { + name: 'Circuit 1 - Chilled Water Loop', + defaults: { + supply_temp: { value: 7, unit: '°C' }, + return_temp: { value: 14, unit: '°C' } + }, + supply_return: { + supply_temp_sensor: 'TS-7501', + return_temp_sensor: 'TS-7502', + supply_flow_sensor: 'FIT-7501', + return_flow_sensor: 'FIT-7502', + pressure_sensor: 'PIT-7501' + }, + buffer_tank: { + tank: 'TQ-7502', + level_sensor: 'LIT-7501', + makeup_valve: 'LCV-7501' + }, + control_valves: { + pressure_bypass: 'PCV-7501' + } + }, + hvac_condenser: { + name: 'Circuit 2 - Condenser Water Loop', + defaults: { + supply_temp: { value: 29, unit: '°C' }, + return_temp: { value: 39, unit: '°C' } + } + }, + ambient: { + rooms: [ + { name: 'Miner Room 1', fan_coils: ['FC-7513', 'FC-7514'], humidity_sensors: ['HT-7501'] }, + { name: 'Miner Room 2', fan_coils: ['FC-7529', 'FC-7530'], humidity_sensors: ['HT-7502'] } + ], + ambient_sensors: ['HT-7505'] + }, + view_metadata: { + miners: { + layout: { title: 'Miners Cooling Layout', description: 'Complete cooling system overview' } + }, + hvac: { + layout: { title: 'HVAC Cooling Layout', description: 'Complete HVAC cooling system overview' }, + ambient: { title: 'Ambient Conditions', description: 'Room temperatures and humidity levels' } + } + } + } +}) + +// Sample snap data with enriched equipment data +const createMockSnapData = () => ({ + success: true, + stats: { + dcs_specific: { + equipment: createMockEquipment() + }, + flow: { + miner_loop: { value: 384, unit: 'm³/h' }, + cooling_tower: { value: 800, unit: 'm³/h' } + }, + humidity: { + avg: 45.3, + min: 41.8, + max: 55.1 + } + }, + config: createMockConfig() +}) + +function createMockCtx (featureEnabled = true, customDcsResponse = null) { + const snapData = createMockSnapData() + const defaultResponse = [{ + id: 'dcs-1', + type: 'dcs', + tags: ['t-dcs'], + last: { snap: snapData } + }] + + const featureConfig = { + centralDCSSetup: { + enabled: featureEnabled, + tag: 't-dcs' + } + } + + return { + conf: { + featureConfig, + orks: [{ rpcPublicKey: 'key1' }] + }, + net_r0: { + jRequest: async () => { + return customDcsResponse !== null ? customDcsResponse : defaultResponse + } + } + } +} + +// Feature flag tests +test('isCentralDCSEnabled - returns true with new config', (t) => { + const ctx = { conf: { featureConfig: { centralDCSSetup: { enabled: true } } } } + t.is(isCentralDCSEnabled(ctx), true) + t.pass() +}) + +test('isCentralDCSEnabled - returns true with legacy config', (t) => { + const ctx = { conf: { featureConfig: { isCentralPCS7Setup: true } } } + t.is(isCentralDCSEnabled(ctx), true) + t.pass() +}) + +test('isCentralDCSEnabled - returns false when disabled', (t) => { + const ctx = { conf: { featureConfig: { centralDCSSetup: { enabled: false } } } } + t.is(isCentralDCSEnabled(ctx), false) + t.pass() +}) + +// DCS Tag tests +test('getDCSTag - returns configured tag', (t) => { + const ctx = { conf: { featureConfig: { centralDCSSetup: { tag: 't-custom-dcs' } } } } + t.is(getDCSTag(ctx), 't-custom-dcs') + t.pass() +}) + +test('getDCSTag - returns default tag when not configured', (t) => { + const ctx = { conf: { featureConfig: {} } } + t.is(getDCSTag(ctx), 't-dcs') + t.pass() +}) + +// Field projection tests +test('getFieldProjection - returns correct projection for miners/circuit1', (t) => { + const projection = getFieldProjection('miners', 'circuit1') + t.ok(projection.id, 'should have base field id') + t.ok(projection['last.snap.stats.dcs_specific.equipment.pumps'], 'should have pumps projection') + t.ok(projection['last.snap.stats.dcs_specific.equipment.temperatures'], 'should have temperatures projection') + t.ok(projection['last.snap.config.cooling_system'], 'should have config projection') + t.pass() +}) + +test('getFieldProjection - returns correct projection for hvac/ambient', (t) => { + const projection = getFieldProjection('hvac', 'ambient') + t.ok(projection.id, 'should have base field id') + t.ok(projection['last.snap.stats.dcs_specific.equipment.fan_coils'], 'should have fan_coils projection') + t.ok(projection['last.snap.stats.dcs_specific.equipment.humidity_sensors'], 'should have humidity_sensors projection') + t.pass() +}) + +test('COOLING_SYSTEM_PROJECTIONS - has correct structure', (t) => { + t.ok(COOLING_SYSTEM_PROJECTIONS.base, 'should have base projection') + t.ok(COOLING_SYSTEM_PROJECTIONS.miners, 'should have miners projections') + t.ok(COOLING_SYSTEM_PROJECTIONS.hvac, 'should have hvac projections') + t.ok(COOLING_SYSTEM_PROJECTIONS.miners.circuit1, 'should have miners.circuit1') + t.ok(COOLING_SYSTEM_PROJECTIONS.miners.circuit2, 'should have miners.circuit2') + t.ok(COOLING_SYSTEM_PROJECTIONS.miners.layout, 'should have miners.layout') + t.ok(COOLING_SYSTEM_PROJECTIONS.hvac.circuit1, 'should have hvac.circuit1') + t.ok(COOLING_SYSTEM_PROJECTIONS.hvac.circuit2, 'should have hvac.circuit2') + t.ok(COOLING_SYSTEM_PROJECTIONS.hvac.layout, 'should have hvac.layout') + t.ok(COOLING_SYSTEM_PROJECTIONS.hvac.ambient, 'should have hvac.ambient') + t.pass() +}) + +// Extract DCS thing tests +test('extractDcsThing - extracts thing from valid results', (t) => { + const snapData = createMockSnapData() + const rpcResults = [[{ + id: 'dcs-1', + type: 'dcs', + tags: ['t-dcs'], + last: { snap: snapData } + }]] + + const thing = extractDcsThing(rpcResults) + t.ok(thing, 'should extract thing') + t.is(thing.type, 'dcs', 'should have correct type') + t.ok(thing.last.snap, 'should have snap data') + t.pass() +}) + +test('extractDcsThing - returns null for empty results', (t) => { + const thing = extractDcsThing([]) + t.is(thing, null) + t.pass() +}) + +test('extractDcsThing - returns null for non-array', (t) => { + const thing = extractDcsThing(null) + t.is(thing, null) + t.pass() +}) + +// View builder tests +test('buildMinersCircuit1View - builds view from enriched equipment', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.ok(view, 'should return view') + t.is(view.title, 'Circuit 1 - Miner Loop', 'title from config') + t.is(view.water_type, 'Cooling Water', 'water_type from config') + t.ok(view.lines, 'should have lines') + t.is(view.lines.length, 2, 'should have 2 lines') + t.ok(view.pumps, 'should have pumps') + t.is(view.pumps.length, 3, 'should have 3 miner loop pumps') + // Check enriched data with units + t.ok(view.pumps[0].speed.unit, 'pump speed should have unit') + t.ok(view.pumps[0].current.unit, 'pump current should have unit') + t.pass() +}) + +test('buildMinersCircuit2View - builds view from enriched equipment', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.ok(view, 'should return view') + t.is(view.title, 'Circuit 2 - Cooling Tower Loop', 'title from config') + t.ok(view.cooling_towers, 'should have cooling_towers') + t.ok(view.makeup_tank, 'should have makeup_tank') + t.ok(view.heat_exchanger_temps, 'should have heat_exchanger_temps') + // Check enriched data with units + t.ok(view.cooling_towers[0].fan_power.unit, 'fan_power should have unit') + t.ok(view.cooling_towers[0].level.unit, 'level should have unit') + t.pass() +}) + +test('buildHvacCircuit1View - builds view from enriched equipment', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit1View(equipment, config) + + t.ok(view, 'should return view') + t.is(view.title, 'Circuit 1 - Chilled Water Loop', 'title from config') + t.ok(view.chiller, 'should have chiller') + t.is(view.chiller.is_running, true, 'chiller should be running') + // Check enriched data with units + t.ok(view.chiller.cooling_capacity.unit, 'cooling_capacity should have unit') + t.ok(view.chiller.evaporator_temp.unit, 'evaporator_temp should have unit') + t.pass() +}) + +test('buildHvacAmbientView - builds view from enriched equipment', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const stats = { humidity: { avg: 45.3 } } + const view = buildHvacAmbientView(equipment, config, stats) + + t.ok(view, 'should return view') + t.ok(view.rooms, 'should have rooms') + t.is(view.rooms.length, 2, 'should have 2 rooms from config') + t.is(view.rooms[0].name, 'Miner Room 1', 'room name from config') + // Check fan coil enriched data + t.ok(view.rooms[0].fan_coils[0].temperature.unit, 'fan coil temp should have unit') + t.pass() +}) + +// buildCoolingViewData tests +test('buildCoolingViewData - returns miners circuit1 data', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'miners', 'circuit1') + + t.ok(data, 'should return data') + t.ok(data.title, 'should have title') + t.ok(data.lines, 'should have lines') + t.ok(data.pumps, 'should have pumps') + t.pass() +}) + +test('buildCoolingViewData - returns miners circuit2 data', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'miners', 'circuit2') + + t.ok(data, 'should return data') + t.ok(data.cooling_towers, 'should have cooling_towers') + t.ok(data.makeup_tank, 'should have makeup_tank') + t.pass() +}) + +test('buildCoolingViewData - returns hvac circuit1 data', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'hvac', 'circuit1') + + t.ok(data, 'should return data') + t.ok(data.chiller, 'should have chiller') + t.ok(data.supply_return, 'should have supply_return') + t.pass() +}) + +test('buildCoolingViewData - returns hvac ambient data', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'hvac', 'ambient') + + t.ok(data, 'should return data') + t.ok(data.rooms, 'should have rooms') + t.is(data.rooms.length, 2, 'should have 2 rooms') + t.pass() +}) + +test('buildCoolingViewData - returns null for invalid type', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'invalid', 'circuit1') + + t.is(data, null, 'should return null') + t.pass() +}) + +// getCoolingSystemData integration tests +test('getCoolingSystemData - returns miners circuit1 data', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'miners', view: 'circuit1' } } + + const result = await getCoolingSystemData(ctx, req) + + t.is(result.type, 'miners', 'type should be miners') + t.is(result.view, 'circuit1', 'view should be circuit1') + t.ok(result.data, 'should have data') + t.ok(result.data.title, 'should have title from config') + t.ok(result.data.lines, 'should have lines') + t.ok(result.data.pumps, 'should have pumps') + t.pass() +}) + +test('getCoolingSystemData - returns miners circuit2 data', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'miners', view: 'circuit2' } } + + const result = await getCoolingSystemData(ctx, req) + + t.is(result.type, 'miners', 'type should be miners') + t.is(result.view, 'circuit2', 'view should be circuit2') + t.ok(result.data, 'should have data') + t.ok(result.data.cooling_towers, 'should have cooling_towers') + t.pass() +}) + +test('getCoolingSystemData - returns hvac circuit1 data', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'hvac', view: 'circuit1' } } + + const result = await getCoolingSystemData(ctx, req) + + t.is(result.type, 'hvac', 'type should be hvac') + t.is(result.view, 'circuit1', 'view should be circuit1') + t.ok(result.data, 'should have data') + t.ok(result.data.chiller, 'should have chiller') + t.pass() +}) + +test('getCoolingSystemData - returns hvac ambient data', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'hvac', view: 'ambient' } } + + const result = await getCoolingSystemData(ctx, req) + + t.is(result.type, 'hvac', 'type should be hvac') + t.is(result.view, 'ambient', 'view should be ambient') + t.ok(result.data, 'should have data') + t.ok(result.data.rooms, 'should have rooms') + t.pass() +}) + +test('getCoolingSystemData - throws error when feature disabled', async (t) => { + const ctx = createMockCtx(false) + const req = { query: { type: 'miners', view: 'circuit1' } } + + try { + await getCoolingSystemData(ctx, req) + t.fail('should throw error') + } catch (err) { + t.is(err.message, 'ERR_FEATURE_NOT_ENABLED', 'should throw ERR_FEATURE_NOT_ENABLED') + } + t.pass() +}) + +test('getCoolingSystemData - throws error for invalid type', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'invalid', view: 'circuit1' } } + + try { + await getCoolingSystemData(ctx, req) + t.fail('should throw error') + } catch (err) { + t.is(err.message, 'ERR_INVALID_TYPE', 'should throw ERR_INVALID_TYPE') + } + t.pass() +}) + +test('getCoolingSystemData - throws error for invalid view', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'miners', view: 'invalid' } } + + try { + await getCoolingSystemData(ctx, req) + t.fail('should throw error') + } catch (err) { + t.is(err.message, 'ERR_INVALID_VIEW', 'should throw ERR_INVALID_VIEW') + } + t.pass() +}) + +test('getCoolingSystemData - throws error when DCS data not found', async (t) => { + const ctx = createMockCtx(true, []) + const req = { query: { type: 'miners', view: 'circuit1' } } + + try { + await getCoolingSystemData(ctx, req) + t.fail('should throw error') + } catch (err) { + t.is(err.message, 'ERR_DCS_DATA_NOT_FOUND', 'should throw ERR_DCS_DATA_NOT_FOUND') + } + t.pass() +}) diff --git a/tests/unit/routes/coolingSystem.routes.test.js b/tests/unit/routes/coolingSystem.routes.test.js new file mode 100644 index 0000000..b4b3a74 --- /dev/null +++ b/tests/unit/routes/coolingSystem.routes.test.js @@ -0,0 +1,63 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +test('coolingSystem routes - module structure', (t) => { + testModuleStructure(t, '../../../workers/lib/server/routes/coolingSystem.routes.js', 'coolingSystem') + t.pass() +}) + +test('coolingSystem routes - route definitions', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/cooling-system'), 'should have cooling-system route') + + t.pass() +}) + +test('coolingSystem routes - HTTP methods', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + + const coolingSystemRoute = routes.find(r => r.url === '/auth/cooling-system') + t.is(coolingSystemRoute.method, 'GET', 'cooling-system route should be GET') + + t.pass() +}) + +test('coolingSystem routes - schema validation', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + + const coolingSystemRoute = routes.find(r => r.url === '/auth/cooling-system') + t.ok(coolingSystemRoute.schema, 'cooling-system route should have schema') + t.ok(coolingSystemRoute.schema.querystring, 'should have querystring schema') + t.ok(coolingSystemRoute.schema.querystring.required.includes('type'), 'type should be required') + t.ok(coolingSystemRoute.schema.querystring.required.includes('view'), 'view should be required') + t.ok(coolingSystemRoute.schema.querystring.properties.type.enum.includes('miners'), 'type enum should include miners') + t.ok(coolingSystemRoute.schema.querystring.properties.type.enum.includes('hvac'), 'type enum should include hvac') + t.ok(coolingSystemRoute.schema.querystring.properties.view.enum.includes('circuit1'), 'view enum should include circuit1') + t.ok(coolingSystemRoute.schema.querystring.properties.view.enum.includes('circuit2'), 'view enum should include circuit2') + t.ok(coolingSystemRoute.schema.querystring.properties.view.enum.includes('layout'), 'view enum should include layout') + t.ok(coolingSystemRoute.schema.querystring.properties.view.enum.includes('ambient'), 'view enum should include ambient') + t.is(coolingSystemRoute.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + + t.pass() +}) + +test('coolingSystem routes - handler functions', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + testHandlerFunctions(t, routes, 'coolingSystem') + t.pass() +}) + +test('coolingSystem routes - onRequest functions', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + + routes.forEach(route => { + t.ok(typeof route.onRequest === 'function', `coolingSystem route ${route.url} should have onRequest function`) + }) + + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 40ce362..0109c1c 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -156,7 +156,9 @@ const ENDPOINTS = { ALERTS_SITE: '/auth/alerts/site', ALERTS_HISTORY: '/auth/alerts/history', - MINERS: '/auth/miners' + MINERS: '/auth/miners', + // Cooling System endpoints + COOLING_SYSTEM: '/auth/dcs/cooling-system' } const HTTP_METHODS = { @@ -289,6 +291,112 @@ const DEVICE_LIST_FIELDS = { id: 1, type: 1, code: 1, ip: 1, tags: 1, info: 1, rack: 1 } +// Cooling system field projections by type/view +const COOLING_SYSTEM_PROJECTIONS = { + base: { id: 1, code: 1, type: 1, tags: 1, rack: 1 }, + equipment: { + pumps: { 'last.snap.stats.dcs_specific.equipment.pumps': 1 }, + temperatures: { 'last.snap.stats.dcs_specific.equipment.temperatures': 1 }, + pressures: { 'last.snap.stats.dcs_specific.equipment.pressures': 1 }, + flows: { 'last.snap.stats.dcs_specific.equipment.flows': 1 }, + levels: { 'last.snap.stats.dcs_specific.equipment.levels': 1 }, + valves: { 'last.snap.stats.dcs_specific.equipment.valves': 1 }, + heat_exchangers: { 'last.snap.stats.dcs_specific.equipment.heat_exchangers': 1 }, + cooling_towers: { 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1 }, + tanks: { 'last.snap.stats.dcs_specific.equipment.tanks': 1 }, + chillers: { 'last.snap.stats.dcs_specific.equipment.chillers': 1 }, + fan_coils: { 'last.snap.stats.dcs_specific.equipment.fan_coils': 1 }, + humidity_sensors: { 'last.snap.stats.dcs_specific.equipment.humidity_sensors': 1 }, + vibration_sensors: { 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1 }, + flow_switches: { 'last.snap.stats.dcs_specific.equipment.flow_switches': 1 } + }, + config: { 'last.snap.config': 1 }, + stats: { + flow: { 'last.snap.stats.flow': 1 }, + temperature: { 'last.snap.stats.temperature': 1 }, + humidity: { 'last.snap.stats.humidity': 1 } + }, + miners: { + circuit1: { + 'last.snap.stats.dcs_specific.equipment.pumps': 1, + 'last.snap.stats.dcs_specific.equipment.temperatures': 1, + 'last.snap.stats.dcs_specific.equipment.pressures': 1, + 'last.snap.stats.dcs_specific.equipment.flows': 1, + 'last.snap.stats.dcs_specific.equipment.heat_exchangers': 1, + 'last.snap.stats.dcs_specific.equipment.valves': 1, + 'last.snap.config.cooling_system': 1 + }, + circuit2: { + 'last.snap.stats.dcs_specific.equipment.pumps': 1, + 'last.snap.stats.dcs_specific.equipment.temperatures': 1, + 'last.snap.stats.dcs_specific.equipment.levels': 1, + 'last.snap.stats.dcs_specific.equipment.heat_exchangers': 1, + 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1, + 'last.snap.stats.dcs_specific.equipment.valves': 1, + 'last.snap.stats.dcs_specific.equipment.tanks': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.config.cooling_system': 1 + }, + layout: { + 'last.snap.stats.dcs_specific.equipment.pumps': 1, + 'last.snap.stats.dcs_specific.equipment.temperatures': 1, + 'last.snap.stats.dcs_specific.equipment.pressures': 1, + 'last.snap.stats.dcs_specific.equipment.flows': 1, + 'last.snap.stats.dcs_specific.equipment.levels': 1, + 'last.snap.stats.dcs_specific.equipment.heat_exchangers': 1, + 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1, + 'last.snap.stats.dcs_specific.equipment.valves': 1, + 'last.snap.stats.dcs_specific.equipment.tanks': 1, + 'last.snap.stats.flow': 1, + 'last.snap.config.cooling_system': 1 + } + }, + hvac: { + circuit1: { + 'last.snap.stats.dcs_specific.equipment.pumps': 1, + 'last.snap.stats.dcs_specific.equipment.temperatures': 1, + 'last.snap.stats.dcs_specific.equipment.pressures': 1, + 'last.snap.stats.dcs_specific.equipment.flows': 1, + 'last.snap.stats.dcs_specific.equipment.levels': 1, + 'last.snap.stats.dcs_specific.equipment.chillers': 1, + 'last.snap.stats.dcs_specific.equipment.fan_coils': 1, + 'last.snap.stats.dcs_specific.equipment.valves': 1, + 'last.snap.stats.dcs_specific.equipment.tanks': 1, + 'last.snap.stats.dcs_specific.equipment.flow_switches': 1, + 'last.snap.config.cooling_system': 1 + }, + circuit2: { + 'last.snap.stats.dcs_specific.equipment.pumps': 1, + 'last.snap.stats.dcs_specific.equipment.temperatures': 1, + 'last.snap.stats.dcs_specific.equipment.flows': 1, + 'last.snap.stats.dcs_specific.equipment.levels': 1, + 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.config.cooling_system': 1 + }, + layout: { + 'last.snap.stats.dcs_specific.equipment.pumps': 1, + 'last.snap.stats.dcs_specific.equipment.temperatures': 1, + 'last.snap.stats.dcs_specific.equipment.pressures': 1, + 'last.snap.stats.dcs_specific.equipment.flows': 1, + 'last.snap.stats.dcs_specific.equipment.levels': 1, + 'last.snap.stats.dcs_specific.equipment.chillers': 1, + 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1, + 'last.snap.stats.dcs_specific.equipment.fan_coils': 1, + 'last.snap.stats.dcs_specific.equipment.valves': 1, + 'last.snap.stats.dcs_specific.equipment.tanks': 1, + 'last.snap.stats.dcs_specific.equipment.flow_switches': 1, + 'last.snap.config.cooling_system': 1 + }, + ambient: { + 'last.snap.stats.dcs_specific.equipment.fan_coils': 1, + 'last.snap.stats.dcs_specific.equipment.humidity_sensors': 1, + 'last.snap.stats.humidity': 1, + 'last.snap.config.cooling_system': 1 + } + } +} + const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', SITE_POWER: 'site_power_w', @@ -479,5 +587,6 @@ module.exports = { MINER_SEARCH_FIELDS, MINER_DEFAULT_FIELDS, MINER_MAX_LIMIT, - MINER_DEFAULT_LIMIT + MINER_DEFAULT_LIMIT, + COOLING_SYSTEM_PROJECTIONS } diff --git a/workers/lib/server/handlers/coolingSystem.handlers.js b/workers/lib/server/handlers/coolingSystem.handlers.js new file mode 100644 index 0000000..be9ed30 --- /dev/null +++ b/workers/lib/server/handlers/coolingSystem.handlers.js @@ -0,0 +1,616 @@ +'use strict' + +const { COOLING_SYSTEM_PROJECTIONS } = require('../../constants') + +const DCS_TAG_DEFAULT = 't-dcs' + +function isCentralDCSEnabled (ctx) { + if (ctx.conf?.featureConfig?.centralDCSSetup?.enabled === true) return true + return false +} + +function getDCSTag (ctx) { + return ctx.conf?.featureConfig?.centralDCSSetup?.tag || DCS_TAG_DEFAULT +} + +function getFieldProjection (type, view) { + const base = COOLING_SYSTEM_PROJECTIONS.base + const typeProjections = COOLING_SYSTEM_PROJECTIONS[type] + const viewProjection = typeProjections?.[view] || {} + return { ...base, ...viewProjection } +} + +function extractDcsThing (rpcResults) { + if (!Array.isArray(rpcResults)) return null + + for (const orkResult of rpcResults) { + if (!Array.isArray(orkResult)) continue + for (const thing of orkResult) { + if (thing && thing?.type && thing.type.includes('dcs') && thing?.last?.snap) { + return thing + } + } + } + return null +} + +function getSensorReading (sensors, sensorId, defaultConfig = null) { + if (!sensorId) return defaultConfig + const sensor = sensors?.find(s => s.equipment === sensorId) + if (sensor?.value != null) { + return { value: sensor.value, unit: sensor.unit } + } + return defaultConfig +} + +function filterPumpsByCircuit (pumps, circuit) { + return (pumps || []).filter(p => p.circuit === circuit) +} + +function formatPump (pump) { + return { + id: pump.equipment, + name: pump.equipment, + status: pump.status, + is_running: pump.FbkRunOut || false, + speed: pump.speed, + current: pump.current, + has_fault: pump.Trip || false + } +} + +function buildMinersCircuit1View (equipment, config) { + const pumps = equipment.pumps + const temperatures = equipment.temperatures + const pressures = equipment.pressures + const flows = equipment.flows + const heatExchangers = equipment.heat_exchangers + const valves = equipment.valves + const coolingConfig = config?.cooling_system?.miner_loop || {} + const viewConfig = config?.cooling_system?.view_metadata?.miners?.circuit1 || {} + + const findHx = (hxId) => (heatExchangers || []).find(hx => hx.equipment === hxId) + + const lines = [] + const lineConfigs = [coolingConfig.line1, coolingConfig.line2].filter(Boolean) + + for (const lineConfig of lineConfigs) { + const hx = findHx(lineConfig.heat_exchanger) + + lines.push({ + name: lineConfig.name, + groups: lineConfig.groups, + supply: { + temperature: hx?.miner_side_out_temp || getSensorReading(temperatures, lineConfig.supply_temp_sensor, coolingConfig.defaults?.supply_temp), + pressure: getSensorReading(pressures, lineConfig.supply_pressure_sensor), + flow: getSensorReading(flows, lineConfig.supply_flow_sensor) + }, + return: { + temperature: getSensorReading(temperatures, lineConfig.return_temp_sensor, coolingConfig.defaults?.return_temp), + pressure: getSensorReading(pressures, lineConfig.return_pressure_sensor) + }, + heat_exchanger: hx + ? { + id: hx.equipment, + name: hx.equipment, + is_active: hx.is_active, + miner_side_out_temp: hx.miner_side_out_temp, + tower_side_in_temp: hx.tower_side_in_temp, + tower_side_out_temp: hx.tower_side_out_temp, + control_valve: { + id: hx.tcv_id || lineConfig.control_valve, + position: hx.tcv_position + } + } + : null + }) + } + + const bypassValveId = coolingConfig.control_valves?.pressure_bypass + const bypassValve = valves?.find(v => v.equipment === bypassValveId) + const controlValves = bypassValveId + ? { + pressure_bypass: { + id: bypassValveId, + position: bypassValve?.position + } + } + : null + + const minerPumps = filterPumpsByCircuit(pumps, 'MINER_LOOP').map(formatPump) + + return { + title: coolingConfig.name || viewConfig.title, + description: coolingConfig.description || viewConfig.description, + water_type: coolingConfig.water_type || viewConfig.water_type, + target_supply_temp: coolingConfig.defaults?.supply_temp, + target_return_temp: coolingConfig.defaults?.return_temp, + lines, + control_valves: controlValves, + pumps: minerPumps + } +} + +function buildMinersCircuit2View (equipment, config) { + const pumps = equipment.pumps + const levels = equipment.levels + const heatExchangers = equipment.heat_exchangers + const coolingTowers = equipment.cooling_towers + const valves = equipment.valves + const tanks = equipment.tanks + const towerConfig = config?.cooling_system?.cooling_tower_loop || {} + const viewConfig = config?.cooling_system?.view_metadata?.miners?.circuit2 || {} + + const towerData = (coolingTowers || []).map(ct => ({ + id: ct.equipment, + name: ct.equipment, + is_running: ct.is_running, + fan_status: ct.fan_status, + fan_power: ct.fan_power, + level: ct.level, + vibration: ct.vibration + })) + + const makeupConfig = towerConfig.makeup || {} + const makeupTankId = makeupConfig.tank || tanks?.[0]?.equipment + const makeupLevelValve = valves?.find(v => v.equipment === makeupConfig.level_control_valve) + const makeupOnOffValves = makeupConfig.on_off_valves || [] + + const makeupTank = { + id: makeupTankId, + name: makeupTankId, + level: getSensorReading(levels, makeupConfig.level_sensor), + level_control_valve: makeupConfig.level_control_valve + ? { + id: makeupConfig.level_control_valve, + position: makeupLevelValve?.position + } + : null, + on_off_valves: makeupOnOffValves.map(vid => { + const valve = valves?.find(v => v.equipment === vid) + return { + id: vid, + is_open: valve?.position?.value > 50 + } + }) + } + + const hxTempSensors = {} + for (const hx of (heatExchangers || [])) { + hxTempSensors[hx.equipment] = { + miner_side_out: hx.miner_side_out_temp, + tower_side_in: hx.tower_side_in_temp, + tower_side_out: hx.tower_side_out_temp + } + } + + const towerPumps = filterPumpsByCircuit(pumps, 'COOLING_TOWER').map(formatPump) + + return { + title: towerConfig.name || viewConfig.title, + description: towerConfig.description || viewConfig.description, + water_type: towerConfig.water_type || viewConfig.water_type, + cooling_towers: towerData, + makeup_tank: makeupTank, + heat_exchanger_temps: hxTempSensors, + pumps: towerPumps + } +} + +function buildMinersLayoutView (equipment, config, stats) { + const circuit1 = buildMinersCircuit1View(equipment, config) + const circuit2 = buildMinersCircuit2View(equipment, config) + const { pumps } = equipment + const flowStats = stats?.flow || {} + const viewConfig = config?.cooling_system?.view_metadata?.miners?.layout || {} + + return { + title: viewConfig.title, + description: viewConfig.description, + summary: { + total_miner_loop_flow: flowStats.miner_loop, + total_tower_loop_flow: flowStats.cooling_tower, + pumps_running: (pumps || []).filter(p => p.FbkRunOut).length, + pumps_total: (pumps || []).length + }, + circuit1: { + name: circuit1.title, + water_type: circuit1.water_type, + lines: circuit1.lines, + pumps: circuit1.pumps + }, + circuit2: { + name: circuit2.title, + water_type: circuit2.water_type, + cooling_towers: circuit2.cooling_towers, + makeup_tank: circuit2.makeup_tank, + pumps: circuit2.pumps + } + } +} + +function buildHvacCircuit1View (equipment, config) { + const pumps = equipment.pumps + const temperatures = equipment.temperatures + const pressures = equipment.pressures + const flows = equipment.flows + const levels = equipment.levels + const chillers = equipment.chillers + const fanCoils = equipment.fan_coils + const valves = equipment.valves + const tanks = equipment.tanks + const flowSwitches = equipment.flow_switches + const chilledConfig = config?.cooling_system?.hvac_chilled_water || {} + const viewConfig = config?.cooling_system?.view_metadata?.hvac?.circuit1 || {} + + const chiller = chillers?.[0] + const chillerData = chiller + ? { + id: chiller.equipment, + name: chiller.equipment, + is_running: chiller.is_running, + mode: chiller.mode, + cooling_capacity: chiller.cooling_capacity, + power_consumption: chiller.power_consumption, + evaporator_temp: chiller.evaporator_temp, + condenser_temp: chiller.condenser_temp + } + : null + + const supplyReturnConfig = chilledConfig.supply_return || {} + const supplyReturn = { + supply: { + temperature: getSensorReading(temperatures, supplyReturnConfig.supply_temp_sensor, chilledConfig.defaults?.supply_temp), + flow: getSensorReading(flows, supplyReturnConfig.supply_flow_sensor), + pressure: getSensorReading(pressures, supplyReturnConfig.pressure_sensor) + }, + return: { + temperature: getSensorReading(temperatures, supplyReturnConfig.return_temp_sensor, chilledConfig.defaults?.return_temp), + flow: getSensorReading(flows, supplyReturnConfig.return_flow_sensor) + }, + flow_switches: (flowSwitches || []).map(fs => ({ + id: fs.equipment, + is_active: fs.is_active + })) + } + + const condenserConfig = chilledConfig.condenser || {} + const condenser = { + inlet: { + temperature: getSensorReading(temperatures, condenserConfig.inlet_temp_sensor), + flow: getSensorReading(flows, condenserConfig.inlet_flow_sensor) + }, + outlet: { + temperature: getSensorReading(temperatures, condenserConfig.outlet_temp_sensor), + flow: getSensorReading(flows, condenserConfig.outlet_flow_sensor) + } + } + + const bufferConfig = chilledConfig.buffer_tank || {} + const bufferTankId = bufferConfig.tank || tanks?.[0]?.equipment + const makeupValve = valves?.find(v => v.equipment === bufferConfig.makeup_valve) + const bufferTank = { + id: bufferTankId, + name: bufferTankId, + level: getSensorReading(levels, bufferConfig.level_sensor), + makeup_valve: bufferConfig.makeup_valve + ? { + id: bufferConfig.makeup_valve, + position: makeupValve?.position + } + : null + } + + const bypassValveId = chilledConfig.control_valves?.pressure_bypass + const bypassValve = valves?.find(v => v.equipment === bypassValveId) + const controlValves = bypassValveId + ? { + pressure_bypass: { + id: bypassValveId, + position: bypassValve?.position + } + } + : null + + const returnPumps = filterPumpsByCircuit(pumps, 'HVAC_RETURN').map(formatPump) + const supplyPumps = filterPumpsByCircuit(pumps, 'HVAC_SUPPLY').map(formatPump) + + const fanCoilsSummary = { + total: (fanCoils || []).length, + running: (fanCoils || []).filter(fc => fc.is_running).length, + units: (fanCoils || []).map(fc => ({ + id: fc.equipment, + is_running: fc.is_running, + temperature: fc.temperature, + valve_position: fc.valve_position + })) + } + + return { + title: chilledConfig.name || viewConfig.title, + description: chilledConfig.description || viewConfig.description, + target_supply_temp: chilledConfig.defaults?.supply_temp, + target_return_temp: chilledConfig.defaults?.return_temp, + chiller: chillerData, + supply_return: supplyReturn, + condenser, + buffer_tank: bufferTank, + control_valves: controlValves, + return_pumps: returnPumps, + supply_pumps: supplyPumps, + fan_coils: fanCoilsSummary + } +} + +function buildHvacCircuit2View (equipment, config) { + const pumps = equipment.pumps + const temperatures = equipment.temperatures + const flows = equipment.flows + const coolingTowers = equipment.cooling_towers + const condenserConfig = config?.cooling_system?.hvac_condenser || {} + const viewConfig = config?.cooling_system?.view_metadata?.hvac?.circuit2 || {} + + const supplyReturnConfig = condenserConfig.supply_return || {} + const supplyReturn = { + supply: { + temperature: getSensorReading(temperatures, supplyReturnConfig.supply_temp_sensor, condenserConfig.defaults?.supply_temp), + flow: getSensorReading(flows, supplyReturnConfig.supply_flow_sensor) + }, + return: { + temperature: getSensorReading(temperatures, supplyReturnConfig.return_temp_sensor, condenserConfig.defaults?.return_temp), + flow: getSensorReading(flows, supplyReturnConfig.return_flow_sensor) + } + } + + const towerData = (coolingTowers || []).map(ct => ({ + id: ct.equipment, + name: ct.equipment, + is_running: ct.is_running, + fan_status: ct.fan_status, + fan_power: ct.fan_power, + level: ct.level, + vibration: ct.vibration + })) + + const condenserPumps = filterPumpsByCircuit(pumps, 'HVAC_CONDENSER').map(formatPump) + + return { + title: condenserConfig.name || viewConfig.title, + description: condenserConfig.description || viewConfig.description, + target_supply_temp: condenserConfig.defaults?.supply_temp, + target_return_temp: condenserConfig.defaults?.return_temp, + supply_return: supplyReturn, + cooling_towers: towerData, + pumps: condenserPumps + } +} + +function buildHvacLayoutView (equipment, config) { + const circuit1 = buildHvacCircuit1View(equipment, config) + const circuit2 = buildHvacCircuit2View(equipment, config) + const { pumps } = equipment + const viewConfig = config?.cooling_system?.view_metadata?.hvac?.layout || {} + + return { + title: viewConfig.title, + description: viewConfig.description, + summary: { + chiller_running: circuit1.chiller?.is_running || false, + fan_coils_running: circuit1.fan_coils.running, + fan_coils_total: circuit1.fan_coils.total, + cooling_towers_running: circuit2.cooling_towers.filter(ct => ct.is_running).length, + pumps_running: (pumps || []).filter(p => p.FbkRunOut).length, + pumps_total: (pumps || []).length + }, + circuit1: { + name: circuit1.title, + chiller: circuit1.chiller, + supply_return: circuit1.supply_return, + buffer_tank: circuit1.buffer_tank, + return_pumps: circuit1.return_pumps, + supply_pumps: circuit1.supply_pumps + }, + circuit2: { + name: circuit2.title, + supply_return: circuit2.supply_return, + cooling_towers: circuit2.cooling_towers, + pumps: circuit2.pumps + } + } +} + +function buildHvacAmbientView (equipment, config, stats) { + const fanCoils = equipment.fan_coils + const humiditySensors = equipment.humidity_sensors + const ambientConfig = config?.cooling_system?.ambient || {} + const viewConfig = config?.cooling_system?.view_metadata?.hvac?.ambient || {} + const humidityStats = stats?.humidity || {} + + const rooms = ambientConfig.rooms || [] + + const roomData = rooms.map(roomConfig => { + const roomFanCoils = (fanCoils || []) + .filter(fc => (roomConfig.fan_coils || []).includes(fc.equipment)) + .map(fc => ({ + id: fc.equipment, + name: fc.equipment, + is_running: fc.is_running, + temperature: fc.temperature, + valve_position: fc.valve_position + })) + + const roomTemps = roomFanCoils + .filter(fc => fc.temperature?.value > 0) + .map(fc => fc.temperature.value) + const avgTemp = roomTemps.length > 0 + ? Math.round((roomTemps.reduce((a, b) => a + b, 0) / roomTemps.length) * 10) / 10 + : null + const tempUnit = roomFanCoils[0]?.temperature?.unit + + const roomHumidity = (humiditySensors || []) + .filter(h => (roomConfig.humidity_sensors || []).includes(h.equipment)) + .map(h => ({ + id: h.equipment, + humidity: { value: h.value, unit: h.unit } + })) + + const humidityValues = roomHumidity.filter(h => h.humidity.value != null).map(h => h.humidity.value) + const avgHumidity = humidityValues.length > 0 + ? Math.round((humidityValues.reduce((a, b) => a + b, 0) / humidityValues.length) * 10) / 10 + : null + const humidityUnit = roomHumidity[0]?.humidity?.unit + + return { + name: roomConfig.name, + temperature: avgTemp != null ? { value: avgTemp, unit: tempUnit } : null, + humidity: avgHumidity != null ? { value: avgHumidity, unit: humidityUnit } : null, + fan_coils: roomFanCoils, + humidity_sensors: roomHumidity + } + }) + + const ambientSensorIds = ambientConfig.ambient_sensors || [] + const ambientSensors = (humiditySensors || []) + .filter(h => ambientSensorIds.includes(h.equipment)) + .map(h => ({ + id: h.equipment, + humidity: { value: h.value, unit: h.unit } + })) + + const humidityUnit = humiditySensors?.[0]?.unit + + return { + title: viewConfig.title, + description: viewConfig.description, + summary: { + average_humidity: humidityStats.avg != null ? { value: humidityStats.avg, unit: humidityUnit } : null, + rooms_count: roomData.length, + fan_coils_running: (fanCoils || []).filter(fc => fc.is_running).length, + fan_coils_total: (fanCoils || []).length + }, + rooms: roomData, + ambient_sensors: ambientSensors + } +} + +/** + * + * @param {Object} snap - Device snap data + * @param {string} type - 'miners' or 'hvac' + * @param {string} view - View name (circuit1, circuit2, layout, ambient) + * @returns {Object|null} + */ +function buildCoolingViewData (snap, type, view) { + const equipment = snap.stats?.dcs_specific?.equipment || {} + const config = snap.config || {} + const stats = snap.stats || {} + + if (type === 'miners') { + switch (view) { + case 'circuit1': + return buildMinersCircuit1View(equipment, config) + case 'circuit2': + return buildMinersCircuit2View(equipment, config) + case 'layout': + return buildMinersLayoutView(equipment, config, stats) + default: + return null + } + } + + if (type === 'hvac') { + switch (view) { + case 'circuit1': + return buildHvacCircuit1View(equipment, config) + case 'circuit2': + return buildHvacCircuit2View(equipment, config) + case 'layout': + return buildHvacLayoutView(equipment, config) + case 'ambient': + return buildHvacAmbientView(equipment, config, stats) + default: + return null + } + } + + return null +} + +/** + * GET /auth/dcs/cooling-system + * + * Returns cooling system data for the requested type and view. + * App-node builds views from enriched equipment data fetched from DCS worker. + * All values include units - app-node is completely agnostic to device details. + * + * Query params: + * - type: 'miners' | 'hvac' (required) + * - view: 'circuit1' | 'circuit2' | 'layout' | 'ambient' (required) + * - overwriteCache: boolean (optional) + */ +async function getCoolingSystemData (ctx, req) { + if (!isCentralDCSEnabled(ctx)) { + throw new Error('ERR_FEATURE_NOT_ENABLED') + } + + const { type, view } = req.query + + if (!type || !['miners', 'hvac'].includes(type)) { + throw new Error('ERR_INVALID_TYPE') + } + + const validViews = type === 'miners' + ? ['circuit1', 'circuit2', 'layout'] + : ['circuit1', 'circuit2', 'layout', 'ambient'] + + if (!view || !validViews.includes(view)) { + throw new Error('ERR_INVALID_VIEW') + } + + const dcsTag = getDCSTag(ctx) + + const fields = getFieldProjection(type, view) + + const payload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields + } + + const rpcResults = await ctx.dataProxy.requestDataMap('listThings', payload) + const dcsThing = extractDcsThing(rpcResults) + + if (!dcsThing) { + throw new Error('ERR_DCS_DATA_NOT_FOUND') + } + + const snap = dcsThing.last.snap + + const viewData = buildCoolingViewData(snap, type, view) + + if (!viewData) { + throw new Error('ERR_VIEW_DATA_NOT_AVAILABLE') + } + + return { + type, + view, + data: viewData + } +} + +module.exports = { + getCoolingSystemData, + isCentralDCSEnabled, + getDCSTag, + getFieldProjection, + extractDcsThing, + buildCoolingViewData, + buildMinersCircuit1View, + buildMinersCircuit2View, + buildMinersLayoutView, + buildHvacCircuit1View, + buildHvacCircuit2View, + buildHvacLayoutView, + buildHvacAmbientView +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 6440ed8..0463bae 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -17,6 +17,7 @@ const metricsRoutes = require('./routes/metrics.routes') const alertsRoutes = require('./routes/alerts.routes') const minersRoutes = require('./routes/miners.routes') const groupsRoutes = require('./routes/groups.routes') +const coolingSystemRoutes = require('./routes/coolingSystem.routes') /** * Collect all routes into a flat array for server injection. @@ -40,7 +41,8 @@ function routes (ctx) { ...metricsRoutes(ctx), ...alertsRoutes(ctx), ...minersRoutes(ctx), - ...groupsRoutes(ctx) + ...groupsRoutes(ctx), + ...coolingSystemRoutes(ctx) ] } diff --git a/workers/lib/server/routes/coolingSystem.routes.js b/workers/lib/server/routes/coolingSystem.routes.js new file mode 100644 index 0000000..dc650eb --- /dev/null +++ b/workers/lib/server/routes/coolingSystem.routes.js @@ -0,0 +1,40 @@ +'use strict' + +const { ENDPOINTS, HTTP_METHODS } = require('../../constants') +const { getCoolingSystemData } = require('../handlers/coolingSystem.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.COOLING_SYSTEM, + schema: { + querystring: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['miners', 'hvac'], + description: 'Cooling system type: miners or hvac' + }, + view: { + type: 'string', + enum: ['circuit1', 'circuit2', 'layout', 'ambient'], + description: 'View to retrieve: circuit1, circuit2, layout, or ambient (hvac only)' + }, + overwriteCache: { + type: 'boolean', + description: 'Force refresh cached data' + } + }, + required: ['type', 'view'] + } + }, + ...createCachedAuthRoute( + ctx, + (req) => ['cooling-system', req.query.type, req.query.view], + ENDPOINTS.COOLING_SYSTEM, + getCoolingSystemData + ) + } +] From c8410210e5261b7fe11e413bd2b4353757077fe4 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Mon, 13 Apr 2026 17:43:59 +0300 Subject: [PATCH 25/63] fix: sanitize queryActions input to prevent injection and DoS (#49) * fix: sanitize queryActions input to prevent injection and DoS (TDEBT-38) Add maxLength constraints to suffix (200) and queries (10000) in route schemas, validate queries array type and cap length at 50 in handler. * fix: tighten queryActions limits and extract to constants Reduce queries string maxLength from 10000 to 1000 and cap the array at 10 items (previously 50). Extract both limits to named constants. --- tests/unit/handlers/actions.handlers.test.js | 2 +- tests/unit/routes/actions.routes.test.js | 17 +++++++++++++++++ workers/lib/constants.js | 5 +++++ workers/lib/server/handlers/actions.handlers.js | 7 +++++++ workers/lib/server/routes/actions.routes.js | 9 +++++---- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/unit/handlers/actions.handlers.test.js b/tests/unit/handlers/actions.handlers.test.js index 7c6e4fd..a0e9cd6 100644 --- a/tests/unit/handlers/actions.handlers.test.js +++ b/tests/unit/handlers/actions.handlers.test.js @@ -60,7 +60,7 @@ test('queryActions - with queries parameter', async (t) => { } ) - const mockReq = createMockReq({ queries: '{"status": "pending"}' }) + const mockReq = createMockReq({ queries: '[{"type": "voting", "query": {"status": "pending"}}]' }) const result = await queryActions(mockCtx, mockReq) diff --git a/tests/unit/routes/actions.routes.test.js b/tests/unit/routes/actions.routes.test.js index 8264c44..51f7ef9 100644 --- a/tests/unit/routes/actions.routes.test.js +++ b/tests/unit/routes/actions.routes.test.js @@ -62,6 +62,23 @@ test('actions routes - schema validation', (t) => { t.pass() }) +test('actions routes - schema constraints for security', (t) => { + const routes = createRoutesForTest('../../../workers/lib/server/routes/actions.routes.js') + + const actionsRoute = routes.find(r => r.url === '/auth/actions') + const queriesProp = actionsRoute.schema.querystring.properties.queries + t.is(queriesProp.maxLength, 1000, 'queries should have maxLength 1000') + + const suffixProp = actionsRoute.schema.querystring.properties.suffix + t.is(suffixProp.maxLength, 200, 'GET suffix should have maxLength 200') + + const batchRoute = routes.find(r => r.url === '/auth/actions/voting/batch') + const batchSuffix = batchRoute.schema.body.properties.suffix + t.is(batchSuffix.maxLength, 200, 'POST suffix should have maxLength 200') + + t.pass() +}) + test('actions routes - handler functions', (t) => { const routes = createRoutesForTest('../../../workers/lib/server/routes/actions.routes.js') testHandlerFunctions(t, routes, 'actions') diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 0109c1c..d99f155 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -455,6 +455,9 @@ const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 const RPC_PAGE_LIMIT = 100 +const ACTIONS_MAX_QUERIES = 10 +const ACTIONS_QUERIES_MAX_LENGTH = 1000 + // Allowed config types for generic config CRUD const CONFIG_TYPES = { POOL: 'pool' @@ -554,6 +557,8 @@ module.exports = { RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, RPC_PAGE_LIMIT, + ACTIONS_MAX_QUERIES, + ACTIONS_QUERIES_MAX_LENGTH, USER_SETTINGS_TYPE, LIST_THINGS, GET_HISTORICAL_LOGS, diff --git a/workers/lib/server/handlers/actions.handlers.js b/workers/lib/server/handlers/actions.handlers.js index bd64531..c1ddb20 100644 --- a/workers/lib/server/handlers/actions.handlers.js +++ b/workers/lib/server/handlers/actions.handlers.js @@ -1,6 +1,7 @@ 'use strict' const { parseJsonQueryParam } = require('../../utils') +const { ACTIONS_MAX_QUERIES } = require('../../constants') async function queryActionsBatch (ctx, req) { const payload = { @@ -21,6 +22,12 @@ async function queryActions (ctx, req, rep) { if (req.query.queries) { payload.queries = parseJsonQueryParam(req.query.queries, 'ERR_QUERIES_INVALID_JSON') + if (!Array.isArray(payload.queries)) { + throw new Error('ERR_QUERIES_INVALID') + } + if (payload.queries.length > ACTIONS_MAX_QUERIES) { + throw new Error('ERR_QUERIES_LIMIT_EXCEEDED') + } } if (req.query.groupBatch) { payload.groupBatch = req.query.groupBatch diff --git a/workers/lib/server/routes/actions.routes.js b/workers/lib/server/routes/actions.routes.js index c59363f..dda05e6 100644 --- a/workers/lib/server/routes/actions.routes.js +++ b/workers/lib/server/routes/actions.routes.js @@ -1,7 +1,8 @@ 'use strict' const { ENDPOINTS, - HTTP_METHODS + HTTP_METHODS, + ACTIONS_QUERIES_MAX_LENGTH } = require('../../constants') const { queryActions, @@ -23,10 +24,10 @@ module.exports = (ctx) => { querystring: { type: 'object', properties: { - queries: { type: 'string' }, + queries: { type: 'string', maxLength: ACTIONS_QUERIES_MAX_LENGTH }, overwriteCache: { type: 'boolean' }, groupBatch: { type: 'boolean' }, - suffix: { type: 'string' } + suffix: { type: 'string', maxLength: 200 } }, required: ['queries'] } @@ -109,7 +110,7 @@ module.exports = (ctx) => { properties: { batchActionsPayload: { type: 'array' }, batchActionUID: { type: 'string' }, - suffix: { type: 'string' } + suffix: { type: 'string', maxLength: 200 } }, required: ['batchActionsPayload', 'batchActionUID'] } From 157f4051cd9263f9997aac2b48c50c97569c6053 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:14:52 +0530 Subject: [PATCH 26/63] Add energy views endpoint support (#52) * Add energy views endpoint support * move outside to dir with other utils * fix imports --- .../handlers/coolingSystem.handlers.test.js | 4 +- workers/lib/constants.js | 29 ++- workers/lib/dcs.utils.js | 69 ++++++ .../server/handlers/coolingSystem.handlers.js | 43 +--- .../server/handlers/energySystem.handlers.js | 205 ++++++++++++++++++ workers/lib/server/index.js | 4 +- .../lib/server/routes/energySystem.routes.js | 35 +++ 7 files changed, 346 insertions(+), 43 deletions(-) create mode 100644 workers/lib/dcs.utils.js create mode 100644 workers/lib/server/handlers/energySystem.handlers.js create mode 100644 workers/lib/server/routes/energySystem.routes.js diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js index 97e7a37..d6f5fb0 100644 --- a/tests/unit/handlers/coolingSystem.handlers.test.js +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -3,10 +3,7 @@ const test = require('brittle') const { getCoolingSystemData, - isCentralDCSEnabled, - getDCSTag, getFieldProjection, - extractDcsThing, buildCoolingViewData, buildMinersCircuit1View, buildMinersCircuit2View, @@ -14,6 +11,7 @@ const { buildHvacAmbientView } = require('../../../workers/lib/server/handlers/coolingSystem.handlers') const { COOLING_SYSTEM_PROJECTIONS } = require('../../../workers/lib/constants') +const { extractDcsThing, getDCSTag, isCentralDCSEnabled } = require('../../../workers/lib/dcs.utils') // Sample enriched equipment data (as provided by the DCS worker) // All data includes units - app-node is completely agnostic diff --git a/workers/lib/constants.js b/workers/lib/constants.js index d99f155..78d22dd 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -158,7 +158,9 @@ const ENDPOINTS = { MINERS: '/auth/miners', // Cooling System endpoints - COOLING_SYSTEM: '/auth/dcs/cooling-system' + COOLING_SYSTEM: '/auth/dcs/cooling-system', + // Energy System endpoints + ENERGY_SYSTEM: '/auth/dcs/energy-system' } const HTTP_METHODS = { @@ -397,6 +399,28 @@ const COOLING_SYSTEM_PROJECTIONS = { } } +const ENERGY_SYSTEM_PROJECTIONS = { + base: { id: 1, code: 1, type: 1, tags: 1, rack: 1 }, + miners: { + 'last.snap.stats.dcs_specific.equipment.power_meters': 1, + 'last.snap.stats.energy': 1, + 'last.snap.config.energy_layout': 1 + }, + cooling_auxiliary: { + 'last.snap.stats.dcs_specific.equipment.power_meters': 1, + 'last.snap.stats.energy': 1, + 'last.snap.config.energy_layout': 1 + }, + layout: { + 'last.snap.stats.dcs_specific.equipment.power_meters': 1, + 'last.snap.stats.dcs_specific.equipment.protection_relays': 1, + 'last.snap.stats.dcs_specific.equipment.transformers': 1, + 'last.snap.stats.dcs_specific.equipment.distribution_boards': 1, + 'last.snap.stats.energy': 1, + 'last.snap.config.energy_layout': 1 + } +} + const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', SITE_POWER: 'site_power_w', @@ -593,5 +617,6 @@ module.exports = { MINER_DEFAULT_FIELDS, MINER_MAX_LIMIT, MINER_DEFAULT_LIMIT, - COOLING_SYSTEM_PROJECTIONS + COOLING_SYSTEM_PROJECTIONS, + ENERGY_SYSTEM_PROJECTIONS } diff --git a/workers/lib/dcs.utils.js b/workers/lib/dcs.utils.js new file mode 100644 index 0000000..6e1eae0 --- /dev/null +++ b/workers/lib/dcs.utils.js @@ -0,0 +1,69 @@ +'use strict' + +const DCS_TAG_DEFAULT = 't-dcs' + +function isCentralDCSEnabled (ctx) { + if (ctx.conf?.featureConfig?.centralDCSSetup?.enabled === true) return true + return false +} + +function getDCSTag (ctx) { + return ctx.conf?.featureConfig?.centralDCSSetup?.tag || DCS_TAG_DEFAULT +} + +function extractDcsThing (rpcResults) { + if (!Array.isArray(rpcResults)) return null + + for (const orkResult of rpcResults) { + if (!Array.isArray(orkResult)) continue + for (const thing of orkResult) { + if (thing && thing?.type && thing.type.includes('dcs') && thing?.last?.snap) { + return thing + } + } + } + return null +} + +function getSensorReading (sensors, sensorId, defaultConfig = null) { + if (!sensorId) return defaultConfig + const sensor = sensors?.find(s => s.equipment === sensorId) + if (sensor?.value != null) { + return { value: sensor.value, unit: sensor.unit } + } + return defaultConfig +} + +function findEquipment (equipmentList, equipmentId) { + if (!equipmentId || !Array.isArray(equipmentList)) return null + return equipmentList.find(e => e.equipment === equipmentId) +} + +function filterEquipmentBy (equipmentList, field, value) { + if (!Array.isArray(equipmentList)) return [] + return equipmentList.filter(e => e[field] === value) +} + +async function fetchDcsThing (ctx, fields) { + const dcsTag = getDCSTag(ctx) + + const payload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields + } + + const rpcResults = await ctx.dataProxy.requestDataMap('listThings', payload) + return extractDcsThing(rpcResults) +} + +module.exports = { + DCS_TAG_DEFAULT, + isCentralDCSEnabled, + getDCSTag, + extractDcsThing, + getSensorReading, + findEquipment, + filterEquipmentBy, + fetchDcsThing +} diff --git a/workers/lib/server/handlers/coolingSystem.handlers.js b/workers/lib/server/handlers/coolingSystem.handlers.js index be9ed30..dde4ce5 100644 --- a/workers/lib/server/handlers/coolingSystem.handlers.js +++ b/workers/lib/server/handlers/coolingSystem.handlers.js @@ -1,17 +1,12 @@ 'use strict' const { COOLING_SYSTEM_PROJECTIONS } = require('../../constants') - -const DCS_TAG_DEFAULT = 't-dcs' - -function isCentralDCSEnabled (ctx) { - if (ctx.conf?.featureConfig?.centralDCSSetup?.enabled === true) return true - return false -} - -function getDCSTag (ctx) { - return ctx.conf?.featureConfig?.centralDCSSetup?.tag || DCS_TAG_DEFAULT -} +const { + isCentralDCSEnabled, + getDCSTag, + extractDcsThing, + getSensorReading +} = require('../../dcs.utils') function getFieldProjection (type, view) { const base = COOLING_SYSTEM_PROJECTIONS.base @@ -20,29 +15,6 @@ function getFieldProjection (type, view) { return { ...base, ...viewProjection } } -function extractDcsThing (rpcResults) { - if (!Array.isArray(rpcResults)) return null - - for (const orkResult of rpcResults) { - if (!Array.isArray(orkResult)) continue - for (const thing of orkResult) { - if (thing && thing?.type && thing.type.includes('dcs') && thing?.last?.snap) { - return thing - } - } - } - return null -} - -function getSensorReading (sensors, sensorId, defaultConfig = null) { - if (!sensorId) return defaultConfig - const sensor = sensors?.find(s => s.equipment === sensorId) - if (sensor?.value != null) { - return { value: sensor.value, unit: sensor.unit } - } - return defaultConfig -} - function filterPumpsByCircuit (pumps, circuit) { return (pumps || []).filter(p => p.circuit === circuit) } @@ -601,10 +573,7 @@ async function getCoolingSystemData (ctx, req) { module.exports = { getCoolingSystemData, - isCentralDCSEnabled, - getDCSTag, getFieldProjection, - extractDcsThing, buildCoolingViewData, buildMinersCircuit1View, buildMinersCircuit2View, diff --git a/workers/lib/server/handlers/energySystem.handlers.js b/workers/lib/server/handlers/energySystem.handlers.js new file mode 100644 index 0000000..dc233dc --- /dev/null +++ b/workers/lib/server/handlers/energySystem.handlers.js @@ -0,0 +1,205 @@ +'use strict' + +const { ENERGY_SYSTEM_PROJECTIONS } = require('../../constants') +const { + isCentralDCSEnabled, + getDCSTag, + extractDcsThing, + findEquipment +} = require('../../dcs.utils') + +function getFieldProjection (view) { + const base = ENERGY_SYSTEM_PROJECTIONS.base + const viewProjection = ENERGY_SYSTEM_PROJECTIONS[view] || {} + return { ...base, ...viewProjection } +} + +function buildMinersView (equipment, config, stats) { + const powerMeters = equipment.power_meters || [] + const energyStats = stats?.energy || {} + + // Site total from main meter (role: site_main) + const siteMeter = powerMeters.find(pm => pm.role === 'site_main') + const siteTotal = energyStats.site_total || { + power_kw: siteMeter?.power?.value || 0, + equipment: siteMeter?.equipment || null + } + + // Rack meters (role: rack) + console.log('powerMeters', powerMeters) + const rackMeters = powerMeters + .filter(pm => pm.role === 'rack') + .sort((a, b) => { + const numA = parseInt((a.equipment.match(/\d+/) || ['0'])[0], 10) + const numB = parseInt((b.equipment.match(/\d+/) || ['0'])[0], 10) + return numA - numB + }) + + return { + title: 'Miners Energy', + site_total: siteTotal, + meters: rackMeters + } +} + +function buildCoolingAuxiliaryView (equipment, config) { + const powerMeters = equipment.power_meters || [] + + // CCM Principal (role: ccm_principal) + const ccmMeter = powerMeters.find(pm => pm.role === 'ccm_principal') + const ccmPrincipal = { + power_kw: ccmMeter?.power?.value || 0, + equipment: ccmMeter?.equipment || null + } + + // Auxiliary meters (role: auxiliary) + const auxiliaryMeters = powerMeters.filter(pm => pm.role === 'auxiliary') + + return { + title: 'Cooling & Auxiliary', + ccm_principal: ccmPrincipal, + ccm_meter: ccmMeter || null, + auxiliary_meters: auxiliaryMeters + } +} + +function buildLayoutView (equipment, config, stats) { + const powerMeters = equipment.power_meters || [] + const protectionRelays = equipment.protection_relays || [] + const transformers = equipment.transformers || [] + const distributionBoards = equipment.distribution_boards || [] + const energyLayout = config?.energy_layout || {} + const energyStats = stats?.energy || {} + + // Site main power meter (role: site_main) + const siteMeter = powerMeters.find(pm => pm.role === 'site_main') + + // Main protection relay (role: main_incoming) + const mainRelay = protectionRelays.find(r => r.role === 'main_incoming') + + // Build branches from config, looking up equipment by role/description + const branches = (energyLayout.branches || []).map(branchConfig => { + const relay = findEquipment(protectionRelays, branchConfig.relay) + const transformer = findEquipment(transformers, branchConfig.transformer) + const board = findEquipment(distributionBoards, branchConfig.board) + const meter = findEquipment(powerMeters, branchConfig.meter) + + return { + feeds: branchConfig.feeds, + protection_relay: relay || null, + transformer: transformer || null, + distribution_board: board || null, + meter: meter ? { equipment: meter.equipment, power: meter.power } : null + } + }) + + // Fallback: if no branches config, group by branch_feeder role + if (branches.length === 0) { + const branchRelays = protectionRelays.filter(r => r.role === 'branch_feeder') + branchRelays.forEach((relay, idx) => { + const transformer = transformers[idx] || null + const board = distributionBoards[idx] || null + + branches.push({ + feeds: relay.description || `Branch ${idx + 1}`, + protection_relay: relay, + transformer, + distribution_board: board, + meter: null + }) + }) + } + + const siteTotal = energyStats.site_total || { + power_kw: siteMeter?.power?.value || 0, + equipment: siteMeter?.equipment || null + } + + return { + title: 'Energy Layout', + site_total: siteTotal, + site_pm: siteMeter || null, + main_protection: mainRelay || null, + branches, + summary: { + protection_relays_total: protectionRelays.length, + protection_relays_tripped: protectionRelays.filter(r => r.is_tripped).length, + transformers_total: transformers.length, + distribution_boards_total: distributionBoards.length, + distribution_boards_tripped: distributionBoards.filter(b => b.is_tripped).length + } + } +} + +/** + * Build energy view data from DCS snap + */ +function buildEnergyViewData (snap, view) { + const equipment = snap.stats?.dcs_specific?.equipment || {} + const config = snap.config || {} + const stats = snap.stats || {} + + switch (view) { + case 'miners': + return buildMinersView(equipment, config, stats) + case 'cooling_auxiliary': + return buildCoolingAuxiliaryView(equipment, config) + case 'layout': + return buildLayoutView(equipment, config, stats) + default: + return null + } +} + +/** + * GET /auth/dcs/energy-system + */ +async function getEnergySystemData (ctx, req) { + if (!isCentralDCSEnabled(ctx)) { + throw new Error('ERR_FEATURE_NOT_ENABLED') + } + + const { view } = req.query + + const validViews = ['miners', 'cooling_auxiliary', 'layout'] + + if (!view || !validViews.includes(view)) { + throw new Error('ERR_INVALID_VIEW') + } + + const dcsTag = getDCSTag(ctx) + const fields = getFieldProjection(view) + + const payload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields + } + + const rpcResults = await ctx.dataProxy.requestDataMap('listThings', payload) + const dcsThing = extractDcsThing(rpcResults) + + if (!dcsThing) { + throw new Error('ERR_DCS_DATA_NOT_FOUND') + } + + const snap = dcsThing.last.snap + const viewData = buildEnergyViewData(snap, view) + + if (!viewData) { + throw new Error('ERR_VIEW_DATA_NOT_AVAILABLE') + } + + return { + view, + data: viewData + } +} + +module.exports = { + getEnergySystemData, + buildEnergyViewData, + buildMinersView, + buildCoolingAuxiliaryView, + buildLayoutView +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 0463bae..1c10a56 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -18,6 +18,7 @@ const alertsRoutes = require('./routes/alerts.routes') const minersRoutes = require('./routes/miners.routes') const groupsRoutes = require('./routes/groups.routes') const coolingSystemRoutes = require('./routes/coolingSystem.routes') +const energySystemRoutes = require('./routes/energySystem.routes') /** * Collect all routes into a flat array for server injection. @@ -42,7 +43,8 @@ function routes (ctx) { ...alertsRoutes(ctx), ...minersRoutes(ctx), ...groupsRoutes(ctx), - ...coolingSystemRoutes(ctx) + ...coolingSystemRoutes(ctx), + ...energySystemRoutes(ctx) ] } diff --git a/workers/lib/server/routes/energySystem.routes.js b/workers/lib/server/routes/energySystem.routes.js new file mode 100644 index 0000000..4baded7 --- /dev/null +++ b/workers/lib/server/routes/energySystem.routes.js @@ -0,0 +1,35 @@ +'use strict' + +const { ENDPOINTS, HTTP_METHODS } = require('../../constants') +const { getEnergySystemData } = require('../handlers/energySystem.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.ENERGY_SYSTEM, + schema: { + querystring: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ['miners', 'cooling_auxiliary', 'layout'], + description: 'View to retrieve: miners, cooling_auxiliary, or layout' + }, + overwriteCache: { + type: 'boolean', + description: 'Force refresh cached data' + } + }, + required: ['view'] + } + }, + ...createCachedAuthRoute( + ctx, + (req) => ['energy-system', req.query.view], + ENDPOINTS.ENERGY_SYSTEM, + getEnergySystemData + ) + } +] From 284f63b1ff9337de98973b6810ff69feed4e5e70 Mon Sep 17 00:00:00 2001 From: tekwani Date: Thu, 16 Apr 2026 15:03:10 +0530 Subject: [PATCH 27/63] feat: microsoft oauth (#54) --- config/facs/httpd-oauth2.config.json.example | 16 +- package-lock.json | 425 ++++++++++-------- tests/integration/api.test.js | 29 +- tests/integration/ws.test.js | 10 +- .../handlers/coolingSystem.handlers.test.js | 12 +- tests/unit/lib/auth.test.js | 129 ++++++ tests/unit/lib/constants.test.js | 1 + tests/unit/routes/auth.routes.test.js | 71 ++- .../unit/routes/coolingSystem.routes.test.js | 9 +- workers/http.node.wrk.js | 3 + workers/lib/auth.js | 55 ++- workers/lib/constants.js | 1 + workers/lib/server/routes/auth.routes.js | 17 + 13 files changed, 569 insertions(+), 209 deletions(-) diff --git a/config/facs/httpd-oauth2.config.json.example b/config/facs/httpd-oauth2.config.json.example index 9a80f68..34d038e 100644 --- a/config/facs/httpd-oauth2.config.json.example +++ b/config/facs/httpd-oauth2.config.json.example @@ -9,7 +9,21 @@ }, "startRedirectPath": "/oauth/google", "callbackUri": "http://localhost:3000/oauth/google/callback", - "callbackUriUI": "http://localhost:3030", + "callbackUriUI": "http://localhost:3000", + "users": [] + }, + "h1": { + "method": "microsoft", + "credentials": { + "client": { + "id": "", + "secret": "" + }, + "tenant":"" + }, + "startRedirectPath": "/oauth/microsoft", + "callbackUri": "http://localhost:3000/oauth/microsoft/callback", + "callbackUriUI": "http://localhost:3000", "users": [] } } diff --git a/package-lock.json b/package-lock.json index 588ded6..23d75cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "mingo": "6.4.6", "svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git", "svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git", - "svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git", + "svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git#pull/5/head", "tether-wrk-base": "git+https://github.com/tetherto/tether-wrk-base.git" }, "devDependencies": { @@ -57,8 +57,8 @@ }, "node_modules/@bitfinexcom/lib-js-util-base": { "name": "@bitfinex/lib-js-util-base", - "version": "2.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#8c06f3a377e62ae556f19dead07489475acd4bce", + "version": "2.0.2", + "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#e915c19224d8e334b3260ae3e979a50865722446", "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { @@ -280,9 +280,9 @@ } }, "node_modules/@fastify/static/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1109,9 +1109,9 @@ } }, "node_modules/autobase": { - "version": "7.26.1", - "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.26.1.tgz", - "integrity": "sha512-h3jTF9arKyxyqkFTgRY/5rKb3uJZP7eCRC8FPxe1e9g9L+9vbwNlahphgqJS0O3yqDQieOYhovdo7Z+nVrL/xw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.27.3.tgz", + "integrity": "sha512-eH0UUYYO2kvy9Vug0KLj/mjjSGEslA6YL7axBlPsArlmadBDJmkYj3olpbWVqnsA+VtTndiHGQZyC/004x/aVw==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.1", @@ -1121,7 +1121,7 @@ "debounceify": "^1.0.0", "encryption-encoding": "^1.0.3", "hyperbee": "^2.22.0", - "hypercore": "^11.4.0", + "hypercore": "^11.27.12", "hypercore-crypto": "^3.4.0", "hypercore-id-encoding": "^1.2.0", "hyperschema": "^1.12.1", @@ -1213,7 +1213,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz", "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-stream": "^2.6.5" @@ -1231,7 +1230,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz", "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-inspect": "^3.1.2" @@ -1352,9 +1350,9 @@ } }, "node_modules/bare-format": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bare-format/-/bare-format-1.0.1.tgz", - "integrity": "sha512-1oS+LZrWK6tnYnvNSHDGljc2MPunRxwhpFriuCgzNF+oklrnwmBKD91tS0yt+jpl2j3UgcSDzBIMiVTvLs9A8w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bare-format/-/bare-format-1.0.2.tgz", + "integrity": "sha512-GswdhnOnP9QtwRbrf4wLApw5widkaLMsLe2XOs35fQD2YfEN1ApoGka+cZ7PfvzxMgfYXmMhj/2OGlVn5/Dxgw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1362,9 +1360,9 @@ } }, "node_modules/bare-fs": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", - "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -1393,22 +1391,22 @@ "license": "Apache-2.0" }, "node_modules/bare-http-parser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.0.1.tgz", - "integrity": "sha512-A3LTDTcELcmNJ3g5liIaS038v/BQxOhA9cjhBESn7eoV7QCuMoIRBKLDadDe08flxyLbxI2f+1l2MZ/5+HnKPA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.1.3.tgz", + "integrity": "sha512-+dhVvQi6brHq14L/XHNRQ+TLuVE76VjRmMt61wVEtS+Od8xUslfMHWJN/ZjIIt3RtTG6vPuA+x9cOh7KrkBJsA==", "dev": true, "license": "Apache-2.0" }, "node_modules/bare-http1": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.2.3.tgz", - "integrity": "sha512-zyvHF3xzvBJNpuw0QydE9etTn2AyC/TRVDWTo8Ed3LQEEGaFOkv6KHhaJPlf4eWEeTekk41Fowie+fTsXoNxkA==", + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.5.6.tgz", + "integrity": "sha512-31OAwMkSU+z1VuUOCk65hx3aWQgzCfH/zQ6LGxbJtmiy2Czsw0+uvOBM9YkqaL6zUSTSYG2pLbL0v/TjME3Buw==", "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.6.0", - "bare-http-parser": "^1.0.0", - "bare-stream": "^2.3.0", + "bare-http-parser": "^1.1.1", + "bare-stream": "^2.10.0", "bare-tcp": "^2.2.0" }, "peerDependencies": { @@ -1425,13 +1423,13 @@ } }, "node_modules/bare-https": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-2.1.2.tgz", - "integrity": "sha512-Q+TTydUDsuKQJvh8dX2dvOXCR9fM3xR5TBmKaFrs5p7Lj7XbKX7v4vIUJ36H0SXg2xCOQxXKIbjwrLg5tfJNYg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-2.1.3.tgz", + "integrity": "sha512-0TI/mJXQGYXmG7UUyWEG+KCJusayIAQLywUjFAskDoKuxfqVnGF+M/mTMrEV8J64DaIdU+5x761FvgmT7M68tA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "bare-http1": "^4.0.0", + "bare-http1": "^4.4.0", "bare-tcp": "^2.2.0", "bare-tls": "^2.0.0" } @@ -1440,7 +1438,6 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz", "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-ansi-escapes": "^2.1.0", @@ -1476,9 +1473,9 @@ } }, "node_modules/bare-module": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/bare-module/-/bare-module-6.1.3.tgz", - "integrity": "sha512-5XWsVHsvtWMH4tK4DQWgpNTV0t/sg3ZrAaQLIxrwjrS5+u8Q9vEgc/zQ4QaDPWDse/y/5h+d+YG1Q0JfSMt0zA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/bare-module/-/bare-module-6.2.0.tgz", + "integrity": "sha512-CoCTG8y8HKPnNW317OW1hynl28DasVPtbX033ZEAXk0Q3SYCUXi3OOnNAr6wTrDgMgpOsW0kl7Nifcom5GGH8Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1536,9 +1533,9 @@ } }, "node_modules/bare-net": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.2.0.tgz", - "integrity": "sha512-UF7cAbHsGE+H6uEqWF5IULBow1x58chZz4g3ALgHtv7wZsFcCbRDt0JKWEumf5Oma3QWS1Q6aLi0Rpll8RElMg==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.1.tgz", + "integrity": "sha512-MypSqDKpDU2Xt7FIfazn5yGvRnV09gFcIPHGWstW0gxuzA4tucTcwJSZeos97C4F89vtU5oGwXDN/HrGN6Y4Jw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1549,9 +1546,9 @@ } }, "node_modules/bare-os": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz", - "integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==", + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -1567,9 +1564,9 @@ } }, "node_modules/bare-pipe": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.1.3.tgz", - "integrity": "sha512-DqQsx93rAzre6yJ9T6l/Vgh+X+bntkVMB1X5ggtXjXtqtMmF2Y2RVlCzxxy/09R6yeR9FSWBEUIaMYJL1/5VDA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.1.5.tgz", + "integrity": "sha512-6OfxaG8JSkRh3Gc4hzHRsxNt+yu2PpN7lrv1V+T78GdknWQkVGwiEvu4m+1nbfk8cMVQ0TGxRvQ90XA4rhnTuw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1581,9 +1578,9 @@ } }, "node_modules/bare-process": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.3.0.tgz", - "integrity": "sha512-HwtAdKbqS98V+jeWAagUdzkrJR4vtuPeeWlsoeD1YKTqM1hKUWM2eU+l2diUYeTqFfxY9b6J9SsBLuBukH0tMQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.4.1.tgz", + "integrity": "sha512-JfcTtymq6akqM2bdyTsP4A4GYJceBzb91k/ECmFps75uFf6uO33SM1bOZsRAqyRXExabsQJLn5S41NBl8HTM4A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1592,15 +1589,14 @@ "bare-events": "^2.3.1", "bare-hrtime": "^2.0.0", "bare-os": "^3.7.1", - "bare-pipe": "^4.0.0", "bare-signals": "^4.0.0", - "bare-tty": "^5.0.0" + "bare-stdio": "^1.0.1" } }, "node_modules/bare-semver": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.2.tgz", - "integrity": "sha512-ESVaN2nzWhcI5tf3Zzcq9aqCZ676VWzqw07eEZ0qxAcEOAFYBa0pWq8sK34OQeHLY3JsfKXZS9mDyzyxGjeLzA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.3.tgz", + "integrity": "sha512-HS/A30bi2+PiRJfU6R4+Kp+6KeLSCSByjYM2iiobOKzLAvtu1CT+S8xWfiU7wz0erknjkUoC+yXy108tzIuP5Q==", "license": "Apache-2.0" }, "node_modules/bare-signals": { @@ -1617,20 +1613,36 @@ "bare": ">=1.7.0" } }, + "node_modules/bare-stdio": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bare-stdio/-/bare-stdio-1.0.2.tgz", + "integrity": "sha512-3WJDqtvVGP4f+j68kyEC05umOYNwKJ1xG+YAXL8yZ605WgNqiRhVaFq+mVIhBt2eKNp7pa5vCQdhOt1pNh79SA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-fs": "^4.5.2", + "bare-pipe": "^4.1.5", + "bare-tty": "^5.0.3" + } + }, "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", "license": "Apache-2.0", "dependencies": { - "streamx": "^2.21.0", + "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { + "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, "bare-buffer": { "optional": true }, @@ -1639,10 +1651,21 @@ } } }, + "node_modules/bare-stylize": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/bare-stylize/-/bare-stylize-0.0.1.tgz", + "integrity": "sha512-l3MjmIl476bWijYWf3RbE+osl4iuXSOMudzp0vAqzIK7gPgn/+G3oAxp8Oin9CFF911KBP0LO9kts8Ci8mGZaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-ansi-escapes": "^2.2.3", + "bare-process": "^4.2.1" + } + }, "node_modules/bare-subprocess": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-5.2.2.tgz", - "integrity": "sha512-L6oXgQ1aWs25RtG5Ky0bDD06p3RAcVVrDDMWb1DfXpHtyEWxamcyIWUbSykxMTWrpLlmSj6ytbb6yoKehGFfmw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-5.2.3.tgz", + "integrity": "sha512-07wwswlV7M3sC9IykbZRZ/jHAkrXFWVLqdBWGv1y0ojCimtRD9hGwxdHmR5FUFmDUZLNsBmTYJNQqgio5+A85Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1680,9 +1703,9 @@ } }, "node_modules/bare-tls": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-2.2.1.tgz", - "integrity": "sha512-hZ+ZqwrUO4dyH7/6WYkYWjgAFNJKjzwEYJiDaMnMs+eRleBDjQ3CvNZawpkw0Ar9jnM9NZK6+f6GqjkZ2FLGmQ==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-2.2.3.tgz", + "integrity": "sha512-dPYBGEXtgLceRFMfGaF2/rqR86/xMxMyrM9ootO/gaRKL/z2uNHJs7aP7IOBtnJF8eUCt5qwMzbKfrKgDIxPLg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1694,9 +1717,9 @@ } }, "node_modules/bare-tty": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/bare-tty/-/bare-tty-5.0.3.tgz", - "integrity": "sha512-jW24RBWRZOMHuSEWC9mh8wUKO5WUNl0UWUTNPn3yZ8qkOqwa8vaV2dR3a+BvblONPk7V35wuvK9P8508+judAg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bare-tty/-/bare-tty-5.1.0.tgz", + "integrity": "sha512-EZLvW4A+XiJgI3TW+e1pMME9PIJsfEXe/DA/WSKzIkq/v7Yarpv/rvG6Z5pGnpo4V/Bd+qopwnCLSR71hMMBYA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1712,25 +1735,24 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz", "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==", - "dev": true, "license": "Apache-2.0", "engines": { "bare": ">=1.2.0" } }, "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" } }, "node_modules/bare-utils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/bare-utils/-/bare-utils-1.5.1.tgz", - "integrity": "sha512-mxCkFvmDU3mlD/mb+pT64kKXOsx2KMsWLQbngN1LB+NOXfhfnRnyvpy3VZc6m7gzQxe57Bsi+aTCBqA4/S3elQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/bare-utils/-/bare-utils-1.6.0.tgz", + "integrity": "sha512-WhQEIkkAxkSnW7u1QgrI0AfNm5JpMruETXeYsb5qnkBJ0TTfNKygZmsh6rkoHBANaV+C/7Jed7bJP9OmEHG7rQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1738,6 +1760,7 @@ "bare-encoding": "^1.0.0", "bare-format": "^1.0.0", "bare-inspect": "^3.0.0", + "bare-stylize": "^0.0.1", "bare-type": "^1.0.6" } }, @@ -1866,7 +1889,7 @@ "node_modules/bfx-facs-http": { "name": "@bitfinex/bfx-facs-http", "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-http.git#850fee8051e0045c8647a95ea152969e3d00f397", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-http.git#46cd482878de7ab227f2b1c93bf070c68ca45e38", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", @@ -1987,9 +2010,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2098,15 +2121,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -2245,9 +2268,9 @@ } }, "node_modules/compact-encoding": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.0.tgz", - "integrity": "sha512-bPQlzwxgzsuOp0wB6G9TVoZ2tGFANJckQfM10xLKUS2fbLe+fFZG6Gi1wYehcMMTcjvtil8oOJSsToMOlCSu4g==", + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.2.tgz", + "integrity": "sha512-/YjhHQE/5L4F7l5Bht69dRbP9RV6zoJPeowi8bMKQxNKe3Nh6hOY8pBGoVE9fz5GaWfEd8fWJ2aU9sB4KZuMYg==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.3.0" @@ -2350,9 +2373,9 @@ } }, "node_modules/corestore": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.8.0.tgz", - "integrity": "sha512-zC9FNHoVzPXiFetVN39QpwJkNtJkqm5jymx7wY+NB6G6cpruEpYjd0/KuDpBtgFK6/JG1l+Qr+DV9ozmz31Dkw==", + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.9.2.tgz", + "integrity": "sha512-dyIktVSVLu0skZ1UM4yODbNbZ46dBpTHypzwlwET1btVuS3YIf66Zz8Tx8h3Xa8nbLO2USGqrYtvPYzSRj2wCA==", "license": "MIT", "dependencies": { "b4a": "^1.6.7", @@ -2573,9 +2596,9 @@ } }, "node_modules/dht-rpc": { - "version": "6.26.3", - "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.26.3.tgz", - "integrity": "sha512-KuLfRv/hecUHipQcTXHpVv4/N4Jhpww5sLdsrn3Edm5oHwzK9SgNV34hNt7a2aVZCOcG5SfP4AvcQ7pI+y9YNg==", + "version": "6.26.4", + "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.26.4.tgz", + "integrity": "sha512-bMI625c13DXaiYN8vHEFmcM4CIauwK1SJgOMWAAqsXhsKXO8WCdp0fgYwTHH40cBB6J9MkDXPa2f79Vj5Ah1iA==", "license": "MIT", "dependencies": { "adaptive-timeout": "^1.0.1", @@ -2692,9 +2715,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2781,16 +2804,16 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", @@ -2802,7 +2825,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", - "safe-array-concat": "^1.1.3" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3009,15 +3032,15 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -3197,6 +3220,28 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-n/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/eslint-plugin-promise": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.6.0.tgz", @@ -3259,30 +3304,6 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3697,16 +3718,16 @@ "license": "MIT" }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -3769,9 +3790,9 @@ } }, "node_modules/fs-native-extensions": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/fs-native-extensions/-/fs-native-extensions-1.4.5.tgz", - "integrity": "sha512-ekV0T//iDm4AvhOcuPaHpxub4DI7HvY5ucLJVDvi7T2J+NZkQ9S6MuvgP0yeQvoqNUaAGyLjVYb1905BF9bpmg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/fs-native-extensions/-/fs-native-extensions-1.5.0.tgz", + "integrity": "sha512-nuZLFm9mGCxvyi7Llww/J4OyifKCS21nEUTAmnlTZp3FObPOvA32aCedCmt4Z+8yk+caqfClNaSCfn/P7T7FLQ==", "license": "Apache-2.0", "dependencies": { "require-addon": "^1.1.0", @@ -4380,9 +4401,9 @@ } }, "node_modules/hypercore": { - "version": "11.27.5", - "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.27.5.tgz", - "integrity": "sha512-/XDAhdSd0/giA/bChonghQSsM7heyBlI52nlpaoEBDkE8dX3h7LqayZC9633qr2CG0Zbzd+WYbHL13H6v48tuA==", + "version": "11.28.1", + "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.28.1.tgz", + "integrity": "sha512-d8jqUilG7Gri8wT+sRdkFTYRPMHGyvBz/NCYHf5rX7+LoivRNRxQm5nWChT7caiUKbcsDak6h7IiEQ5wbD1wCg==", "license": "MIT", "dependencies": { "@hyperswarm/secret-stream": "^6.0.0", @@ -4395,7 +4416,7 @@ "hypercore-crypto": "^3.2.1", "hypercore-errors": "^1.5.0", "hypercore-id-encoding": "^1.2.0", - "hypercore-storage": "^2.0.0", + "hypercore-storage": "^2.8.0", "is-options": "^1.0.1", "nanoassert": "^2.0.0", "protomux": "^3.5.0", @@ -4439,9 +4460,9 @@ } }, "node_modules/hypercore-storage": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.5.5.tgz", - "integrity": "sha512-0W74LFPKhj5XBHnTmzg69PI2m3qYFZ04wycgHqyysnIYopiWZP7ka26yCjf+2UTStgP45PYugHEo9J03Pz3H1A==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.8.0.tgz", + "integrity": "sha512-s35HllFMYvrkSHvkIIklVFmCI/bRVNO7H3pi4mKvWl0XLyhGGGM+NtJ7fpsh82oPRCmL9XtMRO+pT6LiMAO8MA==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.7", @@ -4460,9 +4481,9 @@ } }, "node_modules/hyperdht": { - "version": "6.29.1", - "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.29.1.tgz", - "integrity": "sha512-V7jkUew1wAur/PfTyiB/y7jTzFAOpqF7pPnlicTJ9m7yA6Iq1O6PDjzjqkRayN/k43jtoOnMwI3Bpg5t1PZYmQ==", + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.30.0.tgz", + "integrity": "sha512-LkfeAFVnOIvOpr2ILtJ38CzPgXmye7jXDS12xndVKfNoxzIrM0GonY+Bz5lHutoc08ZbT3Olr/w2NSeq5Pj0Ng==", "license": "MIT", "dependencies": { "@hyperswarm/secret-stream": "^6.6.2", @@ -4471,10 +4492,10 @@ "blind-relay": "^1.3.0", "bogon": "^1.0.0", "compact-encoding": "^2.4.1", - "compact-encoding-net": "^1.0.1", "dht-rpc": "^6.15.1", "hypercore-crypto": "^3.3.0", "hypercore-id-encoding": "^1.2.0", + "hyperdht-address": "^1.0.1", "noise-curve-ed": "^2.0.0", "noise-handshake": "^4.0.0", "record-cache": "^1.1.1", @@ -4489,6 +4510,16 @@ "hyperdht": "bin.js" } }, + "node_modules/hyperdht-address": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hyperdht-address/-/hyperdht-address-1.0.1.tgz", + "integrity": "sha512-v817eJkhryWNtMGQHLxVo6jtfEeIV9k429fRUWUKkp1bm+3/rzH8r5BFef9VT7Jq4Tvgt0Gw11FV32CacsqMZA==", + "license": "Apache-2.0", + "dependencies": { + "compact-encoding": "^2.19.0", + "hyperschema": "^1.20.1" + } + }, "node_modules/hyperschema": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.20.1.tgz", @@ -5283,8 +5314,8 @@ }, "node_modules/lib-js-util-base": { "name": "@bitfinex/lib-js-util-base", - "version": "2.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#8c06f3a377e62ae556f19dead07489475acd4bce", + "version": "2.0.2", + "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#e915c19224d8e334b3260ae3e979a50865722446", "license": "MIT" }, "node_modules/light-my-request": { @@ -5342,9 +5373,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -5551,10 +5582,10 @@ } }, "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "license": "ISC", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "license": "BlueOak-1.0.0", "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -5673,9 +5704,9 @@ } }, "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -6062,9 +6093,9 @@ } }, "node_modules/paparam": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/paparam/-/paparam-1.10.0.tgz", - "integrity": "sha512-p36QQrwU3X6fj5d2JN20B4lcZI/O3fxMVUHc1xD7MNe5bcOf3jIgE0CIK5Zv/s5YAUGlobTJ/kKXrh1FJVrs3w==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/paparam/-/paparam-1.10.1.tgz", + "integrity": "sha512-viyQI64VIja0Za3njIzhoEP8ZVkgPowhZPuG0E96NwBfYJ6ZIyrrlhWGtFPkdN7eYLl2L8CTWL+wla0evm1KKQ==", "dev": true, "license": "Apache-2.0" }, @@ -6132,9 +6163,9 @@ "license": "MIT" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6473,9 +6504,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6749,13 +6780,16 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -6843,9 +6877,9 @@ } }, "node_modules/rocksdb-native": { - "version": "3.13.2", - "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.13.2.tgz", - "integrity": "sha512-FB8gH5eBo+SjqA7uDVGWHe2zlYugF8H775tueWAl+jK26zZvxPP8nXCgs5rZTjMhdBY7wYC2nm3V20pyAKbkcQ==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.15.0.tgz", + "integrity": "sha512-czYx0hOzKAy+HYmJROZm7thvJ1tHFWHNkGI3nomumXWzrJghJdrWo4MIhehGX4OTff0lnOCoB2zu6ekzIKY0rQ==", "license": "Apache-2.0", "dependencies": { "compact-encoding": "^2.15.0", @@ -6971,9 +7005,9 @@ "license": "MIT" }, "node_modules/safety-catch": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/safety-catch/-/safety-catch-1.0.2.tgz", - "integrity": "sha512-C1UYVZ4dtbBxEtvOcpjBaaD27nP8MlvyAQEp2fOTOEe6pfUpk1cDUxij6BR1jZup6rSyUTaBBplK7LanskrULA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safety-catch/-/safety-catch-1.0.3.tgz", + "integrity": "sha512-Zq+J1TefpoEq/HTUabo0YXX5MNvttjWYODGohgPBO2jfko8Wqx3JYMgE823szDFVamdH5PlpByvfiWScTdSYDA==", "license": "MIT" }, "node_modules/same-object": { @@ -7134,14 +7168,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -7332,11 +7366,12 @@ } }, "node_modules/sodium-native": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.0.10.tgz", - "integrity": "sha512-UIw+0AbpCQRuTJF88JWrZomP4O+PXhlWvdopiAJOsUivTyHTf3korMyStxkZuPngSbBEtEfDdc4ewEd8/T4/lA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", "license": "MIT", "dependencies": { + "bare-assert": "^1.2.0", "require-addon": "^1.1.0", "which-runtime": "^1.2.1" }, @@ -7525,9 +7560,9 @@ } }, "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -7773,7 +7808,7 @@ }, "node_modules/svc-facs-httpd-oauth2": { "version": "0.0.1", - "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd-oauth2.git#9a022acff0041d2fed009436a8af83188ccdea86", + "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd-oauth2.git#7b92af92bf5027797c6202709f0b21a79b79cff9", "license": "Apache-2.0", "dependencies": { "@fastify/oauth2": "^7.2.2", @@ -7855,9 +7890,9 @@ } }, "node_modules/tar": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", - "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -8529,9 +8564,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js index c78f44a..4c5ef94 100644 --- a/tests/integration/api.test.js +++ b/tests/integration/api.test.js @@ -38,6 +38,11 @@ test('Api', { timeout: 90000 }, async (main) => { }) const createConfig = () => { + fs.rmSync(`./${baseDir}/store`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/status`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/config`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/db`, { recursive: true, force: true }) + if (!fs.existsSync(`./${baseDir}/config/facs`)) { if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`) fs.mkdirSync(`./${baseDir}/config/facs`) @@ -47,7 +52,10 @@ test('Api', { timeout: 90000 }, async (main) => { const commonConf = { dir_log: 'logs', debug: 0, orks: { 'cluster-1': { rpcPublicKey: '' } }, cacheTiming: {}, featureConfig: {} } const netConf = { r0: {} } const httpdConf = { h0: {} } - const httpdOauthConf = { h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: tokenExpiredUser }, { email: siteOperatorUser, write: true }] } } + const httpdOauthConf = { + h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: tokenExpiredUser }, { email: siteOperatorUser, write: true }] }, + h1: { method: 'microsoft', credentials: { client: { id: 'i', secret: 's' }, tenant: 'test-tenant' }, users: [] } + } const authConf = require('../../config/facs/auth.config.json') superadminUser = authConf.a0.superAdmin @@ -97,6 +105,14 @@ test('Api', { timeout: 90000 }, async (main) => { return token } + const getTestTokenMicrosoft = async (email) => { + worker.worker.authLib._auth.addHandlers({ + microsoft: () => { return { email } } + }) + const token = await worker.worker.auth_a0.authCallbackHandler('microsoft', { ip }) + return token + } + const createUser = async (email, role, token) => { if (!token) token = await getTestToken(superadminUser) @@ -930,6 +946,17 @@ test('Api', { timeout: 90000 }, async (main) => { await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) }) + await main.test('Api: microsoft auth callback handler', async (n) => { + await n.test('should generate valid token from microsoft provider callback', async (t) => { + try { + const token = await getTestTokenMicrosoft(readonlyUser) + t.ok(typeof token === 'string' && token.length > 10, 'should return a non-empty auth token string') + } catch (e) { + t.fail(`Expected microsoft callback token generation to succeed: ${e.message || e}`) + } + }) + }) + await main.test('Api: get finance/ebitda', async (n) => { const api = `${appNodeBaseUrl}${ENDPOINTS.FINANCE_EBITDA}?start=1700000000000&end=1700100000000` await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) diff --git a/tests/integration/ws.test.js b/tests/integration/ws.test.js index e7ea02b..41f89cd 100644 --- a/tests/integration/ws.test.js +++ b/tests/integration/ws.test.js @@ -101,6 +101,11 @@ test('WebSocket endpoint', { timeout: 90000 }, async (main) => { }) const createConfig = () => { + fs.rmSync(`./${baseDir}/store`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/status`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/config`, { recursive: true, force: true }) + fs.rmSync(`./${baseDir}/db`, { recursive: true, force: true }) + if (!fs.existsSync(`./${baseDir}/config/facs`)) { if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`) fs.mkdirSync(`./${baseDir}/config/facs`) @@ -110,7 +115,10 @@ test('WebSocket endpoint', { timeout: 90000 }, async (main) => { const commonConf = { dir_log: 'logs', debug: 0, orks: { 'cluster-1': { rpcPublicKey: '' } }, cacheTiming: {}, featureConfig: {} } const netConf = { r0: {} } const httpdConf = { h0: {} } - const httpdOauthConf = { h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: siteOperatorUser, write: true }] } } + const httpdOauthConf = { + h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: siteOperatorUser, write: true }] }, + h1: { method: 'microsoft', credentials: { client: { id: 'i', secret: 's' }, tenant: 'test-tenant' }, users: [] } + } const authConf = require('../../config/facs/auth.config.json') superadminUser = authConf.a0.superAdmin diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js index d6f5fb0..18db794 100644 --- a/tests/unit/handlers/coolingSystem.handlers.test.js +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -214,12 +214,12 @@ const createMockSnapData = () => ({ function createMockCtx (featureEnabled = true, customDcsResponse = null) { const snapData = createMockSnapData() - const defaultResponse = [{ + const defaultResponse = [[{ id: 'dcs-1', type: 'dcs', tags: ['t-dcs'], last: { snap: snapData } - }] + }]] const featureConfig = { centralDCSSetup: { @@ -233,8 +233,8 @@ function createMockCtx (featureEnabled = true, customDcsResponse = null) { featureConfig, orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: async () => { + dataProxy: { + requestDataMap: async () => { return customDcsResponse !== null ? customDcsResponse : defaultResponse } } @@ -248,9 +248,9 @@ test('isCentralDCSEnabled - returns true with new config', (t) => { t.pass() }) -test('isCentralDCSEnabled - returns true with legacy config', (t) => { +test('isCentralDCSEnabled - ignores legacy config key', (t) => { const ctx = { conf: { featureConfig: { isCentralPCS7Setup: true } } } - t.is(isCentralDCSEnabled(ctx), true) + t.is(isCentralDCSEnabled(ctx), false) t.pass() }) diff --git a/tests/unit/lib/auth.test.js b/tests/unit/lib/auth.test.js index 00b852d..29b296f 100644 --- a/tests/unit/lib/auth.test.js +++ b/tests/unit/lib/auth.test.js @@ -131,6 +131,8 @@ test('AuthLib - start adds OAuth handlers', async (t) => { t.ok(handlersAdded, 'should call addHandlers') t.ok(handlersAdded.google, 'should add google handler') t.ok(typeof handlersAdded.google === 'function', 'google handler should be function') + t.ok(handlersAdded.microsoft, 'should add microsoft handler') + t.ok(typeof handlersAdded.microsoft === 'function', 'microsoft handler should be function') t.pass() }) @@ -448,3 +450,130 @@ test('AuthLib - _resolveOAuthGoogle with null info', async (t) => { t.pass() }) + +test('AuthLib - _resolveOAuthMicrosoft with valid profile mail', async (t) => { + const originalFetch = global.fetch + global.fetch = async (url, opts) => { + t.is(url, 'https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName,otherMails', 'should call graph /me endpoint') + t.is(opts.headers.authorization, 'Bearer ms-token-123', 'should pass bearer token') + return { + ok: true, + json: async () => ({ mail: 'microsoft.user@example.com' }) + } + } + + const authLib = new AuthLib({ + httpc: {}, + httpd: { + server: { + microsoftOAuth2: { + getAccessTokenFromAuthorizationCodeFlow: async () => ({ token: { access_token: 'ms-token-123' } }) + } + } + }, + userService: {}, + auth: {} + }) + + try { + const result = await authLib._resolveOAuthMicrosoft({}, {}) + t.alike(result, { email: 'microsoft.user@example.com' }, 'should return email from profile mail') + } finally { + global.fetch = originalFetch + } + + t.pass() +}) + +test('AuthLib - _resolveOAuthMicrosoft prefers otherMails for guest mail', async (t) => { + const originalFetch = global.fetch + global.fetch = async () => ({ + ok: true, + json: async () => ({ + mail: 'guest_user_example.com#EXT#@tenant.onmicrosoft.com', + userPrincipalName: 'guest_user_example.com#EXT#@tenant.onmicrosoft.com', + otherMails: ['primary.user@example.com'] + }) + }) + + const authLib = new AuthLib({ + httpc: {}, + httpd: { + server: { + microsoftOAuth2: { + getAccessTokenFromAuthorizationCodeFlow: async () => ({ token: { access_token: 'ms-token-123' } }) + } + } + }, + userService: {}, + auth: {} + }) + + try { + const result = await authLib._resolveOAuthMicrosoft({}, {}) + t.alike(result, { email: 'primary.user@example.com' }, 'should prefer non-guest email from otherMails') + } finally { + global.fetch = originalFetch + } + + t.pass() +}) + +test('AuthLib - _resolveOAuthMicrosoft throws when token exchange fails', async (t) => { + const authLib = new AuthLib({ + httpc: {}, + httpd: { + server: { + microsoftOAuth2: { + getAccessTokenFromAuthorizationCodeFlow: async () => { + throw new Error('token exchange failed') + } + } + } + }, + userService: {}, + auth: {} + }) + + try { + await authLib._resolveOAuthMicrosoft({}, {}) + t.fail('should throw on token exchange failure') + } catch (err) { + t.is(err.message, 'token exchange failed', 'should bubble token exchange error message') + } + + t.pass() +}) + +test('AuthLib - _resolveOAuthMicrosoft throws when graph request fails', async (t) => { + const originalFetch = global.fetch + global.fetch = async () => ({ + ok: false, + status: 401, + text: async () => 'unauthorized' + }) + + const authLib = new AuthLib({ + httpc: {}, + httpd: { + server: { + microsoftOAuth2: { + getAccessTokenFromAuthorizationCodeFlow: async () => ({ token: { access_token: 'ms-token-123' } }) + } + } + }, + userService: {}, + auth: {} + }) + + try { + await authLib._resolveOAuthMicrosoft({}, {}) + t.fail('should throw on graph failure') + } catch (err) { + t.is(err.message, 'ERR_MICROSOFT_GRAPH_401: unauthorized', 'should include graph status and body') + } finally { + global.fetch = originalFetch + } + + t.pass() +}) diff --git a/tests/unit/lib/constants.test.js b/tests/unit/lib/constants.test.js index c5d70ba..55e3851 100644 --- a/tests/unit/lib/constants.test.js +++ b/tests/unit/lib/constants.test.js @@ -97,6 +97,7 @@ test('constants - COMMENT_ACTION', (t) => { test('constants - ENDPOINTS', (t) => { t.ok(typeof ENDPOINTS === 'object', 'should be object') t.is(ENDPOINTS.OAUTH_GOOGLE_CALLBACK, '/oauth/google/callback', 'should have OAuth callback endpoint') + t.is(ENDPOINTS.OAUTH_MICROSOFT_CALLBACK, '/oauth/microsoft/callback', 'should have OAuth Microsoft callback endpoint') t.is(ENDPOINTS.USERINFO, '/auth/userinfo', 'should have userinfo endpoint') t.is(ENDPOINTS.TOKEN, '/auth/token', 'should have token endpoint') t.is(ENDPOINTS.USERS, '/auth/users', 'should have users endpoint') diff --git a/tests/unit/routes/auth.routes.test.js b/tests/unit/routes/auth.routes.test.js index 648c5fa..cc5559d 100644 --- a/tests/unit/routes/auth.routes.test.js +++ b/tests/unit/routes/auth.routes.test.js @@ -14,6 +14,7 @@ test('auth routes - route definitions', (t) => { const routeUrls = routes.map(route => route.url) t.ok(routeUrls.includes('/oauth/google/callback'), 'should have OAuth Google callback route') + t.ok(routeUrls.includes('/oauth/microsoft/callback'), 'should have OAuth Microsoft callback route') t.ok(routeUrls.includes('/auth/userinfo'), 'should have userinfo route') t.ok(routeUrls.includes('/auth/token'), 'should have token route') t.ok(routeUrls.includes('/auth/permissions'), 'should have permissions route') @@ -40,13 +41,16 @@ test('auth routes - HTTP methods', (t) => { t.pass() }) -test('auth routes - OAuth callback handler', (t) => { +test('auth routes - OAuth callback handlers exist', (t) => { const mockCtx = { auth_a0: { authCallbackHandler: async () => 'test-token' }, httpdOauth2_h0: { callbackUriUI: () => 'http://localhost:3000/callback' + }, + httpdOauth2_h1: { + callbackUriUI: () => 'http://localhost:3000/ms-callback' } } const routes = require('../../../workers/lib/server/routes/auth.routes.js')(mockCtx) @@ -54,6 +58,71 @@ test('auth routes - OAuth callback handler', (t) => { const oauthRoute = routes.find(r => r.url === '/oauth/google/callback') t.ok(oauthRoute, 'should have OAuth callback route') t.ok(typeof oauthRoute.handler === 'function', 'OAuth callback should have handler') + const microsoftOauthRoute = routes.find(r => r.url === '/oauth/microsoft/callback') + t.ok(microsoftOauthRoute, 'should have Microsoft OAuth callback route') + t.ok(typeof microsoftOauthRoute.handler === 'function', 'Microsoft OAuth callback should have handler') + + t.pass() +}) + +test('auth routes - Google callback redirects with token', async (t) => { + const mockCtx = { + auth_a0: { + authCallbackHandler: async (provider) => { + t.is(provider, 'google', 'should invoke google auth provider') + return 'google-token' + } + }, + httpdOauth2_h0: { + callbackUriUI: () => 'http://localhost:3000/callback' + } + } + const routes = require('../../../workers/lib/server/routes/auth.routes.js')(mockCtx) + const oauthRoute = routes.find(r => r.url === '/oauth/google/callback') + + let redirectUrl + const rep = { + redirect: (url) => { + redirectUrl = url + return url + } + } + + await oauthRoute.handler({}, rep) + t.ok(redirectUrl.includes('http://localhost:3000/callback?'), 'should redirect to UI callback URI') + t.ok(redirectUrl.includes('authToken=google-token'), 'should include auth token in querystring') + t.pass() +}) + +test('auth routes - Microsoft callback redirects with error', async (t) => { + const mockCtx = { + auth_a0: { + authCallbackHandler: async (provider) => { + t.is(provider, 'microsoft', 'should invoke microsoft auth provider') + throw new Error('ERR_MICROSOFT_AUTH') + } + }, + httpdOauth2_h0: { + callbackUriUI: () => 'http://localhost:3000/callback' + }, + httpdOauth2_h1: { + callbackUriUI: () => 'http://localhost:3000/ms-callback' + } + } + const routes = require('../../../workers/lib/server/routes/auth.routes.js')(mockCtx) + const oauthRoute = routes.find(r => r.url === '/oauth/microsoft/callback') + + let redirectUrl + const rep = { + redirect: (url) => { + redirectUrl = url + return url + } + } + + await oauthRoute.handler({}, rep) + t.ok(redirectUrl.includes('http://localhost:3000/ms-callback?'), 'should redirect to microsoft UI callback URI') + t.ok(redirectUrl.includes('error=ERR_MICROSOFT_AUTH'), 'should include error in querystring') t.pass() }) diff --git a/tests/unit/routes/coolingSystem.routes.test.js b/tests/unit/routes/coolingSystem.routes.test.js index b4b3a74..bdb309d 100644 --- a/tests/unit/routes/coolingSystem.routes.test.js +++ b/tests/unit/routes/coolingSystem.routes.test.js @@ -3,6 +3,7 @@ const test = require('brittle') const { testModuleStructure, testHandlerFunctions } = require('../helpers/routeTestHelpers') const { createRoutesForTest } = require('../helpers/mockHelpers') +const { ENDPOINTS } = require('../../../workers/lib/constants') test('coolingSystem routes - module structure', (t) => { testModuleStructure(t, '../../../workers/lib/server/routes/coolingSystem.routes.js', 'coolingSystem') @@ -13,7 +14,7 @@ test('coolingSystem routes - route definitions', (t) => { const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') const routeUrls = routes.map(route => route.url) - t.ok(routeUrls.includes('/auth/cooling-system'), 'should have cooling-system route') + t.ok(routeUrls.includes(ENDPOINTS.COOLING_SYSTEM), 'should have cooling-system route') t.pass() }) @@ -21,7 +22,8 @@ test('coolingSystem routes - route definitions', (t) => { test('coolingSystem routes - HTTP methods', (t) => { const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') - const coolingSystemRoute = routes.find(r => r.url === '/auth/cooling-system') + const coolingSystemRoute = routes.find(r => r.url === ENDPOINTS.COOLING_SYSTEM) + t.ok(coolingSystemRoute, 'cooling-system route should exist') t.is(coolingSystemRoute.method, 'GET', 'cooling-system route should be GET') t.pass() @@ -30,7 +32,8 @@ test('coolingSystem routes - HTTP methods', (t) => { test('coolingSystem routes - schema validation', (t) => { const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') - const coolingSystemRoute = routes.find(r => r.url === '/auth/cooling-system') + const coolingSystemRoute = routes.find(r => r.url === ENDPOINTS.COOLING_SYSTEM) + t.ok(coolingSystemRoute, 'cooling-system route should exist') t.ok(coolingSystemRoute.schema, 'cooling-system route should have schema') t.ok(coolingSystemRoute.schema.querystring, 'should have querystring schema') t.ok(coolingSystemRoute.schema.querystring.required.includes('type'), 'type should be required') diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index 2d425a1..b5af9e1 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -56,6 +56,7 @@ class WrkServerHttp extends TetherWrkBase { trustProxy: true }, 0], ['fac', 'svc-facs-httpd-oauth2', 'h0', 'h0', {}, 0], + ['fac', 'svc-facs-httpd-oauth2', 'h1', 'h1', {}, 0], ['fac', 'svc-facs-auth', 'a0', 'a0', () => ({ sqlite: this.dbSqlite_auth, lru: this.lru_15m @@ -88,9 +89,11 @@ class WrkServerHttp extends TetherWrkBase { const httpd = this.httpd_h0 const httpdAuth = this.httpdOauth2_h0 + const httpdAuthMicrosoft = this.httpdOauth2_h1 if (!this.noAuth) { httpd.addPlugin(httpdAuth.injection()) + httpd.addPlugin(httpdAuthMicrosoft.injection()) } httpd.addPlugin([WebsocketPlugin, {}]) diff --git a/workers/lib/auth.js b/workers/lib/auth.js index 1b299dc..90aaa37 100644 --- a/workers/lib/auth.js +++ b/workers/lib/auth.js @@ -43,7 +43,8 @@ class AuthLib { async start () { this._auth.addHandlers({ - google: this._resolveOAuthGoogle.bind(this) + google: this._resolveOAuthGoogle.bind(this), + microsoft: this._resolveOAuthMicrosoft.bind(this) }) } @@ -110,6 +111,58 @@ class AuthLib { email: info.email } } + + async _resolveOAuthMicrosoft (ctx, req) { + let accessToken + try { + const oauthRes = await this._httpd.server.microsoftOAuth2.getAccessTokenFromAuthorizationCodeFlow(req) + accessToken = oauthRes?.token?.access_token + } catch (err) { + const msg = err?.response?.body?.error_description || err?.message || 'ERR_MICROSOFT_TOKEN_EXCHANGE_FAILED' + throw new Error(msg) + } + + if (!accessToken) { + throw new Error('ERR_MICROSOFT_TOKEN_MISSING') + } + + const graphRes = await fetch('https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName,otherMails', { + headers: { authorization: 'Bearer ' + accessToken } + }) + + if (!graphRes.ok) { + const bodyText = await graphRes.text() + throw new Error(`ERR_MICROSOFT_GRAPH_${graphRes.status}: ${bodyText}`) + } + + const profile = await graphRes.json() + + if (!profile) { + return null + } + + const isAzureGuestUpn = (value) => typeof value === 'string' && value.includes('#EXT#') + const { mail, userPrincipalName, otherMails } = profile + + let email = null + if (mail && !isAzureGuestUpn(mail)) { + email = mail + } else if (Array.isArray(otherMails) && otherMails[0]) { + email = otherMails[0] + } else if (mail) { + email = mail + } else if (userPrincipalName && !isAzureGuestUpn(userPrincipalName)) { + email = userPrincipalName + } else { + email = userPrincipalName || null + } + + if (!email) { + return null + } + + return { email } + } } module.exports = AuthLib diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 78d22dd..655e5ad 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -67,6 +67,7 @@ const COMMENT_ACTION = { const ENDPOINTS = { // OAuth endpoints OAUTH_GOOGLE_CALLBACK: '/oauth/google/callback', + OAUTH_MICROSOFT_CALLBACK: '/oauth/microsoft/callback', // Auth endpoints USERINFO: '/auth/userinfo', diff --git a/workers/lib/server/routes/auth.routes.js b/workers/lib/server/routes/auth.routes.js index 42d9eab..ba67ff7 100644 --- a/workers/lib/server/routes/auth.routes.js +++ b/workers/lib/server/routes/auth.routes.js @@ -30,6 +30,23 @@ module.exports = (ctx) => [ return rep.redirect(redirectUri) } }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.OAUTH_MICROSOFT_CALLBACK, + handler: async (req, rep) => { + const qs = new URLSearchParams() + + try { + const token = await ctx.auth_a0.authCallbackHandler('microsoft', req) + qs.set('authToken', token) + } catch (err) { + qs.set('error', err.message) + } + + const redirectUri = ctx.httpdOauth2_h1?.callbackUriUI?.() + '?' + qs.toString() + return rep.redirect(redirectUri) + } + }, { method: HTTP_METHODS.GET, url: ENDPOINTS.USERINFO, From b6f107656b98363f673f04c3b4e4df14a0c16542 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:28:21 +0530 Subject: [PATCH 28/63] Support site overview endpoints for dcs and miner group wise overview (#55) * Support site overview endpoints for dcs and miner group wise overview * update move to site handlers --- tests/unit/routes/site.routes.test.js | 9 + workers/lib/constants.js | 33 ++- workers/lib/metrics.utils.js | 102 ++++++++- workers/lib/server/handlers/site.handlers.js | 220 ++++++++++++++++++- workers/lib/server/routes/site.routes.js | 28 ++- 5 files changed, 385 insertions(+), 7 deletions(-) diff --git a/tests/unit/routes/site.routes.test.js b/tests/unit/routes/site.routes.test.js index 9c15794..7b67dbc 100644 --- a/tests/unit/routes/site.routes.test.js +++ b/tests/unit/routes/site.routes.test.js @@ -14,6 +14,7 @@ test('site routes - route definitions', (t) => { const routeUrls = routes.map(route => route.url) t.ok(routeUrls.includes('/auth/site/status/live'), 'should have site status live route') + t.ok(routeUrls.includes('/auth/site/overview/groups'), 'should have site overview groups route') t.pass() }) @@ -24,6 +25,9 @@ test('site routes - HTTP methods', (t) => { const siteStatusRoute = routes.find(r => r.url === '/auth/site/status/live') t.is(siteStatusRoute.method, 'GET', 'site status live route should be GET') + const siteOverviewRoute = routes.find(r => r.url === '/auth/site/overview/groups') + t.is(siteOverviewRoute.method, 'GET', 'site overview groups route should be GET') + t.pass() }) @@ -35,6 +39,11 @@ test('site routes - schema validation', (t) => { t.ok(siteStatusRoute.schema.querystring, 'should have querystring schema') t.is(siteStatusRoute.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + const siteOverviewRoute = routes.find(r => r.url === '/auth/site/overview/groups') + t.ok(siteOverviewRoute.schema, 'site overview groups route should have schema') + t.ok(siteOverviewRoute.schema.querystring, 'should have querystring schema') + t.is(siteOverviewRoute.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 655e5ad..8cfff29 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -161,7 +161,9 @@ const ENDPOINTS = { // Cooling System endpoints COOLING_SYSTEM: '/auth/dcs/cooling-system', // Energy System endpoints - ENERGY_SYSTEM: '/auth/dcs/energy-system' + ENERGY_SYSTEM: '/auth/dcs/energy-system', + // Site Overview endpoints + SITE_OVERVIEW_GROUPS: '/auth/site/overview/groups' } const HTTP_METHODS = { @@ -422,6 +424,31 @@ const ENERGY_SYSTEM_PROJECTIONS = { } } +// Site Overview aggregation fields for group-level stats +const SITE_OVERVIEW_AGGR_FIELDS = { + hashrate_mhs_5m_container_group_sum_aggr: 1, + hashrate_mhs_5m_rack_group_sum_aggr: 1, + power_w_container_group_sum_aggr: 1, + power_w_rack_group_sum_aggr: 1, + efficiency_w_ths_container_group_avg_aggr: 1, + efficiency_w_ths_pdu_rack_group_avg_aggr: 1, + offline_cnt: 1, + error_cnt: 1, + not_mining_cnt: 1, + power_mode_sleep_cnt: 1, + power_mode_low_cnt: 1, + power_mode_normal_cnt: 1, + power_mode_high_cnt: 1, + hashrate_mhs_5m_active_container_group_cnt: 1 +} + +// DCS power meter field projections for site overview +const DCS_POWER_METER_FIELDS = { + 'last.snap.stats.dcs_specific.equipment.power_meters': 1, + 'last.snap.config.mining': 1, + 'last.snap.config.energy_layout': 1 +} + const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', SITE_POWER: 'site_power_w', @@ -619,5 +646,7 @@ module.exports = { MINER_MAX_LIMIT, MINER_DEFAULT_LIMIT, COOLING_SYSTEM_PROJECTIONS, - ENERGY_SYSTEM_PROJECTIONS + ENERGY_SYSTEM_PROJECTIONS, + SITE_OVERVIEW_AGGR_FIELDS, + DCS_POWER_METER_FIELDS } diff --git a/workers/lib/metrics.utils.js b/workers/lib/metrics.utils.js index ffe2dff..63773b4 100644 --- a/workers/lib/metrics.utils.js +++ b/workers/lib/metrics.utils.js @@ -107,6 +107,99 @@ function extractKeyEntry (orkResult, keyIndex) { return keyResult[0] || null } +// Hashrate conversion utilities +function mhsToPhs (mhs) { + return Math.round((mhs / 1000000000) * 100) / 100 +} + +function mhsToThs (mhs) { + return mhs / 1000000 +} + +// Rack/Group parsing utilities +function parseRackId (rackKey) { + if (!rackKey || typeof rackKey !== 'string') return null + const idx = rackKey.indexOf('_') + if (idx === -1) return null + return { + group: rackKey.substring(0, idx), + rack: rackKey.substring(idx + 1) + } +} + +function getGroupNumber (groupName) { + const match = groupName.match(/group-(\d+)/i) + return match ? parseInt(match[1], 10) : null +} + +function mergeGroupedField (target, source, isAverage = false) { + if (!source || typeof source !== 'object') return + + for (const [key, value] of Object.entries(source)) { + if (isAverage) { + if (!target[key] || value > target[key]) { + target[key] = value + } + } else { + target[key] = (target[key] || 0) + (value || 0) + } + } +} + +// DCS power meter utilities +function getMeterGroupMapping (meterId, energyLayout) { + const branches = energyLayout?.branches || [] + + for (const branch of branches) { + if (branch.meter === meterId && branch.feeds) { + const match = branch.feeds.match(/Groups?\s+(\d+)-(\d+)/i) + if (match) { + const start = parseInt(match[1], 10) + const end = parseInt(match[2], 10) + const groups = [] + for (let i = start; i <= end; i++) { + groups.push(`group-${i}`) + } + return groups + } + } + } + return [] +} + +function buildGroupPowerFromDCS (powerMeters, hashrateByGroup, energyLayout, miningConfig) { + const groupPower = {} + + const rackMeters = (powerMeters || []).filter(pm => pm.role === 'rack') + + for (const meter of rackMeters) { + const meterPower = meter.power?.value || 0 + const coveredGroups = getMeterGroupMapping(meter.equipment, energyLayout) + + if (coveredGroups.length === 0 || meterPower === 0) continue + + let totalHashrate = 0 + for (const groupName of coveredGroups) { + totalHashrate += hashrateByGroup[groupName] || 0 + } + + if (totalHashrate > 0) { + for (const groupName of coveredGroups) { + const groupHashrate = hashrateByGroup[groupName] || 0 + const proportion = groupHashrate / totalHashrate + groupPower[groupName] = (groupPower[groupName] || 0) + (meterPower * proportion) + } + } else { + const perGroup = meterPower / coveredGroups.length + for (const groupName of coveredGroups) { + groupPower[groupName] = (groupPower[groupName] || 0) + perGroup + } + } + } + + return groupPower +} + module.exports = { parseEntryTs, validateStartEnd, @@ -116,5 +209,12 @@ module.exports = { extractContainerFromMinerKey, extractKeyEntry, resolveInterval, - getIntervalConfig + getIntervalConfig, + mhsToPhs, + mhsToThs, + parseRackId, + getGroupNumber, + mergeGroupedField, + getMeterGroupMapping, + buildGroupPowerFromDCS } diff --git a/workers/lib/server/handlers/site.handlers.js b/workers/lib/server/handlers/site.handlers.js index b6bb873..31f756b 100644 --- a/workers/lib/server/handlers/site.handlers.js +++ b/workers/lib/server/handlers/site.handlers.js @@ -1,6 +1,26 @@ 'use strict' -const { extractKeyEntry } = require('../../metrics.utils') +const { + extractKeyEntry, + mhsToPhs, + mhsToThs, + parseRackId, + getGroupNumber, + mergeGroupedField, + buildGroupPowerFromDCS +} = require('../../metrics.utils') +const { + LOG_KEYS, + WORKER_TYPES, + WORKER_TAGS, + SITE_OVERVIEW_AGGR_FIELDS, + DCS_POWER_METER_FIELDS +} = require('../../constants') +const { + isCentralDCSEnabled, + getDCSTag, + extractDcsThing +} = require('../../dcs.utils') /** * Aggregates miner stats from tailLogMulti results across all orks. @@ -270,6 +290,202 @@ async function getSiteLiveStatus (ctx, req) { ) } +function aggregateOverviewMinerStats (tailLogResults) { + const aggregated = { + hashrateByGroup: {}, + hashrateByRack: {}, + powerByGroup: {}, + powerByRack: {}, + efficiencyByGroup: {}, + efficiencyByRack: {}, + offlineByGroup: {}, + errorByGroup: {}, + notMiningByGroup: {}, + sleepByGroup: {}, + lowByGroup: {}, + normalByGroup: {}, + highByGroup: {}, + activeCountByGroup: {} + } + + for (const orkResult of tailLogResults) { + const entry = extractKeyEntry(orkResult, 0) + if (!entry) continue + + mergeGroupedField(aggregated.hashrateByGroup, entry.hashrate_mhs_5m_container_group_sum_aggr) + mergeGroupedField(aggregated.hashrateByRack, entry.hashrate_mhs_5m_rack_group_sum_aggr) + mergeGroupedField(aggregated.powerByGroup, entry.power_w_container_group_sum_aggr) + mergeGroupedField(aggregated.powerByRack, entry.power_w_rack_group_sum_aggr) + mergeGroupedField(aggregated.efficiencyByGroup, entry.efficiency_w_ths_container_group_avg_aggr, true) + mergeGroupedField(aggregated.efficiencyByRack, entry.efficiency_w_ths_pdu_rack_group_avg_aggr, true) + mergeGroupedField(aggregated.offlineByGroup, entry.offline_cnt) + mergeGroupedField(aggregated.errorByGroup, entry.error_cnt) + mergeGroupedField(aggregated.notMiningByGroup, entry.not_mining_cnt) + mergeGroupedField(aggregated.sleepByGroup, entry.power_mode_sleep_cnt) + mergeGroupedField(aggregated.lowByGroup, entry.power_mode_low_cnt) + mergeGroupedField(aggregated.normalByGroup, entry.power_mode_normal_cnt) + mergeGroupedField(aggregated.highByGroup, entry.power_mode_high_cnt) + mergeGroupedField(aggregated.activeCountByGroup, entry.hashrate_mhs_5m_active_container_group_cnt) + } + + return aggregated +} + +function buildRacksForGroup (groupName, minerStats, racksPerGroup) { + const racks = [] + const rackKeys = Object.keys(minerStats.hashrateByRack) + .filter(key => key.startsWith(groupName + '_')) + .sort((a, b) => { + const rackA = parseRackId(a) + const rackB = parseRackId(b) + if (!rackA || !rackB) return 0 + return rackA.rack.localeCompare(rackB.rack, undefined, { numeric: true }) + }) + + for (const rackKey of rackKeys) { + const parsed = parseRackId(rackKey) + if (!parsed) continue + + const hashrateMhs = minerStats.hashrateByRack[rackKey] || 0 + const powerW = minerStats.powerByRack[rackKey] || 0 + const powerKw = Math.round(powerW / 10) / 100 // W to kW with 2 decimals + const hashrateThs = mhsToThs(hashrateMhs) + const efficiency = hashrateThs > 0 + ? Math.round((powerW / hashrateThs) * 10) / 10 + : minerStats.efficiencyByRack[rackKey] || 0 + + racks.push({ + id: parsed.rack, + name: `Rack ${parsed.rack}`, + efficiency: { value: efficiency, unit: 'W/TH/s' }, + consumption: { value: powerKw, unit: 'kW' }, + hashrate: { value: mhsToPhs(hashrateMhs), unit: 'PH/s' } + }) + } + + return racks +} + +function getMinersPerGroup (miningConfig) { + const racksPerGroup = miningConfig?.racks_per_group || 4 + const minersPerRack = miningConfig?.miners_per_rack || 20 + return racksPerGroup * minersPerRack +} + +function composeGroupsStats (minerStats, dcsThing, totalGroups) { + const groups = [] + const config = dcsThing?.last?.snap?.config || {} + const miningConfig = config.mining || {} + const energyLayout = config.energy_layout || {} + const powerMeters = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.power_meters || [] + const racksPerGroup = miningConfig?.racks_per_group || 4 + const minersPerGroup = getMinersPerGroup(miningConfig) + + const dcsPowerByGroup = buildGroupPowerFromDCS( + powerMeters, + minerStats.hashrateByGroup, + energyLayout, + miningConfig + ) + + const groupNames = Object.keys(minerStats.hashrateByGroup) + .filter(name => name.startsWith('group-')) + .sort((a, b) => getGroupNumber(a) - getGroupNumber(b)) + + const maxGroups = totalGroups || groupNames.length + for (let i = 1; i <= maxGroups; i++) { + const groupName = `group-${i}` + + const hashrateMhs = minerStats.hashrateByGroup[groupName] || 0 + const powerKw = dcsPowerByGroup[groupName] + ? Math.round(dcsPowerByGroup[groupName] * 100) / 100 + : Math.round((minerStats.powerByGroup[groupName] || 0) / 10) / 100 // W to kW + + const hashrateThs = mhsToThs(hashrateMhs) + const efficiency = hashrateThs > 0 + ? Math.round(((powerKw * 1000) / hashrateThs) * 10) / 10 + : minerStats.efficiencyByGroup[groupName] || 0 + + const offline = minerStats.offlineByGroup[groupName] || 0 + const error = minerStats.errorByGroup[groupName] || 0 + const sleep = minerStats.sleepByGroup[groupName] || 0 + const low = minerStats.lowByGroup[groupName] || 0 + const normal = minerStats.normalByGroup[groupName] || 0 + const high = minerStats.highByGroup[groupName] || 0 + const notMining = minerStats.notMiningByGroup[groupName] || 0 + const totalMiners = offline + error + sleep + low + normal + high + notMining + const empty = Math.max(0, minersPerGroup - totalMiners) + + const racks = buildRacksForGroup(groupName, minerStats, racksPerGroup) + + groups.push({ + id: groupName, + name: `Group ${i}`, + summary: { + efficiency: { value: efficiency, unit: 'W/TH/s' }, + consumption: { value: powerKw, unit: 'kW' }, + hashrate: { value: mhsToPhs(hashrateMhs), unit: 'PH/s' } + }, + racks, + status: { + offline, + error, + sleep, + low, + normal, + high, + empty, + not_mining: notMining, + total: totalMiners + } + }) + } + + return { groups } +} + +/** + * GET /auth/site/overview/groups + * + * Returns group-level stats combining: + * - Miner stats from tailLog (hashrate, efficiency, status counts per group/rack) + * - DCS power meter data for consumption + */ +async function getSiteOverviewGroupsStats (ctx, req) { + const tailLogPayload = { + keys: [ + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } + ], + limit: 1, + aggrFields: SITE_OVERVIEW_AGGR_FIELDS + } + + const dcsEnabled = isCentralDCSEnabled(ctx) + let dcsPayload = null + if (dcsEnabled) { + const dcsTag = getDCSTag(ctx) + dcsPayload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields: { id: 1, code: 1, type: 1, tags: 1, ...DCS_POWER_METER_FIELDS } + } + } + + const [tailLogResults, dcsResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload), + dcsEnabled ? ctx.dataProxy.requestDataMap('listThings', dcsPayload) : Promise.resolve(null) + ]) + + const minerStats = aggregateOverviewMinerStats(tailLogResults) + + const dcsThing = dcsResults ? extractDcsThing(dcsResults) : null + + const totalGroups = dcsThing?.last?.snap?.config?.mining?.total_groups || null + + return composeGroupsStats(minerStats, dcsThing, totalGroups) +} + module.exports = { - getSiteLiveStatus + getSiteLiveStatus, + getSiteOverviewGroupsStats } diff --git a/workers/lib/server/routes/site.routes.js b/workers/lib/server/routes/site.routes.js index 461b894..8154688 100644 --- a/workers/lib/server/routes/site.routes.js +++ b/workers/lib/server/routes/site.routes.js @@ -1,7 +1,12 @@ 'use strict' -const { ENDPOINTS, HTTP_METHODS } = require('../../constants') -const { getSiteLiveStatus } = require('../handlers/site.handlers') +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_CAPS, + AUTH_LEVELS +} = require('../../constants') +const { getSiteLiveStatus, getSiteOverviewGroupsStats } = require('../handlers/site.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') module.exports = (ctx) => [ @@ -22,5 +27,24 @@ module.exports = (ctx) => [ ENDPOINTS.SITE_STATUS_LIVE, getSiteLiveStatus ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.SITE_OVERVIEW_GROUPS, + schema: { + querystring: { + type: 'object', + properties: { + overwriteCache: { type: 'boolean' } + } + } + }, + ...createCachedAuthRoute( + ctx, + ['site-overview/groups'], + ENDPOINTS.SITE_OVERVIEW_GROUPS, + getSiteOverviewGroupsStats, + [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] + ) } ] From bb0dab967924a0cf2ad80434e7702d425728af3a Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:03:32 +0530 Subject: [PATCH 29/63] Support new dcs device tags update and fix cooling system apis (#56) * Support new dcs device tags update and fix cooling system apis * update the attributes for rack group * Tests * tests --- .../handlers/coolingSystem.handlers.test.js | 725 +++++++++++++++++- workers/lib/constants.js | 8 +- .../server/handlers/coolingSystem.handlers.js | 301 +++++++- workers/lib/server/handlers/site.handlers.js | 6 +- 4 files changed, 983 insertions(+), 57 deletions(-) diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js index 18db794..1160363 100644 --- a/tests/unit/handlers/coolingSystem.handlers.test.js +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -7,7 +7,10 @@ const { buildCoolingViewData, buildMinersCircuit1View, buildMinersCircuit2View, + buildMinersLayoutView, buildHvacCircuit1View, + buildHvacCircuit2View, + buildHvacLayoutView, buildHvacAmbientView } = require('../../../workers/lib/server/handlers/coolingSystem.handlers') const { COOLING_SYSTEM_PROJECTIONS } = require('../../../workers/lib/constants') @@ -17,14 +20,14 @@ const { extractDcsThing, getDCSTag, isCentralDCSEnabled } = require('../../../wo // All data includes units - app-node is completely agnostic const createMockEquipment = () => ({ pumps: [ - { equipment: 'B-7513', circuit: 'MINER_LOOP', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 42.3, unit: 'A' }, Trip: false }, - { equipment: 'B-7514', circuit: 'MINER_LOOP', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 41.8, unit: 'A' }, Trip: false }, - { equipment: 'B-7515', circuit: 'MINER_LOOP', status: 'Standby', FbkRunOut: false, speed: { value: 0, unit: '%' }, current: { value: 0, unit: 'A' }, Trip: false }, - { equipment: 'B-7516', circuit: 'COOLING_TOWER', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 48.1, unit: 'A' }, Trip: false }, - { equipment: 'B-7517', circuit: 'COOLING_TOWER', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 47.6, unit: 'A' }, Trip: false }, - { equipment: 'B-7501', circuit: 'HVAC_RETURN', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 15.2, unit: 'A' }, Trip: false }, - { equipment: 'B-7502', circuit: 'HVAC_SUPPLY', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 14.8, unit: 'A' }, Trip: false }, - { equipment: 'B-7503', circuit: 'HVAC_CONDENSER', status: 'Running', FbkRunOut: true, speed: { value: 100, unit: '%' }, current: { value: 22.1, unit: 'A' }, Trip: false } + { equipment: 'B-7513', circuit: 'MINER_LOOP', status: 'Running', fbk_run_out: true, speed: { value: 100, unit: '%' }, current: { value: 42.3, unit: 'A' }, trip: false, intlock: false, label: 'Pump 1' }, + { equipment: 'B-7514', circuit: 'MINER_LOOP', status: 'Running', fbk_run_out: true, speed: { value: 100, unit: '%' }, current: { value: 41.8, unit: 'A' }, trip: false, intlock: false, label: 'Pump 2' }, + { equipment: 'B-7515', circuit: 'MINER_LOOP', status: 'Standby', fbk_run_out: false, speed: { value: 0, unit: '%' }, current: { value: 0, unit: 'A' }, trip: false, intlock: false, label: 'Pump 3' }, + { equipment: 'B-7516', circuit: 'COOLING_TOWER', status: 'Running', fbk_run_out: true, speed: { value: 100, unit: '%' }, current: { value: 48.1, unit: 'A' }, trip: false, intlock: false, label: 'CT Pump 1' }, + { equipment: 'B-7517', circuit: 'COOLING_TOWER', status: 'Running', fbk_run_out: true, speed: { value: 100, unit: '%' }, current: { value: 47.6, unit: 'A' }, trip: false, intlock: false, label: 'CT Pump 2' }, + { equipment: 'B-7501', circuit: 'HVAC_RETURN', status: 'Running', fbk_run_out: true, speed: { value: 100, unit: '%' }, current: { value: 15.2, unit: 'A' }, trip: false, intlock: false, label: 'HVAC Return' }, + { equipment: 'B-7502', circuit: 'HVAC_SUPPLY', status: 'Running', fbk_run_out: true, speed: { value: 100, unit: '%' }, current: { value: 14.8, unit: 'A' }, trip: false, intlock: false, label: 'HVAC Supply' }, + { equipment: 'B-7503', circuit: 'HVAC_CONDENSER', status: 'Running', fbk_run_out: true, speed: { value: 100, unit: '%' }, current: { value: 22.1, unit: 'A' }, trip: false, intlock: false, label: 'HVAC Condenser' } ], temperatures: [ { equipment: 'TS-7513', value: 37.1, unit: '°C' }, @@ -103,6 +106,13 @@ const createMockEquipment = () => ({ // Sample config data (site-specific cooling system configuration) // All labels, defaults, and metadata come from config const createMockConfig = () => ({ + mining: { + total_groups: 16, + racks_per_group: 4, + miners_per_rack: 20, + vlan_start: 129, + miner_model: 'S21' + }, cooling_system: { miner_loop: { name: 'Circuit 1 - Miner Loop', @@ -110,25 +120,35 @@ const createMockConfig = () => ({ water_type: 'Cooling Water', defaults: { supply_temp: { value: 37, unit: '°C' }, - return_temp: { value: 47, unit: '°C' } + return_temp: { value: 47, unit: '°C' }, + rated_flow: { value: 400, unit: 'm³/h' }, + pumps_config: { rated_head: 30, rated_flow: 200 } }, line1: { name: 'LINE 1', groups: 'Groups 1-8', + heat_exchanger: 'TC-7501', supply_temp_sensor: 'TS-7513', return_temp_sensor: 'TS-7515', supply_pressure_sensor: 'PIT-7502', return_pressure_sensor: 'PIT-7503', - supply_flow_sensor: 'FIT-7513' + supply_flow_sensor: 'FIT-7513', + control_valve: 'TCV-7501' }, line2: { name: 'LINE 2', groups: 'Groups 9-16', + heat_exchanger: 'TC-7502', supply_temp_sensor: 'TS-7514', return_temp_sensor: 'TS-7516', supply_pressure_sensor: 'PIT-7504', return_pressure_sensor: 'PIT-7505', - supply_flow_sensor: 'FIT-7514' + supply_flow_sensor: 'FIT-7514', + control_valve: 'TCV-7502' + }, + heat_exchangers: { + 'tc-7501': { miner_side_out_sensor: 'TS-7521' }, + 'tc-7502': { miner_side_out_sensor: 'TS-7522' } }, control_valves: { pressure_bypass: 'PCV-7502' @@ -138,10 +158,31 @@ const createMockConfig = () => ({ name: 'Circuit 2 - Cooling Tower Loop', description: 'Filtered Water', water_type: 'Filtered Water', + defaults: { + tower_capacity: { value: 1000, unit: 'kW' }, + tower_capacity_gcal: { value: 0.86, unit: 'Gcal/h' }, + pumps_config: { rated_head: 25, rated_flow: 150 } + }, + tower_level_sensor: 'LIT-7501', + tower_vibration_sensor: 'VT-7501', + tower_fan: 'FAN-7501', + heat_exchangers: { + 'tc-7501': { miner_side_out_sensor: 'TS-7521', tower_side_in_sensor: 'TS-7513', tower_side_out_sensor: 'TS-7515' }, + 'tc-7502': { miner_side_out_sensor: 'TS-7522', tower_side_in_sensor: 'TS-7514', tower_side_out_sensor: 'TS-7516' } + }, makeup: { tank: 'TQ-7501', level_sensor: 'LIT-7503', - level_control_valve: 'LCV-7502' + level_control_valve: 'LCV-7502', + on_off_valves: ['LCV-7501'] + } + }, + makeup: { + pump: 'B-7515', + defaults: { + tank_volume: { value: 50, unit: 'm³' }, + pump_head: { value: 20, unit: 'm' }, + pump_flow: { value: 10, unit: 'm³/h' } } }, hvac_chilled_water: { @@ -361,11 +402,16 @@ test('buildMinersCircuit2View - builds view from enriched equipment', (t) => { t.ok(view, 'should return view') t.is(view.title, 'Circuit 2 - Cooling Tower Loop', 'title from config') t.ok(view.cooling_towers, 'should have cooling_towers') - t.ok(view.makeup_tank, 'should have makeup_tank') - t.ok(view.heat_exchanger_temps, 'should have heat_exchanger_temps') + t.ok(view.makeup, 'should have makeup system') + t.ok(view.makeup.tank, 'should have makeup tank') + t.ok(view.heat_exchangers, 'should have heat_exchangers') + t.ok(view.summary, 'should have summary') // Check enriched data with units t.ok(view.cooling_towers[0].fan_power.unit, 'fan_power should have unit') t.ok(view.cooling_towers[0].level.unit, 'level should have unit') + // Check tower sensor refs + t.ok(view.cooling_towers[0].level_sensor, 'should have level_sensor ref') + t.ok(view.cooling_towers[0].vibration_sensor, 'should have vibration_sensor ref') t.pass() }) @@ -417,7 +463,8 @@ test('buildCoolingViewData - returns miners circuit2 data', (t) => { t.ok(data, 'should return data') t.ok(data.cooling_towers, 'should have cooling_towers') - t.ok(data.makeup_tank, 'should have makeup_tank') + t.ok(data.makeup, 'should have makeup') + t.ok(data.heat_exchangers, 'should have heat_exchangers') t.pass() }) @@ -555,3 +602,651 @@ test('getCoolingSystemData - throws error when DCS data not found', async (t) => } t.pass() }) + +// buildMinersCircuit1View - summary and sensor details +test('buildMinersCircuit1View - computes summary with avgSupplyTemp, avgReturnTemp, deltaT, totalFlow', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.ok(view.summary, 'should have summary') + t.ok(view.summary.supply_temp, 'should have supply_temp summary') + t.ok(view.summary.return_temp, 'should have return_temp summary') + t.ok(view.summary.delta_t, 'should have delta_t summary') + t.ok(view.summary.total_flow, 'should have total_flow summary') + t.ok(view.summary.system_pressure, 'should have system_pressure summary') + t.ok(view.summary.rated_flow, 'should have rated_flow') + t.ok(view.pumps_config, 'should have pumps_config') + t.pass() +}) + +test('buildMinersCircuit1View - lines include sensors arrays', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit1View(equipment, config) + + const line = view.lines[0] + t.ok(line.supply.sensors, 'supply should have sensors') + t.ok(line.supply.sensors.length > 0, 'supply sensors should not be empty') + t.ok(line.return.sensors, 'return should have sensors') + t.ok(line.return.sensors.length > 0, 'return sensors should not be empty') + // Each sensor should have tag and reading + const sensor = line.supply.sensors[0] + t.ok(sensor.tag, 'sensor should have tag') + t.pass() +}) + +test('buildMinersCircuit1View - lines have heat_exchanger with sensors and control_valve', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit1View(equipment, config) + + const line = view.lines[0] + t.ok(line.heat_exchanger, 'should have heat_exchanger') + t.ok(line.heat_exchanger.sensors, 'heat_exchanger should have sensors') + t.ok(line.heat_exchanger.control_valve, 'heat_exchanger should have control_valve') + t.ok(line.heat_exchanger.control_valve.id, 'control_valve should have id') + t.pass() +}) + +test('buildMinersCircuit1View - control_valves from config', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.ok(view.control_valves, 'should have control_valves') + t.ok(view.control_valves.pressure_bypass, 'should have pressure_bypass') + t.is(view.control_valves.pressure_bypass.id, 'PCV-7502', 'bypass valve id') + t.pass() +}) + +test('buildMinersCircuit1View - control_valves null when config has none', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + delete config.cooling_system.miner_loop.control_valves + const view = buildMinersCircuit1View(equipment, config) + + t.is(view.control_valves, null, 'should be null when no control_valves configured') + t.pass() +}) + +test('buildMinersCircuit1View - pumps include label and has_interlock', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.is(view.pumps[0].label, 'Pump 1', 'pump should have label') + t.is(view.pumps[0].has_interlock, false, 'pump should have has_interlock') + t.is(view.pumps[0].has_fault, false, 'pump should have has_fault') + t.pass() +}) + +// buildMinersCircuit2View - detailed tests +test('buildMinersCircuit2View - heat_exchangers have groups mapping', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.ok(view.heat_exchangers.length > 0, 'should have heat exchangers') + // TC-7501 is mapped to line1 groups + const hx1 = view.heat_exchangers.find(hx => hx.id === 'TC-7501') + t.is(hx1.groups, 'Groups 1-8', 'HX should have groups from line config') + t.pass() +}) + +test('buildMinersCircuit2View - summary with pre_hx and post_hx temps', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.ok(view.summary, 'should have summary') + t.ok(view.summary.pre_hx_temp, 'should have pre_hx_temp') + t.ok(view.summary.post_hx_temp, 'should have post_hx_temp') + t.ok(view.summary.delta_t, 'should have delta_t') + t.ok(view.summary.tower_capacity, 'should have tower_capacity') + t.pass() +}) + +test('buildMinersCircuit2View - makeup system includes pump and on_off_valves', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.ok(view.makeup, 'should have makeup') + t.ok(view.makeup.tank, 'should have tank') + t.ok(view.makeup.pump, 'should have pump') + t.is(view.makeup.pump.id, 'B-7515', 'makeup pump id') + t.ok(view.makeup.on_off_valves, 'should have on_off_valves') + t.is(view.makeup.on_off_valves.length, 1, 'should have 1 on_off_valve') + t.pass() +}) + +test('buildMinersCircuit2View - makeup pump null when no pump configured', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + delete config.cooling_system.makeup + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.makeup.pump, null, 'makeup pump should be null') + t.pass() +}) + +// buildMinersLayoutView tests +test('buildMinersLayoutView - builds complete layout', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const stats = { + flow: { + miner_loop: { value: 384, unit: 'm³/h' }, + cooling_tower: { value: 800, unit: 'm³/h' } + } + } + const view = buildMinersLayoutView(equipment, config, stats) + + t.ok(view, 'should return view') + t.ok(view.summary, 'should have summary') + t.is(view.summary.pumps_running, 7, 'should count running pumps') + t.is(view.summary.pumps_total, 8, 'should count total pumps') + t.ok(view.legend, 'should have legend') + t.ok(view.mining_room, 'should have mining_room') + t.is(view.mining_room.total_groups, 16, 'should have total_groups') + t.is(view.mining_room.total_miners, 1280, 'should compute total_miners') + t.is(view.mining_room.miner_model, 'S21', 'should have miner_model') + t.is(view.mining_room.groups.length, 16, 'should have 16 groups') + t.is(view.mining_room.groups[0].id, 'G1', 'first group id') + t.is(view.mining_room.groups[0].vlan, 129, 'first group vlan') + t.pass() +}) + +test('buildMinersLayoutView - circuit1 and circuit2 sections', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersLayoutView(equipment, config, {}) + + t.ok(view.circuit1, 'should have circuit1') + t.ok(view.circuit1.summary, 'circuit1 should have summary') + t.ok(view.circuit1.lines, 'circuit1 should have lines') + t.ok(view.circuit1.pumps, 'circuit1 should have pumps') + t.ok(view.circuit2, 'should have circuit2') + t.ok(view.circuit2.summary, 'circuit2 should have summary') + t.ok(view.circuit2.heat_exchangers, 'circuit2 should have heat_exchangers') + t.ok(view.circuit2.cooling_towers, 'circuit2 should have cooling_towers') + t.ok(view.circuit2.makeup, 'circuit2 should have makeup') + t.pass() +}) + +// buildHvacCircuit2View tests +test('buildHvacCircuit2View - builds view from enriched equipment', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit2View(equipment, config) + + t.ok(view, 'should return view') + t.ok(view.cooling_towers, 'should have cooling_towers') + t.ok(view.pumps, 'should have pumps') + t.ok(view.supply_return, 'should have supply_return') + t.pass() +}) + +// buildHvacLayoutView tests +test('buildHvacLayoutView - builds complete HVAC layout', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacLayoutView(equipment, config) + + t.ok(view, 'should return view') + t.ok(view.summary, 'should have summary') + t.is(view.summary.chiller_running, true, 'chiller should be running') + t.is(view.summary.fan_coils_running, 3, 'should have 3 fan coils running') + t.is(view.summary.fan_coils_total, 4, 'should have 4 fan coils total') + t.is(view.summary.pumps_running, 7, 'should count running pumps') + t.ok(view.circuit1, 'should have circuit1') + t.ok(view.circuit1.chiller, 'should have chiller in circuit1') + t.ok(view.circuit2, 'should have circuit2') + t.ok(view.circuit2.cooling_towers, 'should have cooling_towers in circuit2') + t.pass() +}) + +// buildCoolingViewData - layout views +test('buildCoolingViewData - returns miners layout data', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'miners', 'layout') + + t.ok(data, 'should return data') + t.ok(data.summary, 'should have summary') + t.ok(data.mining_room, 'should have mining_room') + t.ok(data.circuit1, 'should have circuit1') + t.ok(data.circuit2, 'should have circuit2') + t.pass() +}) + +test('buildCoolingViewData - returns hvac layout data', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'hvac', 'layout') + + t.ok(data, 'should return data') + t.ok(data.summary, 'should have summary') + t.ok(data.circuit1, 'should have circuit1') + t.ok(data.circuit2, 'should have circuit2') + t.pass() +}) + +test('buildCoolingViewData - returns hvac circuit2 data', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'hvac', 'circuit2') + + t.ok(data, 'should return data') + t.ok(data.cooling_towers, 'should have cooling_towers') + t.ok(data.pumps, 'should have pumps') + t.pass() +}) + +test('buildCoolingViewData - returns null for invalid miners view', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'miners', 'nonexistent') + + t.is(data, null, 'should return null') + t.pass() +}) + +test('buildCoolingViewData - returns null for invalid hvac view', (t) => { + const snap = createMockSnapData() + const data = buildCoolingViewData(snap, 'hvac', 'nonexistent') + + t.is(data, null, 'should return null') + t.pass() +}) + +// getCoolingSystemData - layout views +test('getCoolingSystemData - returns miners layout data', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'miners', view: 'layout' } } + + const result = await getCoolingSystemData(ctx, req) + + t.is(result.type, 'miners', 'type should be miners') + t.is(result.view, 'layout', 'view should be layout') + t.ok(result.data, 'should have data') + t.ok(result.data.mining_room, 'should have mining_room') + t.pass() +}) + +test('getCoolingSystemData - returns hvac layout data', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'hvac', view: 'layout' } } + + const result = await getCoolingSystemData(ctx, req) + + t.is(result.type, 'hvac', 'type should be hvac') + t.is(result.view, 'layout', 'view should be layout') + t.ok(result.data, 'should have data') + t.ok(result.data.summary, 'should have summary') + t.pass() +}) + +test('getCoolingSystemData - returns hvac circuit2 data', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'hvac', view: 'circuit2' } } + + const result = await getCoolingSystemData(ctx, req) + + t.is(result.type, 'hvac', 'type should be hvac') + t.is(result.view, 'circuit2', 'view should be circuit2') + t.ok(result.data, 'should have data') + t.pass() +}) + +// Empty equipment - exercises all the || [], ?., and fallback branches +const createEmptyEquipment = () => ({ + pumps: [], + temperatures: [], + pressures: [], + flows: [], + levels: [], + heat_exchangers: [], + cooling_towers: [], + valves: [], + tanks: [], + chillers: [], + fan_coils: [], + humidity_sensors: [], + vibration_sensors: [], + flow_switches: [] +}) + +const createMinimalConfig = () => ({ + cooling_system: { + miner_loop: { + line1: { + name: 'LINE 1', + groups: 'Groups 1-8', + supply_temp_sensor: 'MISSING-1', + return_temp_sensor: 'MISSING-2', + supply_pressure_sensor: 'MISSING-3', + return_pressure_sensor: 'MISSING-4', + supply_flow_sensor: 'MISSING-5' + } + }, + cooling_tower_loop: {}, + hvac_chilled_water: {}, + hvac_condenser: {}, + ambient: {}, + view_metadata: { + miners: { + circuit1: { title: 'C1', description: 'D1', water_type: 'W1' }, + circuit2: { title: 'C2', description: 'D2', water_type: 'W2' }, + layout: { title: 'Layout', description: 'Layout Desc' } + }, + hvac: { + circuit1: { title: 'HVAC C1', description: 'HVAC D1' }, + circuit2: { title: 'HVAC C2', description: 'HVAC D2' }, + layout: { title: 'HVAC Layout', description: 'HVAC Layout Desc' }, + ambient: { title: 'Ambient', description: 'Ambient Desc' } + } + } + } +}) + +test('buildMinersCircuit1View - empty equipment uses fallbacks', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.is(view.title, 'C1', 'title from view_metadata fallback') + t.is(view.water_type, 'W1', 'water_type from view_metadata fallback') + t.is(view.target_supply_temp, undefined, 'no target_supply_temp') + t.is(view.target_return_temp, undefined, 'no target_return_temp') + t.is(view.summary.supply_temp, null, 'no supply_temp summary') + t.is(view.summary.return_temp, null, 'no return_temp summary') + t.is(view.summary.delta_t, null, 'no delta_t summary') + t.is(view.summary.total_flow, null, 'no total_flow summary') + t.is(view.summary.system_pressure, null, 'no system_pressure summary') + t.is(view.summary.rated_flow, null, 'no rated_flow') + t.is(view.pumps_config, null, 'no pumps_config') + t.is(view.lines.length, 1, 'should have 1 line from config') + t.is(view.lines[0].heat_exchanger, null, 'heat_exchanger null when not found') + t.is(view.control_valves, null, 'no control_valves') + t.is(view.pumps.length, 0, 'no miner loop pumps') + t.pass() +}) + +test('buildMinersCircuit1View - line with no heat_exchanger config key', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.is(view.lines[0].heat_exchanger, null, 'heat_exchanger null') + t.ok(view.lines[0].supply.sensors.length > 0, 'sensors still created for missing sensors') + t.is(view.lines[0].supply.sensors[0].reading, null, 'sensor reading null when not found') + t.pass() +}) + +test('buildMinersCircuit1View - line with heat_exchanger but no control_valve', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + // Remove control_valve from line config + delete config.cooling_system.miner_loop.line1.control_valve + delete config.cooling_system.miner_loop.line2.control_valve + const view = buildMinersCircuit1View(equipment, config) + + t.ok(view.lines[0].heat_exchanger, 'heat_exchanger present') + t.ok(view.lines[0].heat_exchanger.control_valve, 'control_valve still present via tcv_id') + t.pass() +}) + +test('buildMinersCircuit2View - empty equipment uses fallbacks', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.title, 'C2', 'title from view_metadata fallback') + t.is(view.water_type, 'W2', 'water_type from view_metadata fallback') + t.is(view.summary.pre_hx_temp, null, 'no pre_hx_temp') + t.is(view.summary.post_hx_temp, null, 'no post_hx_temp') + t.is(view.summary.delta_t, null, 'no delta_t') + t.is(view.summary.tower_capacity, null, 'no tower_capacity') + t.is(view.summary.tower_level, null, 'no tower_level') + t.is(view.heat_exchangers.length, 0, 'no heat_exchangers') + t.is(view.cooling_towers.length, 0, 'no cooling_towers') + t.is(view.makeup.pump, null, 'no makeup pump') + t.is(view.makeup.level_control_valve, null, 'no level_control_valve') + t.is(view.makeup.on_off_valves.length, 0, 'no on_off_valves') + t.is(view.pumps.length, 0, 'no pumps') + t.is(view.pumps_config, null, 'no pumps_config') + t.pass() +}) + +test('buildMinersCircuit2View - heat_exchangers with no controlValveId', (t) => { + const equipment = createMockEquipment() + const config = createMinimalConfig() + config.cooling_system.miner_loop.line1 = { ...config.cooling_system.miner_loop.line1, heat_exchanger: 'TC-7501' } + config.cooling_system.miner_loop.line2 = { name: 'LINE 2', groups: 'Groups 9-16', heat_exchanger: 'TC-7502' } + const view = buildMinersCircuit2View(equipment, config) + + t.ok(view.heat_exchangers.length > 0, 'should have heat exchangers') + t.is(view.heat_exchangers[0].control_valve, null, 'control_valve null when not configured') + t.pass() +}) + +test('buildMinersCircuit2View - makeup tank falls back to first tank', (t) => { + const equipment = createMockEquipment() + const config = createMinimalConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.makeup.tank.id, 'TQ-7501', 'falls back to first tank') + t.pass() +}) + +test('buildMinersCircuit2View - on_off_valves with position > 50 are open', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + const onOffValves = view.makeup.on_off_valves + t.is(onOffValves[0].is_open, false, 'valve with position 25 should be closed') + t.pass() +}) + +test('buildMinersLayoutView - uses defaults when no mining config', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildMinersLayoutView(equipment, config, {}) + + t.is(view.mining_room.total_groups, 16, 'default total_groups') + t.is(view.mining_room.racks_per_group, 4, 'default racks_per_group') + t.is(view.mining_room.miners_per_rack, 20, 'default miners_per_rack') + t.is(view.mining_room.total_miners, 1280, 'default total_miners') + t.is(view.mining_room.miner_model, null, 'no miner_model') + t.is(view.mining_room.groups[0].vlan, 129, 'default vlan_start') + t.is(view.summary.pumps_running, 0, 'no pumps running') + t.is(view.summary.pumps_total, 0, 'no pumps total') + t.pass() +}) + +test('buildMinersLayoutView - null flow stats', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildMinersLayoutView(equipment, config, null) + + t.is(view.summary.total_miner_loop_flow, undefined, 'no miner_loop flow') + t.is(view.summary.total_tower_loop_flow, undefined, 'no tower_loop flow') + t.pass() +}) + +test('buildHvacCircuit1View - empty equipment uses fallbacks', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildHvacCircuit1View(equipment, config) + + t.is(view.title, 'HVAC C1', 'title from view_metadata fallback') + t.is(view.chiller, null, 'no chiller') + t.is(view.control_valves, null, 'no control_valves') + t.is(view.return_pumps.length, 0, 'no return_pumps') + t.is(view.supply_pumps.length, 0, 'no supply_pumps') + t.is(view.fan_coils.total, 0, 'no fan coils') + t.is(view.fan_coils.running, 0, 'no fan coils running') + t.pass() +}) + +test('buildHvacCircuit1View - buffer tank falls back to first tank', (t) => { + const equipment = createMockEquipment() + const config = createMinimalConfig() + const view = buildHvacCircuit1View(equipment, config) + + t.is(view.buffer_tank.id, 'TQ-7501', 'falls back to first tank') + t.pass() +}) + +test('buildHvacCircuit1View - with full config has bypass valve and buffer tank', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit1View(equipment, config) + + t.ok(view.control_valves, 'should have control_valves') + t.ok(view.control_valves.pressure_bypass, 'should have pressure_bypass') + t.ok(view.buffer_tank.makeup_valve, 'should have makeup_valve') + t.ok(view.supply_return.flow_switches.length > 0, 'should have flow_switches') + t.pass() +}) + +test('buildHvacCircuit2View - empty equipment uses fallbacks', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildHvacCircuit2View(equipment, config) + + t.is(view.title, 'HVAC C2', 'title from view_metadata fallback') + t.is(view.cooling_towers.length, 0, 'no cooling_towers') + t.is(view.pumps.length, 0, 'no pumps') + t.pass() +}) + +test('buildHvacLayoutView - empty equipment', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildHvacLayoutView(equipment, config) + + t.is(view.summary.chiller_running, false, 'no chiller running') + t.is(view.summary.fan_coils_running, 0, 'no fan coils running') + t.is(view.summary.fan_coils_total, 0, 'no fan coils') + t.is(view.summary.pumps_running, 0, 'no pumps running') + t.is(view.summary.pumps_total, 0, 'no pumps') + t.pass() +}) + +test('buildHvacAmbientView - empty equipment and no rooms config', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + const view = buildHvacAmbientView(equipment, config, {}) + + t.is(view.title, 'Ambient', 'title from view_metadata') + t.is(view.rooms.length, 0, 'no rooms') + t.is(view.summary.average_humidity, null, 'no humidity') + t.is(view.summary.rooms_count, 0, 'no rooms count') + t.is(view.ambient_sensors.length, 0, 'no ambient sensors') + t.pass() +}) + +test('buildHvacAmbientView - rooms with no matching fan coils or humidity', (t) => { + const equipment = createEmptyEquipment() + const config = createMinimalConfig() + config.cooling_system.ambient = { + rooms: [ + { name: 'Empty Room', fan_coils: ['MISSING-FC'], humidity_sensors: ['MISSING-HT'] } + ], + ambient_sensors: ['MISSING-AS'] + } + const view = buildHvacAmbientView(equipment, config, { humidity: { avg: null } }) + + t.is(view.rooms.length, 1, 'should have 1 room') + t.is(view.rooms[0].fan_coils.length, 0, 'no matching fan coils') + t.is(view.rooms[0].humidity_sensors.length, 0, 'no matching humidity sensors') + t.is(view.rooms[0].temperature, null, 'no temperature') + t.is(view.rooms[0].humidity, null, 'no humidity') + t.pass() +}) + +test('buildHvacAmbientView - room with fan coils that have zero temperature', (t) => { + const equipment = createEmptyEquipment() + equipment.fan_coils = [ + { equipment: 'FC-1', is_running: false, temperature: { value: 0, unit: '°C' }, valve_position: { value: 0, unit: '%' } } + ] + const config = createMinimalConfig() + config.cooling_system.ambient = { + rooms: [{ name: 'Cold Room', fan_coils: ['FC-1'], humidity_sensors: [] }] + } + const view = buildHvacAmbientView(equipment, config, {}) + + t.is(view.rooms[0].temperature, null, 'zero temp filtered out') + t.pass() +}) + +test('formatPump - pump with no optional fields', (t) => { + const equipment = { + pumps: [{ equipment: 'P-1', circuit: 'MINER_LOOP', status: 'Off' }], + temperatures: [], + pressures: [], + flows: [], + levels: [], + heat_exchangers: [], + cooling_towers: [], + valves: [], + tanks: [] + } + const config = createMinimalConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.is(view.pumps.length, 1, 'should have 1 pump') + t.is(view.pumps[0].is_running, false, 'fbk_run_out defaults to false') + t.is(view.pumps[0].has_fault, false, 'trip defaults to false') + t.is(view.pumps[0].has_interlock, false, 'intlock defaults to false') + t.is(view.pumps[0].label, undefined, 'no label') + t.pass() +}) + +test('getCoolingSystemData - throws for missing type', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { view: 'circuit1' } } + + try { + await getCoolingSystemData(ctx, req) + t.fail('should throw error') + } catch (err) { + t.is(err.message, 'ERR_INVALID_TYPE') + } + t.pass() +}) + +test('getCoolingSystemData - throws for missing view', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'miners' } } + + try { + await getCoolingSystemData(ctx, req) + t.fail('should throw error') + } catch (err) { + t.is(err.message, 'ERR_INVALID_VIEW') + } + t.pass() +}) + +test('getCoolingSystemData - hvac ambient is valid view', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'hvac', view: 'ambient' } } + + const result = await getCoolingSystemData(ctx, req) + t.is(result.view, 'ambient') + t.pass() +}) + +test('getCoolingSystemData - miners ambient is invalid view', async (t) => { + const ctx = createMockCtx(true) + const req = { query: { type: 'miners', view: 'ambient' } } + + try { + await getCoolingSystemData(ctx, req) + t.fail('should throw error') + } catch (err) { + t.is(err.message, 'ERR_INVALID_VIEW') + } + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 8cfff29..23f1c58 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -340,6 +340,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.valves': 1, 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.fans': 1, 'last.snap.config.cooling_system': 1 }, layout: { @@ -352,8 +353,11 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1, 'last.snap.stats.dcs_specific.equipment.valves': 1, 'last.snap.stats.dcs_specific.equipment.tanks': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.fans': 1, 'last.snap.stats.flow': 1, - 'last.snap.config.cooling_system': 1 + 'last.snap.config.cooling_system': 1, + 'last.snap.config.mining': 1 } }, hvac: { @@ -432,6 +436,8 @@ const SITE_OVERVIEW_AGGR_FIELDS = { power_w_rack_group_sum_aggr: 1, efficiency_w_ths_container_group_avg_aggr: 1, efficiency_w_ths_pdu_rack_group_avg_aggr: 1, + hashrate_mhs_5m_pdu_rack_group_avg_aggr:1, + power_w_pdu_rack_group_sum_aggr: 1, offline_cnt: 1, error_cnt: 1, not_mining_cnt: 1, diff --git a/workers/lib/server/handlers/coolingSystem.handlers.js b/workers/lib/server/handlers/coolingSystem.handlers.js index dde4ce5..c2bd9fd 100644 --- a/workers/lib/server/handlers/coolingSystem.handlers.js +++ b/workers/lib/server/handlers/coolingSystem.handlers.js @@ -23,11 +23,25 @@ function formatPump (pump) { return { id: pump.equipment, name: pump.equipment, + label: pump.label, status: pump.status, - is_running: pump.FbkRunOut || false, + is_running: pump.fbk_run_out || false, speed: pump.speed, current: pump.current, - has_fault: pump.Trip || false + has_fault: pump.trip || false, + has_interlock: pump.intlock || false + } +} + +function getSensorWithTag (sensors, sensorId, defaultConfig) { + if (!sensorId) return null + const sensor = sensors?.find(s => s.equipment === sensorId) + return { + tag: sensorId, + type: sensor?.type || null, + reading: sensor?.value != null + ? { value: sensor.value, unit: sensor.unit } + : (defaultConfig || null) } } @@ -43,23 +57,45 @@ function buildMinersCircuit1View (equipment, config) { const findHx = (hxId) => (heatExchangers || []).find(hx => hx.equipment === hxId) + const hxConfigs = coolingConfig.heat_exchangers || {} + const lines = [] const lineConfigs = [coolingConfig.line1, coolingConfig.line2].filter(Boolean) for (const lineConfig of lineConfigs) { const hx = findHx(lineConfig.heat_exchanger) + const hxConfigKey = lineConfig.heat_exchanger?.toLowerCase() + const hxSensorConfig = hxConfigs[hxConfigKey] || {} + + const supplyTempSensor = getSensorWithTag(temperatures, lineConfig.supply_temp_sensor, coolingConfig.defaults?.supply_temp) + const supplyPressureSensor = getSensorWithTag(pressures, lineConfig.supply_pressure_sensor) + const supplyFlowSensor = getSensorWithTag(flows, lineConfig.supply_flow_sensor) + const returnTempSensor = getSensorWithTag(temperatures, lineConfig.return_temp_sensor, coolingConfig.defaults?.return_temp) + const returnPressureSensor = getSensorWithTag(pressures, lineConfig.return_pressure_sensor) + + const minerSideOutSensorId = hxSensorConfig.miner_side_out_sensor + const minerSideOutSensor = getSensorWithTag(temperatures, minerSideOutSensorId) + if (minerSideOutSensor) { + minerSideOutSensor.role = 'post_hx' + minerSideOutSensor.target = coolingConfig.defaults?.supply_temp || null + } + + const controlValveId = lineConfig.control_valve + const controlValve = valves?.find(v => v.equipment === controlValveId) lines.push({ name: lineConfig.name, groups: lineConfig.groups, supply: { - temperature: hx?.miner_side_out_temp || getSensorReading(temperatures, lineConfig.supply_temp_sensor, coolingConfig.defaults?.supply_temp), + temperature: supplyTempSensor?.reading || getSensorReading(temperatures, lineConfig.supply_temp_sensor, coolingConfig.defaults?.supply_temp), pressure: getSensorReading(pressures, lineConfig.supply_pressure_sensor), - flow: getSensorReading(flows, lineConfig.supply_flow_sensor) + flow: getSensorReading(flows, lineConfig.supply_flow_sensor), + sensors: [supplyTempSensor, supplyPressureSensor, supplyFlowSensor].filter(Boolean) }, return: { temperature: getSensorReading(temperatures, lineConfig.return_temp_sensor, coolingConfig.defaults?.return_temp), - pressure: getSensorReading(pressures, lineConfig.return_pressure_sensor) + pressure: getSensorReading(pressures, lineConfig.return_pressure_sensor), + sensors: [returnTempSensor, returnPressureSensor].filter(Boolean) }, heat_exchanger: hx ? { @@ -69,27 +105,65 @@ function buildMinersCircuit1View (equipment, config) { miner_side_out_temp: hx.miner_side_out_temp, tower_side_in_temp: hx.tower_side_in_temp, tower_side_out_temp: hx.tower_side_out_temp, + sensors: [ + minerSideOutSensor, + controlValve + ? { + tag: controlValveId, + type: controlValve.type || controlValve.description, + reading: controlValve.position || hx.tcv_position + } + : null + ].filter(Boolean), control_valve: { - id: hx.tcv_id || lineConfig.control_valve, - position: hx.tcv_position + id: hx.tcv_id || controlValveId, + position: controlValve?.position || hx.tcv_position } } : null }) } - const bypassValveId = coolingConfig.control_valves?.pressure_bypass - const bypassValve = valves?.find(v => v.equipment === bypassValveId) - const controlValves = bypassValveId - ? { - pressure_bypass: { - id: bypassValveId, - position: bypassValve?.position - } - } + // Compute summary values from line sensor data — units derived from sensors + const tempUnit = lines[0]?.supply?.temperature?.unit + const flowUnit = lines[0]?.supply?.flow?.unit + const pressureUnit = lines[0]?.supply?.pressure?.unit + + const allSupplyTemps = lines.map(l => l.supply.temperature?.value).filter(v => v != null) + const allReturnTemps = lines.map(l => l.return.temperature?.value).filter(v => v != null) + const allSupplyFlows = lines.map(l => l.supply.flow?.value).filter(v => v != null) + const allSupplyPressures = lines.map(l => l.supply.pressure?.value).filter(v => v != null) + + const avgSupplyTemp = allSupplyTemps.length > 0 + ? Math.round((allSupplyTemps.reduce((a, b) => a + b, 0) / allSupplyTemps.length) * 10) / 10 + : null + const avgReturnTemp = allReturnTemps.length > 0 + ? Math.round((allReturnTemps.reduce((a, b) => a + b, 0) / allReturnTemps.length) * 10) / 10 + : null + const totalFlow = allSupplyFlows.length > 0 + ? Math.round(allSupplyFlows.reduce((a, b) => a + b, 0) * 10) / 10 + : null + const systemPressure = allSupplyPressures.length > 0 + ? Math.round((allSupplyPressures.reduce((a, b) => a + b, 0) / allSupplyPressures.length) * 10) / 10 : null + const deltaT = (avgSupplyTemp != null && avgReturnTemp != null) + ? Math.round((avgReturnTemp - avgSupplyTemp) * 10) / 10 + : null + + const controlValveEntries = coolingConfig.control_valves || {} + const controlValves = {} + for (const [role, valveId] of Object.entries(controlValveEntries)) { + const valve = valves?.find(v => v.equipment === valveId) + controlValves[role] = { + id: valveId, + type: valve?.type || null, + description: valve?.description || null, + position: valve?.position || null, + setpoint: valve?.setpoint || null + } + } - const minerPumps = filterPumpsByCircuit(pumps, 'MINER_LOOP').map(formatPump) + const formattedPumps = filterPumpsByCircuit(pumps, 'MINER_LOOP').map(formatPump) return { title: coolingConfig.name || viewConfig.title, @@ -97,44 +171,152 @@ function buildMinersCircuit1View (equipment, config) { water_type: coolingConfig.water_type || viewConfig.water_type, target_supply_temp: coolingConfig.defaults?.supply_temp, target_return_temp: coolingConfig.defaults?.return_temp, + summary: { + supply_temp: avgSupplyTemp != null ? { value: avgSupplyTemp, unit: tempUnit } : null, + return_temp: avgReturnTemp != null ? { value: avgReturnTemp, unit: tempUnit } : null, + delta_t: deltaT != null ? { value: deltaT, unit: tempUnit } : null, + total_flow: totalFlow != null ? { value: totalFlow, unit: flowUnit } : null, + rated_flow: coolingConfig.defaults?.rated_flow || null, + system_pressure: systemPressure != null ? { value: systemPressure, unit: pressureUnit } : null + }, + pumps_config: coolingConfig.defaults?.pumps_config || null, lines, - control_valves: controlValves, - pumps: minerPumps + control_valves: Object.keys(controlValves).length > 0 ? controlValves : null, + pumps: formattedPumps } } function buildMinersCircuit2View (equipment, config) { const pumps = equipment.pumps + const temperatures = equipment.temperatures const levels = equipment.levels const heatExchangers = equipment.heat_exchangers const coolingTowers = equipment.cooling_towers const valves = equipment.valves const tanks = equipment.tanks const towerConfig = config?.cooling_system?.cooling_tower_loop || {} + const minerLoopConfig = config?.cooling_system?.miner_loop || {} + const makeupGlobalConfig = config?.cooling_system?.makeup || {} const viewConfig = config?.cooling_system?.view_metadata?.miners?.circuit2 || {} + // Build HX → groups mapping from miner_loop line configs + const hxGroupsMap = {} + const lineConfigs = [minerLoopConfig.line1, minerLoopConfig.line2].filter(Boolean) + for (const lineConfig of lineConfigs) { + if (lineConfig.heat_exchanger) { + hxGroupsMap[lineConfig.heat_exchanger] = lineConfig.groups + } + } + + // Heat exchangers with full sensor detail + const hxConfigs = towerConfig.heat_exchangers || {} + const targetSupplyTemp = minerLoopConfig.defaults?.supply_temp || null + const heatExchangerData = (heatExchangers || []).map(hx => { + const hxConfigKey = hx.equipment?.toLowerCase() + const hxSensorConfig = hxConfigs[hxConfigKey] || {} + const controlValveId = minerLoopConfig.heat_exchangers?.[hxConfigKey]?.control_valve || + minerLoopConfig.heat_exchangers?.[hx.equipment]?.control_valve + const controlValve = controlValveId ? valves?.find(v => v.equipment === controlValveId) : null + + const minerSideOutSensor = getSensorWithTag(temperatures, hxSensorConfig.miner_side_out_sensor) + if (minerSideOutSensor) { + minerSideOutSensor.role = 'post_hx' + minerSideOutSensor.target = targetSupplyTemp + } + const towerSideInSensor = getSensorWithTag(temperatures, hxSensorConfig.tower_side_in_sensor) + if (towerSideInSensor) towerSideInSensor.role = 'tower_side_in' + const towerSideOutSensor = getSensorWithTag(temperatures, hxSensorConfig.tower_side_out_sensor) + if (towerSideOutSensor) towerSideOutSensor.role = 'tower_side_out' + + return { + id: hx.equipment, + name: hx.equipment, + groups: hxGroupsMap[hx.equipment] || null, + is_active: hx.is_active, + miner_side_out_temp: hx.miner_side_out_temp, + tower_side_in_temp: hx.tower_side_in_temp, + tower_side_out_temp: hx.tower_side_out_temp, + sensors: [ + controlValve + ? { tag: controlValveId, type: controlValve.type, reading: controlValve.position || hx.tcv_position } + : null, + minerSideOutSensor, + towerSideInSensor, + towerSideOutSensor + ].filter(Boolean), + control_valve: controlValveId + ? { id: controlValveId, position: controlValve?.position || hx.tcv_position } + : null + } + }) + + // Summary: pre-HX and post-HX temps from heat exchangers + const allTowerSideIn = heatExchangerData.map(hx => hx.tower_side_in_temp?.value).filter(v => v != null) + const allTowerSideOut = heatExchangerData.map(hx => hx.tower_side_out_temp?.value).filter(v => v != null) + const preHxTemp = allTowerSideIn.length > 0 + ? Math.round((allTowerSideIn.reduce((a, b) => a + b, 0) / allTowerSideIn.length) * 10) / 10 + : null + const postHxTemp = allTowerSideOut.length > 0 + ? Math.round((allTowerSideOut.reduce((a, b) => a + b, 0) / allTowerSideOut.length) * 10) / 10 + : null + const deltaT = (preHxTemp != null && postHxTemp != null) + ? Math.round((postHxTemp - preHxTemp) * 10) / 10 + : null + const tempUnit = heatExchangerData[0]?.tower_side_in_temp?.unit + + // Tower level from config sensor + const towerLevelSensor = towerConfig.tower_level_sensor + const towerLevel = getSensorReading(levels, towerLevelSensor) + + // Cooling towers with sensor tag references + const towerVibrationSensorId = towerConfig.tower_vibration_sensor + const towerFanId = towerConfig.tower_fan + const towerData = (coolingTowers || []).map(ct => ({ id: ct.equipment, name: ct.equipment, is_running: ct.is_running, fan_status: ct.fan_status, + fan_speed: ct.fan_speed, fan_power: ct.fan_power, + fan_id: towerFanId, level: ct.level, - vibration: ct.vibration + level_sensor: towerLevelSensor, + vibration: ct.vibration, + vibration_sensor: towerVibrationSensorId })) + // Makeup water system const makeupConfig = towerConfig.makeup || {} const makeupTankId = makeupConfig.tank || tanks?.[0]?.equipment const makeupLevelValve = valves?.find(v => v.equipment === makeupConfig.level_control_valve) const makeupOnOffValves = makeupConfig.on_off_valves || [] - - const makeupTank = { - id: makeupTankId, - name: makeupTankId, - level: getSensorReading(levels, makeupConfig.level_sensor), + const makeupPumpId = makeupGlobalConfig.pump || null + const makeupPump = makeupPumpId ? (pumps || []).find(p => p.equipment === makeupPumpId) : null + + const makeupSystem = { + tank: { + id: makeupTankId, + name: makeupTankId, + volume: makeupGlobalConfig.defaults?.tank_volume || null, + level: getSensorReading(levels, makeupConfig.level_sensor), + level_sensor: makeupConfig.level_sensor + }, + pump: makeupPump + ? { + id: makeupPump.equipment, + name: makeupPump.equipment, + status: makeupPump.status, + is_running: makeupPump.fbk_run_out || false, + rated_head: makeupGlobalConfig.defaults?.pump_head || null, + rated_flow: makeupGlobalConfig.defaults?.pump_flow || null + } + : null, level_control_valve: makeupConfig.level_control_valve ? { id: makeupConfig.level_control_valve, + type: makeupLevelValve?.type || null, + description: makeupLevelValve?.description || null, position: makeupLevelValve?.position } : null, @@ -142,29 +324,33 @@ function buildMinersCircuit2View (equipment, config) { const valve = valves?.find(v => v.equipment === vid) return { id: vid, + type: valve?.type || null, + position: valve?.position, is_open: valve?.position?.value > 50 } }) } - const hxTempSensors = {} - for (const hx of (heatExchangers || [])) { - hxTempSensors[hx.equipment] = { - miner_side_out: hx.miner_side_out_temp, - tower_side_in: hx.tower_side_in_temp, - tower_side_out: hx.tower_side_out_temp - } - } - const towerPumps = filterPumpsByCircuit(pumps, 'COOLING_TOWER').map(formatPump) return { title: towerConfig.name || viewConfig.title, description: towerConfig.description || viewConfig.description, water_type: towerConfig.water_type || viewConfig.water_type, + summary: { + pre_hx_temp: preHxTemp != null ? { value: preHxTemp, unit: tempUnit } : null, + post_hx_temp: postHxTemp != null ? { value: postHxTemp, unit: tempUnit } : null, + delta_t: deltaT != null ? { value: deltaT, unit: tempUnit } : null, + tower_capacity: towerConfig.defaults?.tower_capacity || null, + tower_capacity_gcal: towerConfig.defaults?.tower_capacity_gcal || null, + tower_level: towerLevel + ? { ...towerLevel, sensor: towerLevelSensor } + : null + }, + pumps_config: towerConfig.defaults?.pumps_config || null, + heat_exchangers: heatExchangerData, cooling_towers: towerData, - makeup_tank: makeupTank, - heat_exchanger_temps: hxTempSensors, + makeup: makeupSystem, pumps: towerPumps } } @@ -174,28 +360,65 @@ function buildMinersLayoutView (equipment, config, stats) { const circuit2 = buildMinersCircuit2View(equipment, config) const { pumps } = equipment const flowStats = stats?.flow || {} + const miningConfig = config?.mining || {} const viewConfig = config?.cooling_system?.view_metadata?.miners?.layout || {} + // Mining room groups grid + const totalGroups = miningConfig.total_groups || 16 + const racksPerGroup = miningConfig.racks_per_group || 4 + const minersPerRack = miningConfig.miners_per_rack || 20 + const vlanStart = miningConfig.vlan_start || 129 + const totalMiners = totalGroups * racksPerGroup * minersPerRack + + const groups = [] + for (let i = 1; i <= totalGroups; i++) { + groups.push({ + id: `G${i}`, + name: `G${i}`, + vlan: vlanStart + (i - 1) + }) + } + return { title: viewConfig.title, description: viewConfig.description, summary: { total_miner_loop_flow: flowStats.miner_loop, total_tower_loop_flow: flowStats.cooling_tower, - pumps_running: (pumps || []).filter(p => p.FbkRunOut).length, + pumps_running: (pumps || []).filter(p => p.fbk_run_out).length, pumps_total: (pumps || []).length }, + legend: { + c1_supply_temp: circuit1.summary?.supply_temp, + c1_return_temp: circuit1.summary?.return_temp, + c2_tower_cold: circuit2.summary?.pre_hx_temp, + c2_tower_hot: circuit2.summary?.post_hx_temp + }, + mining_room: { + total_groups: totalGroups, + racks_per_group: racksPerGroup, + miners_per_rack: minersPerRack, + total_miners: totalMiners, + miner_model: miningConfig.miner_model || null, + groups + }, circuit1: { name: circuit1.title, water_type: circuit1.water_type, + summary: circuit1.summary, + pumps_config: circuit1.pumps_config, lines: circuit1.lines, + control_valves: circuit1.control_valves, pumps: circuit1.pumps }, circuit2: { name: circuit2.title, water_type: circuit2.water_type, + summary: circuit2.summary, + pumps_config: circuit2.pumps_config, + heat_exchangers: circuit2.heat_exchangers, cooling_towers: circuit2.cooling_towers, - makeup_tank: circuit2.makeup_tank, + makeup: circuit2.makeup, pumps: circuit2.pumps } } @@ -371,7 +594,7 @@ function buildHvacLayoutView (equipment, config) { fan_coils_running: circuit1.fan_coils.running, fan_coils_total: circuit1.fan_coils.total, cooling_towers_running: circuit2.cooling_towers.filter(ct => ct.is_running).length, - pumps_running: (pumps || []).filter(p => p.FbkRunOut).length, + pumps_running: (pumps || []).filter(p => p.fbk_run_out).length, pumps_total: (pumps || []).length }, circuit1: { diff --git a/workers/lib/server/handlers/site.handlers.js b/workers/lib/server/handlers/site.handlers.js index 31f756b..d895b59 100644 --- a/workers/lib/server/handlers/site.handlers.js +++ b/workers/lib/server/handlers/site.handlers.js @@ -312,10 +312,12 @@ function aggregateOverviewMinerStats (tailLogResults) { const entry = extractKeyEntry(orkResult, 0) if (!entry) continue + console.log("entry", entry) + mergeGroupedField(aggregated.hashrateByGroup, entry.hashrate_mhs_5m_container_group_sum_aggr) - mergeGroupedField(aggregated.hashrateByRack, entry.hashrate_mhs_5m_rack_group_sum_aggr) + mergeGroupedField(aggregated.hashrateByRack, entry.hashrate_mhs_5m_pdu_rack_group_avg_aggr) mergeGroupedField(aggregated.powerByGroup, entry.power_w_container_group_sum_aggr) - mergeGroupedField(aggregated.powerByRack, entry.power_w_rack_group_sum_aggr) + mergeGroupedField(aggregated.powerByRack, entry.power_w_pdu_rack_group_sum_aggr) mergeGroupedField(aggregated.efficiencyByGroup, entry.efficiency_w_ths_container_group_avg_aggr, true) mergeGroupedField(aggregated.efficiencyByRack, entry.efficiency_w_ths_pdu_rack_group_avg_aggr, true) mergeGroupedField(aggregated.offlineByGroup, entry.offline_cnt) From f54cc68b8dcc19705e14c075154d9771a94c183c Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:00:05 +0530 Subject: [PATCH 30/63] Add hvac circuit 1 and layout api updates (#58) * Add hvac circuit 1 and layout api updates * Update hvac circuit2 api --- workers/lib/constants.js | 3 + .../server/handlers/coolingSystem.handlers.js | 221 ++++++++++++++---- 2 files changed, 180 insertions(+), 44 deletions(-) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 23f1c58..0d47ca2 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -369,6 +369,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.levels': 1, 'last.snap.stats.dcs_specific.equipment.chillers': 1, 'last.snap.stats.dcs_specific.equipment.fan_coils': 1, + 'last.snap.stats.dcs_specific.equipment.fans': 1, 'last.snap.stats.dcs_specific.equipment.valves': 1, 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.flow_switches': 1, @@ -392,9 +393,11 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.chillers': 1, 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1, 'last.snap.stats.dcs_specific.equipment.fan_coils': 1, + 'last.snap.stats.dcs_specific.equipment.fans': 1, 'last.snap.stats.dcs_specific.equipment.valves': 1, 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.flow_switches': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, 'last.snap.config.cooling_system': 1 }, ambient: { diff --git a/workers/lib/server/handlers/coolingSystem.handlers.js b/workers/lib/server/handlers/coolingSystem.handlers.js index c2bd9fd..755a53b 100644 --- a/workers/lib/server/handlers/coolingSystem.handlers.js +++ b/workers/lib/server/handlers/coolingSystem.handlers.js @@ -434,10 +434,14 @@ function buildHvacCircuit1View (equipment, config) { const fanCoils = equipment.fan_coils const valves = equipment.valves const tanks = equipment.tanks + const fans = equipment.fans const flowSwitches = equipment.flow_switches const chilledConfig = config?.cooling_system?.hvac_chilled_water || {} const viewConfig = config?.cooling_system?.view_metadata?.hvac?.circuit1 || {} + const supplyReturnConfig = chilledConfig.supply_return || {} + const condenserConfig = chilledConfig.condenser || {} + const chiller = chillers?.[0] const chillerData = chiller ? { @@ -445,31 +449,55 @@ function buildHvacCircuit1View (equipment, config) { name: chiller.equipment, is_running: chiller.is_running, mode: chiller.mode, + capacity_tr: chilledConfig.chiller_capacity_tr || null, cooling_capacity: chiller.cooling_capacity, + compressor_load: chiller.compressor_load, power_consumption: chiller.power_consumption, evaporator_temp: chiller.evaporator_temp, - condenser_temp: chiller.condenser_temp + condenser_temp: chiller.condenser_temp, + chilled_water_side: { + inlet_temp: getSensorWithTag(temperatures, supplyReturnConfig.return_temp_sensor), + outlet_temp: getSensorWithTag(temperatures, supplyReturnConfig.supply_temp_sensor), + inlet_flow: getSensorWithTag(flows, supplyReturnConfig.return_flow_sensor), + outlet_flow: getSensorWithTag(flows, supplyReturnConfig.supply_flow_sensor), + flow_switch: supplyReturnConfig.flow_switches?.[0] + ? { tag: supplyReturnConfig.flow_switches[0], is_active: (flowSwitches || []).find(fs => fs.equipment === supplyReturnConfig.flow_switches[0])?.is_active } + : null + }, + condenser_water_side: { + inlet_temp: getSensorWithTag(temperatures, condenserConfig.inlet_temp_sensor), + outlet_temp: getSensorWithTag(temperatures, condenserConfig.outlet_temp_sensor), + inlet_flow: getSensorWithTag(flows, condenserConfig.inlet_flow_sensor), + outlet_flow: getSensorWithTag(flows, condenserConfig.outlet_flow_sensor), + flow_switch: supplyReturnConfig.flow_switches?.[1] + ? { tag: supplyReturnConfig.flow_switches[1], is_active: (flowSwitches || []).find(fs => fs.equipment === supplyReturnConfig.flow_switches[1])?.is_active } + : null + } } : null - const supplyReturnConfig = chilledConfig.supply_return || {} + const supplyTemp = getSensorReading(temperatures, supplyReturnConfig.supply_temp_sensor, chilledConfig.defaults?.supply_temp) + const returnTemp = getSensorReading(temperatures, supplyReturnConfig.return_temp_sensor, chilledConfig.defaults?.return_temp) + const supplyFlow = getSensorReading(flows, supplyReturnConfig.supply_flow_sensor) + const returnFlow = getSensorReading(flows, supplyReturnConfig.return_flow_sensor) + const systemPressure = getSensorReading(pressures, supplyReturnConfig.pressure_sensor) + const supplyReturn = { supply: { - temperature: getSensorReading(temperatures, supplyReturnConfig.supply_temp_sensor, chilledConfig.defaults?.supply_temp), - flow: getSensorReading(flows, supplyReturnConfig.supply_flow_sensor), - pressure: getSensorReading(pressures, supplyReturnConfig.pressure_sensor) + temperature: supplyTemp, + flow: supplyFlow, + pressure: systemPressure }, return: { - temperature: getSensorReading(temperatures, supplyReturnConfig.return_temp_sensor, chilledConfig.defaults?.return_temp), - flow: getSensorReading(flows, supplyReturnConfig.return_flow_sensor) + temperature: returnTemp, + flow: returnFlow }, - flow_switches: (flowSwitches || []).map(fs => ({ - id: fs.equipment, - is_active: fs.is_active - })) + flow_switches: (supplyReturnConfig.flow_switches || []).map(fsId => { + const fs = (flowSwitches || []).find(f => f.equipment === fsId) + return { id: fsId, is_active: fs?.is_active || false } + }) } - const condenserConfig = chilledConfig.condenser || {} const condenser = { inlet: { temperature: getSensorReading(temperatures, condenserConfig.inlet_temp_sensor), @@ -481,31 +509,48 @@ function buildHvacCircuit1View (equipment, config) { } } + const tempUnit = supplyTemp?.unit + const flowUnit = supplyFlow?.unit + const avgSupplyTemp = supplyTemp?.value + const avgReturnTemp = returnTemp?.value + const totalFlow = supplyFlow?.value + const deltaT = (avgSupplyTemp != null && avgReturnTemp != null) + ? Math.round((avgReturnTemp - avgSupplyTemp) * 10) / 10 + : null + const bufferConfig = chilledConfig.buffer_tank || {} const bufferTankId = bufferConfig.tank || tanks?.[0]?.equipment const makeupValve = valves?.find(v => v.equipment === bufferConfig.makeup_valve) const bufferTank = { id: bufferTankId, name: bufferTankId, + volume: chilledConfig.defaults?.buffer_tank_volume || null, level: getSensorReading(levels, bufferConfig.level_sensor), + level_sensor: bufferConfig.level_sensor, makeup_valve: bufferConfig.makeup_valve ? { id: bufferConfig.makeup_valve, - position: makeupValve?.position + type: makeupValve?.type || null, + description: makeupValve?.description || null, + position: makeupValve?.position, + is_open: makeupValve?.position?.value > 50 } : null } - const bypassValveId = chilledConfig.control_valves?.pressure_bypass - const bypassValve = valves?.find(v => v.equipment === bypassValveId) - const controlValves = bypassValveId - ? { - pressure_bypass: { - id: bypassValveId, - position: bypassValve?.position - } - } - : null + const controlValveEntries = chilledConfig.control_valves || {} + const controlValves = {} + for (const [role, valveId] of Object.entries(controlValveEntries)) { + const valve = valves?.find(v => v.equipment === valveId) + controlValves[role] = { + id: valveId, + type: valve?.type || null, + description: valve?.description || null, + position: valve?.position || null, + setpoint: valve?.setpoint || null, + controlled_by: supplyReturnConfig.pressure_sensor || null + } + } const returnPumps = filterPumpsByCircuit(pumps, 'HVAC_RETURN').map(formatPump) const supplyPumps = filterPumpsByCircuit(pumps, 'HVAC_SUPPLY').map(formatPump) @@ -513,12 +558,26 @@ function buildHvacCircuit1View (equipment, config) { const fanCoilsSummary = { total: (fanCoils || []).length, running: (fanCoils || []).filter(fc => fc.is_running).length, - units: (fanCoils || []).map(fc => ({ - id: fc.equipment, - is_running: fc.is_running, - temperature: fc.temperature, - valve_position: fc.valve_position - })) + units: (fanCoils || []).map(fc => { + const fcNumber = fc.equipment.replace(/^FCT?-/, '') + const fanId = `V-${fcNumber}` + const valveId = `PIV-${fcNumber}` + const tempSensorId = `TT-${fcNumber}` + const fan = (fans || []).find(f => f.equipment === fanId) + const valve = valves?.find(v => v.equipment === valveId) + + return { + id: fc.equipment, + is_running: fc.is_running, + fan_id: fanId, + fan_running: fan?.fbk_run_out || false, + fan_speed: fc.fan_speed, + valve_tag: valveId, + valve_position: valve?.position || fc.valve_position, + temperature_tag: tempSensorId, + temperature: fc.temperature + } + }) } return { @@ -526,11 +585,23 @@ function buildHvacCircuit1View (equipment, config) { description: chilledConfig.description || viewConfig.description, target_supply_temp: chilledConfig.defaults?.supply_temp, target_return_temp: chilledConfig.defaults?.return_temp, + summary: { + supply_temp: avgSupplyTemp != null ? { value: avgSupplyTemp, unit: tempUnit } : null, + return_temp: avgReturnTemp != null ? { value: avgReturnTemp, unit: tempUnit } : null, + delta_t: deltaT != null ? { value: deltaT, unit: tempUnit } : null, + total_flow: totalFlow != null ? { value: totalFlow, unit: flowUnit } : null, + rated_flow: chilledConfig.defaults?.rated_flow || null, + system_pressure: systemPressure + ? { ...systemPressure, sensor: supplyReturnConfig.pressure_sensor } + : null + }, chiller: chillerData, supply_return: supplyReturn, condenser, buffer_tank: bufferTank, - control_valves: controlValves, + control_valves: Object.keys(controlValves).length > 0 ? controlValves : null, + return_pumps_config: chilledConfig.defaults?.return_pumps_config || null, + supply_pumps_config: chilledConfig.defaults?.supply_pumps_config || null, return_pumps: returnPumps, supply_pumps: supplyPumps, fan_coils: fanCoilsSummary @@ -541,31 +612,68 @@ function buildHvacCircuit2View (equipment, config) { const pumps = equipment.pumps const temperatures = equipment.temperatures const flows = equipment.flows + const levels = equipment.levels const coolingTowers = equipment.cooling_towers const condenserConfig = config?.cooling_system?.hvac_condenser || {} const viewConfig = config?.cooling_system?.view_metadata?.hvac?.circuit2 || {} const supplyReturnConfig = condenserConfig.supply_return || {} + + const supplyTemp = getSensorReading(temperatures, supplyReturnConfig.supply_temp_sensor, condenserConfig.defaults?.supply_temp) + const returnTemp = getSensorReading(temperatures, supplyReturnConfig.return_temp_sensor, condenserConfig.defaults?.return_temp) + const supplyFlow = getSensorReading(flows, supplyReturnConfig.supply_flow_sensor) + const returnFlow = getSensorReading(flows, supplyReturnConfig.return_flow_sensor) + const supplyReturn = { supply: { - temperature: getSensorReading(temperatures, supplyReturnConfig.supply_temp_sensor, condenserConfig.defaults?.supply_temp), - flow: getSensorReading(flows, supplyReturnConfig.supply_flow_sensor) + temperature: supplyTemp, + flow: supplyFlow, + sensors: [ + getSensorWithTag(temperatures, supplyReturnConfig.supply_temp_sensor), + getSensorWithTag(flows, supplyReturnConfig.supply_flow_sensor) + ].filter(Boolean) }, return: { - temperature: getSensorReading(temperatures, supplyReturnConfig.return_temp_sensor, condenserConfig.defaults?.return_temp), - flow: getSensorReading(flows, supplyReturnConfig.return_flow_sensor) + temperature: returnTemp, + flow: returnFlow, + sensors: [ + getSensorWithTag(temperatures, supplyReturnConfig.return_temp_sensor), + getSensorWithTag(flows, supplyReturnConfig.return_flow_sensor) + ].filter(Boolean) } } - const towerData = (coolingTowers || []).map(ct => ({ - id: ct.equipment, - name: ct.equipment, - is_running: ct.is_running, - fan_status: ct.fan_status, - fan_power: ct.fan_power, - level: ct.level, - vibration: ct.vibration - })) + const tempUnit = supplyTemp?.unit + const flowUnit = supplyFlow?.unit + const totalFlow = supplyFlow?.value != null && returnFlow?.value != null + ? Math.round(((supplyFlow.value + returnFlow.value) / 2) * 10) / 10 + : (supplyFlow?.value || returnFlow?.value || null) + const deltaT = (supplyTemp?.value != null && returnTemp?.value != null) + ? Math.round((returnTemp.value - supplyTemp.value) * 10) / 10 + : null + + const towerConfigRef = condenserConfig.tower || {} + const towerLevelSensorId = towerConfigRef.level_sensor + const towerLevel = getSensorReading(levels, towerLevelSensorId) + + const towerData = (coolingTowers || []).map(ct => { + const isConfiguredTower = ct.equipment === towerConfigRef.equipment + return { + id: ct.equipment, + name: ct.equipment, + is_running: ct.is_running, + fan_status: ct.fan_status, + fan_speed: ct.fan_speed, + fan_power: ct.fan_power, + fan_id: isConfiguredTower ? towerConfigRef.fan : null, + level: ct.level, + level_sensor: isConfiguredTower ? towerConfigRef.level_sensor : null, + vibration: ct.vibration, + vibration_sensor: isConfiguredTower ? towerConfigRef.vibration_sensor : null, + capacity_mcal: condenserConfig.defaults?.tower_capacity_mcal || null, + capacity_flow: condenserConfig.defaults?.tower_flow || null + } + }) const condenserPumps = filterPumpsByCircuit(pumps, 'HVAC_CONDENSER').map(formatPump) @@ -574,6 +682,17 @@ function buildHvacCircuit2View (equipment, config) { description: condenserConfig.description || viewConfig.description, target_supply_temp: condenserConfig.defaults?.supply_temp, target_return_temp: condenserConfig.defaults?.return_temp, + summary: { + supply_temp: supplyTemp, + return_temp: returnTemp, + delta_t: deltaT != null ? { value: deltaT, unit: tempUnit } : null, + total_flow: totalFlow != null ? { value: totalFlow, unit: flowUnit } : null, + rated_flow: condenserConfig.defaults?.pumps_config?.rated_flow || null, + tower_level: towerLevel + ? { ...towerLevel, sensor: towerLevelSensorId } + : null + }, + pumps_config: condenserConfig.defaults?.pumps_config || null, supply_return: supplyReturn, cooling_towers: towerData, pumps: condenserPumps @@ -597,16 +716,30 @@ function buildHvacLayoutView (equipment, config) { pumps_running: (pumps || []).filter(p => p.fbk_run_out).length, pumps_total: (pumps || []).length }, + legend: { + c1_supply_temp: circuit1.summary?.supply_temp, + c1_return_temp: circuit1.summary?.return_temp, + c2_condenser_cold: circuit2.target_supply_temp, + c2_condenser_hot: circuit2.target_return_temp + }, circuit1: { name: circuit1.title, + summary: circuit1.summary, chiller: circuit1.chiller, supply_return: circuit1.supply_return, + condenser: circuit1.condenser, buffer_tank: circuit1.buffer_tank, + control_valves: circuit1.control_valves, + return_pumps_config: circuit1.return_pumps_config, + supply_pumps_config: circuit1.supply_pumps_config, return_pumps: circuit1.return_pumps, - supply_pumps: circuit1.supply_pumps + supply_pumps: circuit1.supply_pumps, + fan_coils: circuit1.fan_coils }, circuit2: { name: circuit2.title, + summary: circuit2.summary, + pumps_config: circuit2.pumps_config, supply_return: circuit2.supply_return, cooling_towers: circuit2.cooling_towers, pumps: circuit2.pumps From 2b66502c0e0db30ad99fb348d1b1c39669be40cf Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:40:47 +0530 Subject: [PATCH 31/63] Add site effciency api (#57) * Add site effciency api * Add site effciency api routes * tests --- tests/unit/handlers/site.handlers.test.js | 572 ++++++++++++++++++- tests/unit/routes/site.routes.test.js | 9 + workers/lib/constants.js | 18 +- workers/lib/server/handlers/site.handlers.js | 169 +++++- workers/lib/server/routes/site.routes.js | 21 +- 5 files changed, 780 insertions(+), 9 deletions(-) diff --git a/tests/unit/handlers/site.handlers.test.js b/tests/unit/handlers/site.handlers.test.js index 2ef8aa0..e91b9bd 100644 --- a/tests/unit/handlers/site.handlers.test.js +++ b/tests/unit/handlers/site.handlers.test.js @@ -1,7 +1,7 @@ 'use strict' const test = require('brittle') -const { getSiteLiveStatus } = require('../../../workers/lib/server/handlers/site.handlers') +const { getSiteLiveStatus, getSiteOverviewGroupsStats, getSiteEfficiency } = require('../../../workers/lib/server/handlers/site.handlers') const { withDataProxy } = require('../helpers/mockHelpers') function createMockCtx (tailLogMultiResponse, extDataResponse, globalConfigResponse) { @@ -207,3 +207,573 @@ test('getSiteLiveStatus - falls back to globalConfig nominalHashrate', async (t) t.is(result.hashrate.nominal, 2000, 'should fall back to globalConfig nominalHashrate') t.pass() }) + +// ============ getSiteOverviewGroupsStats Tests ============ + +function createMockDcsCtx (tailLogResponse, listThingsResponse) { + return withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLogMulti') return tailLogResponse + if (method === 'listThings') return listThingsResponse + return {} + } + } + }) +} + +function createMockNoDcsCtx (tailLogResponse) { + return withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: false } } + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLogMulti') return tailLogResponse + return {} + } + } + }) +} + +test('getSiteOverviewGroupsStats - returns groups with correct structure', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 100000000, 'group-2': 200000000 }, + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 50000000, 'group-1_rack-2': 50000000 }, + power_w_container_group_sum_aggr: { 'group-1': 50000, 'group-2': 100000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 25000, 'group-1_rack-2': 25000 }, + offline_cnt: { 'group-1': 2, 'group-2': 1 }, + error_cnt: { 'group-1': 1, 'group-2': 0 }, + not_mining_cnt: { 'group-1': 0, 'group-2': 1 }, + power_mode_sleep_cnt: { 'group-1': 5, 'group-2': 3 }, + power_mode_low_cnt: { 'group-1': 10, 'group-2': 8 }, + power_mode_normal_cnt: { 'group-1': 50, 'group-2': 55 }, + power_mode_high_cnt: { 'group-1': 12, 'group-2': 12 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { total_groups: 2, racks_per_group: 4, miners_per_rack: 20 }, + energy_layout: { branches: [] } + }, + stats: { dcs_specific: { equipment: { power_meters: [] } } } + } + } + }] + + const ctx = createMockDcsCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteOverviewGroupsStats(ctx, req) + + t.ok(result.groups, 'should have groups') + t.is(result.groups.length, 2, 'should have 2 groups') + + const group1 = result.groups[0] + t.is(group1.id, 'group-1', 'group 1 should have correct id') + t.is(group1.name, 'Group 1', 'group 1 should have correct name') + t.ok(group1.summary, 'group should have summary') + t.ok(group1.summary.hashrate, 'summary should have hashrate') + t.ok(group1.summary.consumption, 'summary should have consumption') + t.ok(group1.summary.efficiency, 'summary should have efficiency') + t.ok(group1.status, 'group should have status') + t.is(group1.status.offline, 2, 'offline count should match') + t.is(group1.status.error, 1, 'error count should match') + t.is(group1.status.sleep, 5, 'sleep count should match') + t.ok(group1.racks, 'group should have racks') + + t.pass() +}) + +test('getSiteOverviewGroupsStats - handles empty miner data gracefully', async (t) => { + // When there's no miner data, groups are determined by what's in hashrateByGroup + // If hashrateByGroup is empty but total_groups is set, it uses total_groups + const tailLogResponse = [ + [{ + // Empty group data - groups will be determined by total_groups config + hashrate_mhs_5m_container_group_sum_aggr: {}, + power_w_container_group_sum_aggr: {}, + power_mode_normal_cnt: {} + }] + ] + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { mining: { total_groups: 2, racks_per_group: 4, miners_per_rack: 20 } }, + stats: { dcs_specific: { equipment: { power_meters: [] } } } + } + } + }] + + const ctx = createMockDcsCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteOverviewGroupsStats(ctx, req) + + t.ok(result.groups, 'should have groups') + t.is(result.groups.length, 2, 'should have groups from total_groups config') + t.is(result.groups[0].status.total, 0, 'total miners should be 0') + t.is(result.groups[0].summary.hashrate.value, 0, 'hashrate should be 0') + t.pass() +}) + +test('getSiteOverviewGroupsStats - works without DCS enabled', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 100000000 }, + power_w_container_group_sum_aggr: { 'group-1': 50000 }, + power_mode_normal_cnt: { 'group-1': 50 } + }] + ] + + const ctx = createMockNoDcsCtx(tailLogResponse) + const req = { query: {} } + + const result = await getSiteOverviewGroupsStats(ctx, req) + + t.ok(result.groups, 'should have groups') + t.is(result.groups.length, 1, 'should have 1 group from miner data') + t.pass() +}) + +test('getSiteOverviewGroupsStats - builds racks for groups', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 100000000 }, + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { + 'group-1_rack-1': 30000000, + 'group-1_rack-2': 40000000, + 'group-1_rack-3': 30000000 + }, + power_w_pdu_rack_group_sum_aggr: { + 'group-1_rack-1': 15000, + 'group-1_rack-2': 20000, + 'group-1_rack-3': 15000 + }, + power_mode_normal_cnt: { 'group-1': 60 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { mining: { total_groups: 1, racks_per_group: 4 } }, + stats: { dcs_specific: { equipment: { power_meters: [] } } } + } + } + }] + + const ctx = createMockDcsCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteOverviewGroupsStats(ctx, req) + + t.is(result.groups[0].racks.length, 3, 'should have 3 racks') + t.is(result.groups[0].racks[0].id, 'rack-1', 'first rack should be rack-1') + t.ok(result.groups[0].racks[0].hashrate.value > 0, 'rack should have hashrate') + t.ok(result.groups[0].racks[0].consumption.value > 0, 'rack should have consumption') + t.pass() +}) + +// ============ getSiteEfficiency Tests ============ + +function createMockEfficiencyCtx (tailLogResponse, listThingsResponse) { + return withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLogMulti') return tailLogResponse + if (method === 'listThings') return listThingsResponse + return {} + } + } + }) +} + +test('getSiteEfficiency - returns correct structure', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { + 'group-1': 500000000000, + 'group-2': 500000000000, + 'group-3': 500000000000, + 'group-4': 500000000000 + } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { racks_per_group: 4, miners_per_rack: 20 }, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', transformer: 'TX-1', meter: 'PM-1', feeds: 'Groups 1-2' }, + { board: 'DB-2', transformer: 'TX-2', meter: 'PM-2', feeds: 'Groups 3-4' }, + { board: 'DB-CCM', meter: 'PM-CCM', feeds: 'Cooling & Auxiliary' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', name: 'Site Main', power: { value: 10000, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', name: 'Meter 1', power: { value: 4000, unit: 'kW' } }, + { equipment: 'PM-2', role: 'rack', name: 'Meter 2', power: { value: 4000, unit: 'kW' } }, + { equipment: 'PM-CCM', role: 'auxiliary', name: 'CCM', power: { value: 2000, unit: 'kW' } } + ], + distribution_boards: [ + { equipment: 'DB-1', name: 'Distribution Board 1' }, + { equipment: 'DB-2', name: 'Distribution Board 2' } + ], + transformers: [ + { equipment: 'TX-1', name: 'Transformer 1' }, + { equipment: 'TX-2', name: 'Transformer 2' } + ] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + t.ok(result.summary, 'should have summary') + t.ok(result.summary.site_efficiency, 'should have site_efficiency') + t.ok(result.summary.mining_efficiency, 'should have mining_efficiency') + t.ok(result.summary.total_consumption, 'should have total_consumption') + t.ok(result.summary.ca_overhead, 'should have ca_overhead') + t.is(result.summary.total_consumption.unit, 'MW', 'total_consumption unit should be MW') + + t.ok(result.efficiency_per_meter, 'should have efficiency_per_meter') + t.ok(Array.isArray(result.efficiency_per_meter), 'efficiency_per_meter should be array') + t.is(result.efficiency_per_meter.length, 2, 'should have 2 rack meters') + + t.ok(result.consumption_breakdown, 'should have consumption_breakdown') + t.ok(Array.isArray(result.consumption_breakdown), 'consumption_breakdown should be array') + + t.pass() +}) + +test('getSiteEfficiency - computes efficiency correctly', async (t) => { + // 1 TH/s = 1,000,000 MH/s, so 2 TH/s total + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { + 'group-1': 1000000, + 'group-2': 1000000 + } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { racks_per_group: 4, miners_per_rack: 20 }, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', transformer: 'TX-1', meter: 'PM-1', feeds: 'Groups 1-2' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + // Site uses 100 kW, mining uses 80 kW for 2 TH/s = 40 W/TH + { equipment: 'PM-SITE', role: 'site_main', power: { value: 100, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', power: { value: 80, unit: 'kW' } } + ], + distribution_boards: [], + transformers: [] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + // Mining efficiency: 80 kW * 1000 / 2 TH/s = 40000 W/TH + t.is(result.summary.mining_efficiency.value, 40000, 'mining efficiency should be 40000 W/TH') + // Site efficiency: 100 kW * 1000 / 2 TH/s = 50000 W/TH + t.is(result.summary.site_efficiency.value, 50000, 'site efficiency should be 50000 W/TH') + t.pass() +}) + +test('getSiteEfficiency - computes C&A overhead', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 1000000 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: {}, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', meter: 'PM-1', feeds: 'Groups 1-2' }, + { board: 'DB-CCM', meter: 'PM-CCM', feeds: 'Cooling' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', power: { value: 1000, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', power: { value: 800, unit: 'kW' } }, + { equipment: 'PM-CCM', role: 'auxiliary', power: { value: 200, unit: 'kW' } } + ], + distribution_boards: [], + transformers: [] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + // C&A overhead: 200 / 1000 * 100 = 20% + t.is(result.summary.ca_overhead.value, 20, 'C&A overhead should be 20%') + t.is(result.summary.ca_overhead.unit, '%', 'C&A overhead unit should be %') + t.pass() +}) + +test('getSiteEfficiency - throws error when DCS data not found', async (t) => { + const tailLogResponse = [[{ hashrate_mhs_5m_container_group_sum_aggr: {} }]] + const listThingsResponse = [] // No DCS thing + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + try { + await getSiteEfficiency(ctx, req) + t.fail('should have thrown error') + } catch (err) { + t.is(err.message, 'ERR_DCS_DATA_NOT_FOUND', 'should throw ERR_DCS_DATA_NOT_FOUND') + } + t.pass() +}) + +test('getSiteEfficiency - handles zero hashrate gracefully', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 0, 'group-2': 0 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: {}, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', meter: 'PM-1', feeds: 'Groups 1-2' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', power: { value: 1000, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', power: { value: 800, unit: 'kW' } } + ], + distribution_boards: [], + transformers: [] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + t.is(result.summary.site_efficiency.value, 0, 'site efficiency should be 0 when hashrate is 0') + t.is(result.summary.mining_efficiency.value, 0, 'mining efficiency should be 0 when hashrate is 0') + t.pass() +}) + +test('getSiteEfficiency - builds consumption breakdown', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 1000000 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: {}, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', meter: 'PM-1', feeds: 'Groups 1-2' }, + { board: 'DB-2', meter: 'PM-2', feeds: 'Groups 3-4' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', name: 'Site Main', power: { value: 1000, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', power: { value: 600, unit: 'kW' } }, + { equipment: 'PM-2', role: 'rack', power: { value: 400, unit: 'kW' } } + ], + distribution_boards: [], + transformers: [] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + t.ok(result.consumption_breakdown.length >= 3, 'should have at least 3 entries in breakdown') + + const siteEntry = result.consumption_breakdown.find(e => e.source === 'Site Main') + t.ok(siteEntry, 'should have site main entry') + t.is(siteEntry.percent, 100, 'site main should be 100%') + + const db1Entry = result.consumption_breakdown.find(e => e.source === 'DB-1') + t.ok(db1Entry, 'should have DB-1 entry') + t.is(db1Entry.percent, 60, 'DB-1 should be 60%') + + const db2Entry = result.consumption_breakdown.find(e => e.source === 'DB-2') + t.ok(db2Entry, 'should have DB-2 entry') + t.is(db2Entry.percent, 40, 'DB-2 should be 40%') + + t.pass() +}) + +test('getSiteEfficiency - handles efficiency per meter correctly', async (t) => { + // 1000 TH/s per group = 1,000,000,000 MH/s + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { + 'group-1': 1000000000000, // 1000 TH/s + 'group-2': 1000000000000 // 1000 TH/s + } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { racks_per_group: 4, miners_per_rack: 20 }, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', transformer: 'TX-1', meter: 'PM-1', feeds: 'Groups 1-2' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', power: { value: 50000, unit: 'kW' } }, + // 40000 kW for 2000 TH/s = 20 W/TH + { equipment: 'PM-1', role: 'rack', power: { value: 40000, unit: 'kW' } } + ], + distribution_boards: [{ equipment: 'DB-1', name: 'Board 1' }], + transformers: [{ equipment: 'TX-1', name: 'Transformer 1' }] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + t.is(result.efficiency_per_meter.length, 1, 'should have 1 meter entry') + const meter = result.efficiency_per_meter[0] + t.is(meter.board, 'DB-1', 'board should be DB-1') + t.is(meter.board_name, 'Board 1', 'board_name should be Board 1') + t.is(meter.transformer, 'TX-1', 'transformer should be TX-1') + t.is(meter.transformer_name, 'Transformer 1', 'transformer_name should be Transformer 1') + t.is(meter.feeds, 'Groups 1-2', 'feeds should match') + t.is(meter.efficiency.value, 20, 'efficiency should be 20 W/TH') + t.is(meter.miners, 160, 'miners should be 2 groups * 4 racks * 20 miners = 160') + t.pass() +}) diff --git a/tests/unit/routes/site.routes.test.js b/tests/unit/routes/site.routes.test.js index 7b67dbc..8b08845 100644 --- a/tests/unit/routes/site.routes.test.js +++ b/tests/unit/routes/site.routes.test.js @@ -15,6 +15,7 @@ test('site routes - route definitions', (t) => { const routeUrls = routes.map(route => route.url) t.ok(routeUrls.includes('/auth/site/status/live'), 'should have site status live route') t.ok(routeUrls.includes('/auth/site/overview/groups'), 'should have site overview groups route') + t.ok(routeUrls.includes('/auth/site/efficiency'), 'should have site efficiency route') t.pass() }) @@ -28,6 +29,9 @@ test('site routes - HTTP methods', (t) => { const siteOverviewRoute = routes.find(r => r.url === '/auth/site/overview/groups') t.is(siteOverviewRoute.method, 'GET', 'site overview groups route should be GET') + const siteEfficiencyRoute = routes.find(r => r.url === '/auth/site/efficiency') + t.is(siteEfficiencyRoute.method, 'GET', 'site efficiency route should be GET') + t.pass() }) @@ -44,6 +48,11 @@ test('site routes - schema validation', (t) => { t.ok(siteOverviewRoute.schema.querystring, 'should have querystring schema') t.is(siteOverviewRoute.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + const siteEfficiencyRoute = routes.find(r => r.url === '/auth/site/efficiency') + t.ok(siteEfficiencyRoute.schema, 'site efficiency route should have schema') + t.ok(siteEfficiencyRoute.schema.querystring, 'should have querystring schema') + t.is(siteEfficiencyRoute.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 0d47ca2..e4edc58 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -163,7 +163,9 @@ const ENDPOINTS = { // Energy System endpoints ENERGY_SYSTEM: '/auth/dcs/energy-system', // Site Overview endpoints - SITE_OVERVIEW_GROUPS: '/auth/site/overview/groups' + SITE_OVERVIEW_GROUPS: '/auth/site/overview/groups', + // Site Efficiency endpoint + SITE_EFFICIENCY: '/auth/site/efficiency' } const HTTP_METHODS = { @@ -439,7 +441,7 @@ const SITE_OVERVIEW_AGGR_FIELDS = { power_w_rack_group_sum_aggr: 1, efficiency_w_ths_container_group_avg_aggr: 1, efficiency_w_ths_pdu_rack_group_avg_aggr: 1, - hashrate_mhs_5m_pdu_rack_group_avg_aggr:1, + hashrate_mhs_5m_pdu_rack_group_avg_aggr: 1, power_w_pdu_rack_group_sum_aggr: 1, offline_cnt: 1, error_cnt: 1, @@ -458,6 +460,15 @@ const DCS_POWER_METER_FIELDS = { 'last.snap.config.energy_layout': 1 } +// DCS field projections for site efficiency +const DCS_EFFICIENCY_FIELDS = { + 'last.snap.stats.dcs_specific.equipment.power_meters': 1, + 'last.snap.stats.dcs_specific.equipment.distribution_boards': 1, + 'last.snap.stats.dcs_specific.equipment.transformers': 1, + 'last.snap.config.mining': 1, + 'last.snap.config.energy_layout': 1 +} + const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', SITE_POWER: 'site_power_w', @@ -657,5 +668,6 @@ module.exports = { COOLING_SYSTEM_PROJECTIONS, ENERGY_SYSTEM_PROJECTIONS, SITE_OVERVIEW_AGGR_FIELDS, - DCS_POWER_METER_FIELDS + DCS_POWER_METER_FIELDS, + DCS_EFFICIENCY_FIELDS } diff --git a/workers/lib/server/handlers/site.handlers.js b/workers/lib/server/handlers/site.handlers.js index d895b59..20ea677 100644 --- a/workers/lib/server/handlers/site.handlers.js +++ b/workers/lib/server/handlers/site.handlers.js @@ -14,7 +14,8 @@ const { WORKER_TYPES, WORKER_TAGS, SITE_OVERVIEW_AGGR_FIELDS, - DCS_POWER_METER_FIELDS + DCS_POWER_METER_FIELDS, + DCS_EFFICIENCY_FIELDS } = require('../../constants') const { isCentralDCSEnabled, @@ -312,8 +313,6 @@ function aggregateOverviewMinerStats (tailLogResults) { const entry = extractKeyEntry(orkResult, 0) if (!entry) continue - console.log("entry", entry) - mergeGroupedField(aggregated.hashrateByGroup, entry.hashrate_mhs_5m_container_group_sum_aggr) mergeGroupedField(aggregated.hashrateByRack, entry.hashrate_mhs_5m_pdu_rack_group_avg_aggr) mergeGroupedField(aggregated.powerByGroup, entry.power_w_container_group_sum_aggr) @@ -487,7 +486,169 @@ async function getSiteOverviewGroupsStats (ctx, req) { return composeGroupsStats(minerStats, dcsThing, totalGroups) } +function composeSiteEfficiency (minerStats, dcsThing) { + const config = dcsThing?.last?.snap?.config || {} + const miningConfig = config.mining || {} + const energyLayout = config.energy_layout || {} + const powerMeters = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.power_meters || [] + const distributionBoards = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.distribution_boards || [] + const transformers = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.transformers || [] + const branches = energyLayout.branches || [] + + const minersPerGroup = (miningConfig.racks_per_group || 4) * (miningConfig.miners_per_rack || 20) + const siteMeter = powerMeters.find(pm => pm.equipment === energyLayout.site_meter) || powerMeters.find(pm => pm.role === 'site_main') + const siteTotalKw = siteMeter?.power?.value || 0 + const powerUnit = siteMeter?.power?.unit + + const efficiencyPerMeter = [] + let totalMiningPowerKw = 0 + let totalHashrateMhs = 0 + + for (const branch of branches) { + const meter = powerMeters.find(pm => pm.equipment === branch.meter) + if (!meter || meter.role !== 'rack') continue + + const meterPower = meter.power?.value || 0 + const coveredGroups = [] + const feedsMatch = branch.feeds?.match(/Groups?\s+(\d+)-(\d+)/i) + if (feedsMatch) { + const start = parseInt(feedsMatch[1], 10) + const end = parseInt(feedsMatch[2], 10) + for (let i = start; i <= end; i++) { + coveredGroups.push(`group-${i}`) + } + } + + let branchHashrateMhs = 0 + for (const groupName of coveredGroups) { + branchHashrateMhs += minerStats.hashrateByGroup[groupName] || 0 + } + + const branchHashrateThs = mhsToThs(branchHashrateMhs) + const efficiency = branchHashrateThs > 0 + ? Math.round(((meterPower * 1000) / branchHashrateThs) * 100) / 100 + : 0 + + totalMiningPowerKw += meterPower + totalHashrateMhs += branchHashrateMhs + + const board = distributionBoards.find(db => db.equipment === branch.board) + const transformer = transformers.find(tr => tr.equipment === branch.transformer) + + efficiencyPerMeter.push({ + board: branch.board, + board_name: board?.name || branch.board, + transformer: branch.transformer, + transformer_name: transformer?.name || branch.transformer, + feeds: branch.feeds, + meter: branch.meter, + efficiency: { value: efficiency, unit: 'W/THs' }, + power: { value: Math.round(meterPower * 10) / 10, unit: powerUnit }, + hashrate: { value: mhsToPhs(branchHashrateMhs), unit: 'PH/s' }, + miners: coveredGroups.length * minersPerGroup + }) + } + + // Site-level efficiency + const totalHashrateThs = mhsToThs(totalHashrateMhs) + const siteEfficiency = totalHashrateThs > 0 + ? Math.round(((siteTotalKw * 1000) / totalHashrateThs) * 100) / 100 + : 0 + const miningEfficiency = totalHashrateThs > 0 + ? Math.round(((totalMiningPowerKw * 1000) / totalHashrateThs) * 100) / 100 + : 0 + + // C&A overhead + const ccmBranch = branches.find(b => b.feeds && !b.feeds.match(/Groups?\s+/i)) + const ccmMeter = ccmBranch ? powerMeters.find(pm => pm.equipment === ccmBranch.meter) : null + const ccmPowerKw = ccmMeter?.power?.value || 0 + const caOverhead = siteTotalKw > 0 + ? Math.round((ccmPowerKw / siteTotalKw) * 1000) / 10 + : 0 + + // Consumption breakdown + const consumptionBreakdown = [] + + if (siteMeter) { + consumptionBreakdown.push({ + source: siteMeter.name || siteMeter.equipment, + board: null, + meter: siteMeter.equipment, + consumption: { value: Math.round(siteTotalKw * 10) / 10, unit: powerUnit }, + percent: 100 + }) + } + + for (const branch of branches) { + const meter = powerMeters.find(pm => pm.equipment === branch.meter) + if (!meter) continue + const meterPower = meter.power?.value || 0 + consumptionBreakdown.push({ + source: branch.board, + board: branch.board, + feeds: branch.feeds, + meter: branch.meter, + consumption: { value: Math.round(meterPower * 10) / 10, unit: powerUnit }, + percent: siteTotalKw > 0 ? Math.round((meterPower / siteTotalKw) * 1000) / 10 : 0 + }) + } + + return { + summary: { + site_efficiency: { value: siteEfficiency, unit: 'W/THs' }, + mining_efficiency: { value: miningEfficiency, unit: 'W/THs' }, + total_consumption: { value: Math.round((siteTotalKw / 1000) * 1000) / 1000, unit: 'MW' }, + ca_overhead: { value: caOverhead, unit: '%' } + }, + efficiency_per_meter: efficiencyPerMeter, + consumption_breakdown: consumptionBreakdown + } +} + +/** + * GET /auth/site/efficiency + * + * Returns site efficiency metrics combining: + * - Miner hashrate stats from tailLog (per group) + * - DCS power meters, distribution boards, transformers for branch-level power + */ +async function getSiteEfficiency (ctx, req) { + const tailLogPayload = { + keys: [ + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } + ], + limit: 1, + aggrFields: SITE_OVERVIEW_AGGR_FIELDS + } + + const dcsEnabled = isCentralDCSEnabled(ctx) + let dcsPayload = null + if (dcsEnabled) { + const dcsTag = getDCSTag(ctx) + dcsPayload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields: { id: 1, code: 1, type: 1, tags: 1, ...DCS_EFFICIENCY_FIELDS } + } + } + + const [tailLogResults, dcsResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload), + dcsEnabled ? ctx.dataProxy.requestDataMap('listThings', dcsPayload) : Promise.resolve(null) + ]) + + const minerStats = aggregateOverviewMinerStats(tailLogResults) + const dcsThing = dcsResults ? extractDcsThing(dcsResults) : null + + if (!dcsThing) { + throw new Error('ERR_DCS_DATA_NOT_FOUND') + } + + return composeSiteEfficiency(minerStats, dcsThing) +} + module.exports = { getSiteLiveStatus, - getSiteOverviewGroupsStats + getSiteOverviewGroupsStats, + getSiteEfficiency } diff --git a/workers/lib/server/routes/site.routes.js b/workers/lib/server/routes/site.routes.js index 8154688..a26ea3a 100644 --- a/workers/lib/server/routes/site.routes.js +++ b/workers/lib/server/routes/site.routes.js @@ -6,7 +6,7 @@ const { AUTH_CAPS, AUTH_LEVELS } = require('../../constants') -const { getSiteLiveStatus, getSiteOverviewGroupsStats } = require('../handlers/site.handlers') +const { getSiteLiveStatus, getSiteOverviewGroupsStats, getSiteEfficiency } = require('../handlers/site.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') module.exports = (ctx) => [ @@ -46,5 +46,24 @@ module.exports = (ctx) => [ getSiteOverviewGroupsStats, [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.SITE_EFFICIENCY, + schema: { + querystring: { + type: 'object', + properties: { + overwriteCache: { type: 'boolean' } + } + } + }, + ...createCachedAuthRoute( + ctx, + ['site-efficiency'], + ENDPOINTS.SITE_EFFICIENCY, + getSiteEfficiency, + [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] + ) } ] From c2fc18f4091b45e7ca901b845dc77169bea7b09d Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:28:03 +0530 Subject: [PATCH 32/63] Add explorer racks api (#59) --- tests/unit/handlers/explorer.handlers.test.js | 461 ++++++++++++++++++ tests/unit/routes/explorer.routes.test.js | 63 +++ workers/lib/constants.js | 19 +- .../lib/server/handlers/explorer.handlers.js | 238 +++++++++ workers/lib/server/index.js | 4 +- workers/lib/server/routes/explorer.routes.js | 44 ++ 6 files changed, 826 insertions(+), 3 deletions(-) create mode 100644 tests/unit/handlers/explorer.handlers.test.js create mode 100644 tests/unit/routes/explorer.routes.test.js create mode 100644 workers/lib/server/handlers/explorer.handlers.js create mode 100644 workers/lib/server/routes/explorer.routes.js diff --git a/tests/unit/handlers/explorer.handlers.test.js b/tests/unit/handlers/explorer.handlers.test.js new file mode 100644 index 0000000..12c1c7b --- /dev/null +++ b/tests/unit/handlers/explorer.handlers.test.js @@ -0,0 +1,461 @@ +'use strict' + +const test = require('brittle') +const { + listExplorerRacks, + aggregateRackStats, + buildRackList, + filterByGroups, + filterBySearch, + sortRacks +} = require('../../../workers/lib/server/handlers/explorer.handlers') + +function createMockRackStats (overrides = {}) { + return { + hashrateByRack: { + 'group-1_rack-1': 5000000000, // 5 PH/s + 'group-1_rack-2': 4000000000, + 'group-2_rack-1': 3000000000, + 'group-2_rack-2': 6000000000, + ...overrides.hashrateByRack + }, + powerByRack: { + 'group-1_rack-1': 500000, + 'group-1_rack-2': 400000, + 'group-2_rack-1': 300000, + 'group-2_rack-2': 600000, + ...overrides.powerByRack + }, + efficiencyByRack: { + 'group-1_rack-1': 6, + 'group-1_rack-2': 6, + 'group-2_rack-1': 6, + 'group-2_rack-2': 6, + ...overrides.efficiencyByRack + } + } +} + +function createMockMiningConfig (overrides = {}) { + return { + total_groups: 2, + racks_per_group: 2, + miners_per_rack: 20, + ...overrides + } +} + +function createMockDcsThing (miningConfig) { + return { + id: 'dcs-1', + type: 'wrk-dcs-siemens', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: miningConfig + } + } + } + } +} + +function createMockCtx (tailLogData, dcsThing = null) { + return { + conf: { + featureConfig: dcsThing ? { centralDCSSetup: { enabled: true, tag: 't-dcs' } } : {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + if (method === 'tailLogMulti') return [tailLogData] + if (method === 'listThings') return dcsThing ? [[dcsThing]] : [[]] + return [{}] + } + } + } +} + +// ==================== aggregateRackStats Tests ==================== + +test('aggregateRackStats - extracts per-rack hashrate, power, efficiency', (t) => { + const tailLogResults = [ + [ + [{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 5000000000, 'group-1_rack-2': 4000000000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 500000, 'group-1_rack-2': 400000 }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 6, 'group-1_rack-2': 6.5 } + }] + ] + ] + + const stats = aggregateRackStats(tailLogResults) + t.is(stats.hashrateByRack['group-1_rack-1'], 5000000000) + t.is(stats.powerByRack['group-1_rack-2'], 400000) + t.is(stats.efficiencyByRack['group-1_rack-2'], 6.5) + t.pass() +}) + +test('aggregateRackStats - merges across multiple orks', (t) => { + const tailLogResults = [ + [[{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 3000000000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 300000 }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 5 } + }]], + [[{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 2000000000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 200000 }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 7 } + }]] + ] + + const stats = aggregateRackStats(tailLogResults) + t.is(stats.hashrateByRack['group-1_rack-1'], 5000000000, 'hashrate sums across orks') + t.is(stats.powerByRack['group-1_rack-1'], 500000, 'power sums across orks') + t.is(stats.efficiencyByRack['group-1_rack-1'], 7, 'efficiency takes max across orks') + t.pass() +}) + +test('aggregateRackStats - handles empty results', (t) => { + const stats = aggregateRackStats([]) + t.alike(stats.hashrateByRack, {}) + t.alike(stats.powerByRack, {}) + t.alike(stats.efficiencyByRack, {}) + t.pass() +}) + +// ==================== buildRackList Tests ==================== + +test('buildRackList - builds racks from config', (t) => { + const config = createMockMiningConfig() + const stats = createMockRackStats() + + const racks = buildRackList(config, stats) + + t.is(racks.length, 4, 'should create 2 groups * 2 racks = 4 racks') + t.is(racks[0].id, 'group-1_rack-1') + t.is(racks[0].name, 'Rack 1') + t.is(racks[0].group.id, 'group-1') + t.is(racks[0].group.name, 'Group 1') + t.is(racks[0].miners_count, 20) + t.is(racks[0].efficiency.unit, 'W/TH/s') + t.is(racks[0].hashrate.unit, 'PH/s') + t.is(racks[0].consumption.unit, 'kW') + t.pass() +}) + +test('buildRackList - sequential rack naming across groups', (t) => { + const config = createMockMiningConfig({ total_groups: 3, racks_per_group: 2 }) + const stats = createMockRackStats() + + const racks = buildRackList(config, stats) + + t.is(racks[0].name, 'Rack 1') + t.is(racks[1].name, 'Rack 2') + t.is(racks[2].name, 'Rack 3') + t.is(racks[3].name, 'Rack 4') + t.is(racks[4].name, 'Rack 5') + t.is(racks[5].name, 'Rack 6') + t.pass() +}) + +test('buildRackList - handles missing stats gracefully', (t) => { + const config = createMockMiningConfig({ total_groups: 1, racks_per_group: 1 }) + const stats = { hashrateByRack: {}, powerByRack: {}, efficiencyByRack: {} } + + const racks = buildRackList(config, stats) + + t.is(racks.length, 1) + t.is(racks[0].efficiency.value, 0) + t.is(racks[0].hashrate.value, 0) + t.is(racks[0].consumption.value, 0) + t.pass() +}) + +test('buildRackList - handles empty config', (t) => { + const racks = buildRackList({}, { hashrateByRack: {}, powerByRack: {}, efficiencyByRack: {} }) + t.is(racks.length, 0) + t.pass() +}) + +// ==================== filterByGroups Tests ==================== + +test('filterByGroups - filters to matching groups', (t) => { + const racks = [ + { id: 'group-1_rack-1', group: { id: 'group-1' } }, + { id: 'group-2_rack-1', group: { id: 'group-2' } }, + { id: 'group-3_rack-1', group: { id: 'group-3' } } + ] + + const result = filterByGroups(racks, ['group-1', 'group-3']) + t.is(result.length, 2) + t.is(result[0].id, 'group-1_rack-1') + t.is(result[1].id, 'group-3_rack-1') + t.pass() +}) + +test('filterByGroups - returns empty for no matches', (t) => { + const racks = [{ id: 'group-1_rack-1', group: { id: 'group-1' } }] + const result = filterByGroups(racks, ['group-99']) + t.is(result.length, 0) + t.pass() +}) + +// ==================== filterBySearch Tests ==================== + +test('filterBySearch - matches rack name case-insensitively', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1' }, + { id: 'group-1_rack-2', name: 'Rack 2' }, + { id: 'group-2_rack-1', name: 'Rack 3' } + ] + + const result = filterBySearch(racks, 'rack 1') + t.is(result.length, 1) + t.is(result[0].name, 'Rack 1') + t.pass() +}) + +test('filterBySearch - matches rack id', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1' }, + { id: 'group-2_rack-1', name: 'Rack 3' } + ] + + const result = filterBySearch(racks, 'group-2') + t.is(result.length, 1) + t.is(result[0].id, 'group-2_rack-1') + t.pass() +}) + +test('filterBySearch - returns all on empty match', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1' } + ] + + const result = filterBySearch(racks, 'xyz') + t.is(result.length, 0) + t.pass() +}) + +// ==================== sortRacks Tests ==================== + +test('sortRacks - sorts by efficiency descending', (t) => { + const racks = [ + { efficiency: { value: 5 } }, + { efficiency: { value: 8 } }, + { efficiency: { value: 3 } } + ] + + sortRacks(racks, { efficiency: -1 }) + t.is(racks[0].efficiency.value, 8) + t.is(racks[1].efficiency.value, 5) + t.is(racks[2].efficiency.value, 3) + t.pass() +}) + +test('sortRacks - sorts by hashrate ascending', (t) => { + const racks = [ + { hashrate: { value: 10 } }, + { hashrate: { value: 5 } }, + { hashrate: { value: 15 } } + ] + + sortRacks(racks, { hashrate: 1 }) + t.is(racks[0].hashrate.value, 5) + t.is(racks[1].hashrate.value, 10) + t.is(racks[2].hashrate.value, 15) + t.pass() +}) + +test('sortRacks - sorts by group', (t) => { + const racks = [ + { group: { id: 'group-3' } }, + { group: { id: 'group-1' } }, + { group: { id: 'group-2' } } + ] + + sortRacks(racks, { group: 1 }) + t.is(racks[0].group.id, 'group-1') + t.is(racks[1].group.id, 'group-2') + t.is(racks[2].group.id, 'group-3') + t.pass() +}) + +// ==================== listExplorerRacks Tests ==================== + +test('listExplorerRacks - returns paginated rack list', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 4 }) + const dcsThing = createMockDcsThing(miningConfig) + const tailLogData = [ + [{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 5000000000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 500000 }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 6 } + }] + ] + + const ctx = createMockCtx(tailLogData, dcsThing) + const req = { query: {} } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 16, 'total should be 4 groups * 4 racks') + t.is(result.data.length, 16, 'default limit should show all when <= 20') + t.is(result.offset, 0) + t.is(result.limit, 20) + t.is(result.hasMore, false) + t.is(result.data[0].id, 'group-1_rack-1') + t.is(result.data[0].group.name, 'Group 1') + t.pass() +}) + +test('listExplorerRacks - filters by group', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { group: 'group-2' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 2, 'should only return racks from group-2') + result.data.forEach(rack => { + t.is(rack.group.id, 'group-2') + }) + t.pass() +}) + +test('listExplorerRacks - filters by multiple groups', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { group: 'group-1,group-3' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 4) + const groupIds = new Set(result.data.map(r => r.group.id)) + t.ok(groupIds.has('group-1')) + t.ok(groupIds.has('group-3')) + t.ok(!groupIds.has('group-2')) + t.pass() +}) + +test('listExplorerRacks - applies search filter', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 4 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { search: 'Rack 1' } } + + const result = await listExplorerRacks(ctx, req) + + // "Rack 1" matches "Rack 1", "Rack 10", "Rack 11", etc. + t.ok(result.totalCount >= 1) + result.data.forEach(rack => { + t.ok(rack.name.toLowerCase().includes('rack 1') || rack.id.toLowerCase().includes('rack 1')) + }) + t.pass() +}) + +test('listExplorerRacks - combined group + search filter', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 4 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { group: 'group-2', search: 'rack-1' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 1) + t.is(result.data[0].group.id, 'group-2') + t.ok(result.data[0].id.includes('rack-1')) + t.pass() +}) + +test('listExplorerRacks - applies pagination', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 8, racks_per_group: 4 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { offset: 5, limit: 3 } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 32) + t.is(result.data.length, 3) + t.is(result.offset, 5) + t.is(result.limit, 3) + t.is(result.hasMore, true) + t.pass() +}) + +test('listExplorerRacks - enforces max limit', async (t) => { + const miningConfig = createMockMiningConfig() + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { limit: 500 } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.limit, 100) + t.pass() +}) + +test('listExplorerRacks - applies sort', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 2, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const tailLogData = [ + [{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { + 'group-1_rack-1': 1000000000, + 'group-1_rack-2': 5000000000, + 'group-2_rack-1': 3000000000, + 'group-2_rack-2': 2000000000 + }, + power_w_pdu_rack_group_sum_aggr: { + 'group-1_rack-1': 100000, + 'group-1_rack-2': 500000, + 'group-2_rack-1': 300000, + 'group-2_rack-2': 200000 + }, + efficiency_w_ths_pdu_rack_group_avg_aggr: {} + }] + ] + + const ctx = createMockCtx(tailLogData, dcsThing) + const req = { query: { sort: '{"hashrate":-1}' } } + + const result = await listExplorerRacks(ctx, req) + + t.ok(result.data[0].hashrate.value >= result.data[1].hashrate.value, 'first rack should have highest hashrate') + t.pass() +}) + +test('listExplorerRacks - handles no DCS (empty rack list)', async (t) => { + const ctx = { + conf: { featureConfig: {} }, + dataProxy: { + requestDataMap: async () => [[{}]] + } + } + const req = { query: {} } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 0) + t.is(result.data.length, 0) + t.pass() +}) + +test('listExplorerRacks - throws on invalid sort JSON', async (t) => { + const miningConfig = createMockMiningConfig() + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { sort: 'not-json' } } + + try { + await listExplorerRacks(ctx, req) + t.fail('should have thrown') + } catch (err) { + t.ok(err instanceof SyntaxError || err.message.includes('JSON')) + } + t.pass() +}) diff --git a/tests/unit/routes/explorer.routes.test.js b/tests/unit/routes/explorer.routes.test.js new file mode 100644 index 0000000..6c81178 --- /dev/null +++ b/tests/unit/routes/explorer.routes.test.js @@ -0,0 +1,63 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/explorer.routes.js' + +test('explorer routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'explorer') + t.pass() +}) + +test('explorer routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/explorer/racks'), 'should have explorer racks route') + + t.pass() +}) + +test('explorer routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const racksRoute = routes.find(r => r.url === '/auth/explorer/racks') + t.is(racksRoute.method, 'GET', 'racks route should be GET') + + t.pass() +}) + +test('explorer routes - schema validation', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const racksRoute = routes.find(r => r.url === '/auth/explorer/racks') + t.ok(racksRoute.schema, 'should have schema') + t.ok(racksRoute.schema.querystring, 'should have querystring schema') + + const props = racksRoute.schema.querystring.properties + t.is(props.group.type, 'string', 'group should be string') + t.is(props.search.type, 'string', 'search should be string') + t.is(props.sort.type, 'string', 'sort should be string') + t.is(props.offset.type, 'integer', 'offset should be integer') + t.is(props.limit.type, 'integer', 'limit should be integer') + + t.pass() +}) + +test('explorer routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'explorer') + t.pass() +}) + +test('explorer routes - onRequest functions (auth)', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.ok(typeof route.onRequest === 'function', `explorer route ${route.url} should have onRequest function`) + }) + + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index e4edc58..629be16 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -165,7 +165,9 @@ const ENDPOINTS = { // Site Overview endpoints SITE_OVERVIEW_GROUPS: '/auth/site/overview/groups', // Site Efficiency endpoint - SITE_EFFICIENCY: '/auth/site/efficiency' + SITE_EFFICIENCY: '/auth/site/efficiency', + // Explorer endpoints + EXPLORER_RACKS: '/auth/explorer/racks' } const HTTP_METHODS = { @@ -612,6 +614,16 @@ const MINER_DEFAULT_FIELDS = { const MINER_MAX_LIMIT = 200 const MINER_DEFAULT_LIMIT = 50 +// Explorer racks aggregation fields +const EXPLORER_RACK_AGGR_FIELDS = { + hashrate_mhs_5m_pdu_rack_group_avg_aggr: 1, + power_w_pdu_rack_group_sum_aggr: 1, + efficiency_w_ths_pdu_rack_group_avg_aggr: 1 +} + +const EXPLORER_RACK_DEFAULT_LIMIT = 20 +const EXPLORER_RACK_MAX_LIMIT = 100 + module.exports = { SUPER_ADMIN_ROLE, GLOBAL_DATA_TYPES, @@ -669,5 +681,8 @@ module.exports = { ENERGY_SYSTEM_PROJECTIONS, SITE_OVERVIEW_AGGR_FIELDS, DCS_POWER_METER_FIELDS, - DCS_EFFICIENCY_FIELDS + DCS_EFFICIENCY_FIELDS, + EXPLORER_RACK_AGGR_FIELDS, + EXPLORER_RACK_DEFAULT_LIMIT, + EXPLORER_RACK_MAX_LIMIT } diff --git a/workers/lib/server/handlers/explorer.handlers.js b/workers/lib/server/handlers/explorer.handlers.js new file mode 100644 index 0000000..ba83d83 --- /dev/null +++ b/workers/lib/server/handlers/explorer.handlers.js @@ -0,0 +1,238 @@ +'use strict' + +const { + extractKeyEntry, + mhsToPhs, + mhsToThs, + mergeGroupedField, + getGroupNumber +} = require('../../metrics.utils') +const { + LOG_KEYS, + WORKER_TYPES, + WORKER_TAGS, + EXPLORER_RACK_AGGR_FIELDS, + EXPLORER_RACK_DEFAULT_LIMIT, + EXPLORER_RACK_MAX_LIMIT, + DCS_POWER_METER_FIELDS +} = require('../../constants') +const { + isCentralDCSEnabled, + getDCSTag, + extractDcsThing +} = require('../../dcs.utils') + +/** + * Aggregates per-rack stats from tailLogMulti results across all orks. + * + * @param {Array} tailLogResults - Array of ork responses from tailLogMulti + * @returns {Object} { hashrateByRack, powerByRack, efficiencyByRack } + */ +function aggregateRackStats (tailLogResults) { + const stats = { + hashrateByRack: {}, + powerByRack: {}, + efficiencyByRack: {} + } + + for (const orkResult of tailLogResults) { + const entry = extractKeyEntry(orkResult, 0) + if (!entry) continue + + mergeGroupedField(stats.hashrateByRack, entry.hashrate_mhs_5m_pdu_rack_group_avg_aggr) + mergeGroupedField(stats.powerByRack, entry.power_w_pdu_rack_group_sum_aggr) + mergeGroupedField(stats.efficiencyByRack, entry.efficiency_w_ths_pdu_rack_group_avg_aggr, true) + } + + return stats +} + +/** + * Builds a flat list of all racks from mining config, enriched with tailLog stats. + * + * @param {Object} miningConfig - DCS mining config (total_groups, racks_per_group, miners_per_rack) + * @param {Object} rackStats - Per-rack stats from aggregateRackStats + * @returns {Array} Array of rack objects + */ +function buildRackList (miningConfig, rackStats) { + const totalGroups = miningConfig?.total_groups || 0 + const racksPerGroup = miningConfig?.racks_per_group || 4 + const minersPerRack = miningConfig?.miners_per_rack || 20 + const racks = [] + + for (let g = 1; g <= totalGroups; g++) { + const groupId = `group-${g}` + const groupName = `Group ${g}` + + for (let r = 1; r <= racksPerGroup; r++) { + const rackKey = `${groupId}_rack-${r}` + const hashrateMhs = rackStats.hashrateByRack[rackKey] || 0 + const powerW = rackStats.powerByRack[rackKey] || 0 + const powerKw = Math.round(powerW / 10) / 100 + const hashrateThs = mhsToThs(hashrateMhs) + const efficiency = hashrateThs > 0 + ? Math.round((powerW / hashrateThs) * 10) / 10 + : rackStats.efficiencyByRack[rackKey] || 0 + + racks.push({ + id: rackKey, + name: `Rack ${((g - 1) * racksPerGroup) + r}`, + group: { id: groupId, name: groupName }, + miners_count: minersPerRack, + efficiency: { value: efficiency, unit: 'W/TH/s' }, + hashrate: { value: mhsToPhs(hashrateMhs), unit: 'PH/s' }, + consumption: { value: powerKw, unit: 'kW' } + }) + } + } + + return racks +} + +/** + * Filters racks by group ids. + * + * @param {Array} racks - Array of rack objects + * @param {Array} groups - Group ids to filter by + * @returns {Array} Filtered racks + */ +function filterByGroups (racks, groups) { + const groupSet = new Set(groups) + return racks.filter(rack => groupSet.has(rack.group.id)) +} + +/** + * Filters racks by search string (matches rack name or id, case-insensitive). + * + * @param {Array} racks - Array of rack objects + * @param {string} search - Search term + * @returns {Array} Filtered racks + */ +function filterBySearch (racks, search) { + const term = search.toLowerCase() + return racks.filter(rack => + rack.name.toLowerCase().includes(term) || + rack.id.toLowerCase().includes(term) + ) +} + +/** + * Sorts racks by the given sort spec. + * Supported fields: efficiency, hashrate, consumption, name, group + * + * @param {Array} racks - Array of rack objects + * @param {Object} sort - Sort spec: { field: 1 or -1 } + * @returns {Array} Sorted racks (mutates the original) + */ +function sortRacks (racks, sort) { + const entries = Object.entries(sort) + if (entries.length === 0) return racks + + return racks.sort((a, b) => { + for (const [field, direction] of entries) { + let aVal, bVal + if (field === 'efficiency' || field === 'hashrate' || field === 'consumption') { + aVal = a[field]?.value + bVal = b[field]?.value + } else if (field === 'group') { + aVal = getGroupNumber(a.group.id) + bVal = getGroupNumber(b.group.id) + } else if (field === 'name') { + aVal = a.name + bVal = b.name + } else { + continue + } + + if (aVal === bVal) continue + if (aVal == null) return direction + if (bVal == null) return -direction + if (aVal < bVal) return -direction + if (aVal > bVal) return direction + } + return 0 + }) +} + +/** + * GET /auth/explorer/racks + * + * Returns a paginated list of racks with per-rack stats. + * Combines DCS mining config (rack structure) with tailLog RTD (per-rack hashrate/power/efficiency). + * + * Query params: + * group - comma-separated group ids to filter by (e.g. "group-2" or "group-1,group-3") + * search - text search on rack name/id + * sort - JSON sort spec (e.g. '{"efficiency":-1}') + * offset - pagination offset (default 0) + * limit - page size (default 20, max 100) + */ +async function listExplorerRacks (ctx, req) { + const tailLogPayload = { + keys: [ + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } + ], + limit: 1, + aggrFields: EXPLORER_RACK_AGGR_FIELDS + } + + const dcsEnabled = isCentralDCSEnabled(ctx) + let dcsPayload = null + if (dcsEnabled) { + const dcsTag = getDCSTag(ctx) + dcsPayload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields: { id: 1, code: 1, type: 1, tags: 1, ...DCS_POWER_METER_FIELDS } + } + } + + const [tailLogResults, dcsResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload), + dcsEnabled ? ctx.dataProxy.requestDataMap('listThings', dcsPayload) : Promise.resolve(null) + ]) + + const rackStats = aggregateRackStats(tailLogResults) + const dcsThing = dcsResults ? extractDcsThing(dcsResults) : null + const miningConfig = dcsThing?.last?.snap?.config?.mining || {} + + let racks = buildRackList(miningConfig, rackStats) + + if (req.query.group) { + const groups = req.query.group.split(',').map(g => g.trim()).filter(Boolean) + if (groups.length > 0) { + racks = filterByGroups(racks, groups) + } + } + + if (req.query.search) { + racks = filterBySearch(racks, req.query.search) + } + + if (req.query.sort) { + const sort = typeof req.query.sort === 'string' ? JSON.parse(req.query.sort) : req.query.sort + sortRacks(racks, sort) + } + + const offset = req.query.offset || 0 + const limit = Math.min(req.query.limit || EXPLORER_RACK_DEFAULT_LIMIT, EXPLORER_RACK_MAX_LIMIT) + const totalCount = racks.length + const page = racks.slice(offset, offset + limit) + + return { + data: page, + totalCount, + offset, + limit, + hasMore: offset + limit < totalCount + } +} + +module.exports = { + listExplorerRacks, + aggregateRackStats, + buildRackList, + filterByGroups, + filterBySearch, + sortRacks +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 1c10a56..2721205 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -19,6 +19,7 @@ const minersRoutes = require('./routes/miners.routes') const groupsRoutes = require('./routes/groups.routes') const coolingSystemRoutes = require('./routes/coolingSystem.routes') const energySystemRoutes = require('./routes/energySystem.routes') +const explorerRoutes = require('./routes/explorer.routes') /** * Collect all routes into a flat array for server injection. @@ -44,7 +45,8 @@ function routes (ctx) { ...minersRoutes(ctx), ...groupsRoutes(ctx), ...coolingSystemRoutes(ctx), - ...energySystemRoutes(ctx) + ...energySystemRoutes(ctx), + ...explorerRoutes(ctx) ] } diff --git a/workers/lib/server/routes/explorer.routes.js b/workers/lib/server/routes/explorer.routes.js new file mode 100644 index 0000000..eae826f --- /dev/null +++ b/workers/lib/server/routes/explorer.routes.js @@ -0,0 +1,44 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_CAPS, + AUTH_LEVELS +} = require('../../constants') +const { listExplorerRacks } = require('../handlers/explorer.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.EXPLORER_RACKS, + schema: { + querystring: { + type: 'object', + properties: { + group: { type: 'string' }, + search: { type: 'string' }, + sort: { type: 'string' }, + offset: { type: 'integer' }, + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + } + } + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'explorer-racks', + req.query.group, + req.query.search, + req.query.sort, + req.query.offset, + req.query.limit + ], + ENDPOINTS.EXPLORER_RACKS, + listExplorerRacks, + [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] + ) + } +] From 14656b41ffaa536f066d5f62bbb76ac2112b83c1 Mon Sep 17 00:00:00 2001 From: borik91 <9007515+boris91@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:25:49 +0300 Subject: [PATCH 33/63] (improvement) Explorer handlers, Racks list, Search filtering flow improved (#60) * improv: workers/lib/server/handlers - explorer - 'filterBySearch' flow improved. * improv: workers/lib/server/handlers - explorer - 'filterBySearch' flow & usage improved. * fix: workers/lib/server/handlers - explorer - 'filterBySearch' flow fixed. * test: tests/unit/handlers - explorer - 'filterBySearch' cases coverage extended. * test: tests/unit/handlers - coolingSystem - 'createMockConfig' result generation fixed. --- .../handlers/coolingSystem.handlers.test.js | 3 +- tests/unit/handlers/explorer.handlers.test.js | 192 ++++++++++++++++++ .../lib/server/handlers/explorer.handlers.js | 29 ++- 3 files changed, 213 insertions(+), 11 deletions(-) diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js index 1160363..3dd46df 100644 --- a/tests/unit/handlers/coolingSystem.handlers.test.js +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -196,7 +196,8 @@ const createMockConfig = () => ({ return_temp_sensor: 'TS-7502', supply_flow_sensor: 'FIT-7501', return_flow_sensor: 'FIT-7502', - pressure_sensor: 'PIT-7501' + pressure_sensor: 'PIT-7501', + flow_switches: ['FS-7501', 'FS-7502'] }, buffer_tank: { tank: 'TQ-7502', diff --git a/tests/unit/handlers/explorer.handlers.test.js b/tests/unit/handlers/explorer.handlers.test.js index 12c1c7b..72dd37c 100644 --- a/tests/unit/handlers/explorer.handlers.test.js +++ b/tests/unit/handlers/explorer.handlers.test.js @@ -238,6 +238,142 @@ test('filterBySearch - returns all on empty match', (t) => { t.pass() }) +test('filterBySearch - matches group id case-insensitively', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } }, + { id: 'group-2_rack-1', name: 'Rack 3', group: { id: 'group-2', name: 'Group 2' } } + ] + + const result = filterBySearch(racks, 'GROUP-1') + t.is(result.length, 1) + t.is(result[0].id, 'group-1_rack-1') + t.pass() +}) + +test('filterBySearch - matches group name case-insensitively', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } }, + { id: 'group-2_rack-1', name: 'Rack 3', group: { id: 'group-2', name: 'Group 2' } } + ] + + const result = filterBySearch(racks, 'group 2') + t.is(result.length, 1) + t.is(result[0].group.name, 'Group 2') + t.pass() +}) + +test('filterBySearch - supports comma-separated terms as OR', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } }, + { id: 'group-1_rack-2', name: 'Rack 2', group: { id: 'group-1', name: 'Group 1' } }, + { id: 'group-2_rack-1', name: 'Rack 3', group: { id: 'group-2', name: 'Group 2' } }, + { id: 'group-3_rack-1', name: 'Rack 4', group: { id: 'group-3', name: 'Group 3' } } + ] + + const result = filterBySearch(racks, 'Rack 1,Rack 3') + const ids = result.map(r => r.id).sort() + t.is(result.length, 2) + t.alike(ids, ['group-1_rack-1', 'group-2_rack-1']) + t.pass() +}) + +test('filterBySearch - trims whitespace around terms', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } }, + { id: 'group-2_rack-1', name: 'Rack 3', group: { id: 'group-2', name: 'Group 2' } } + ] + + const result = filterBySearch(racks, ' rack 1 , group-2 ') + const ids = result.map(r => r.id).sort() + t.is(result.length, 2) + t.alike(ids, ['group-1_rack-1', 'group-2_rack-1']) + t.pass() +}) + +test('filterBySearch - ignores empty terms within comma-separated list', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } }, + { id: 'group-2_rack-1', name: 'Rack 3', group: { id: 'group-2', name: 'Group 2' } } + ] + + const result = filterBySearch(racks, ',,rack 1,, ,') + t.is(result.length, 1) + t.is(result[0].id, 'group-1_rack-1') + t.pass() +}) + +test('filterBySearch - returns all racks when search is undefined', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } }, + { id: 'group-2_rack-1', name: 'Rack 3', group: { id: 'group-2', name: 'Group 2' } } + ] + + const result = filterBySearch(racks, undefined) + t.is(result.length, 2) + t.alike(result, racks) + t.pass() +}) + +test('filterBySearch - returns all racks when search is null', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } } + ] + + const result = filterBySearch(racks, null) + t.is(result.length, 1) + t.alike(result, racks) + t.pass() +}) + +test('filterBySearch - returns all racks when search is empty string', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } }, + { id: 'group-2_rack-1', name: 'Rack 3', group: { id: 'group-2', name: 'Group 2' } } + ] + + const result = filterBySearch(racks, '') + t.is(result.length, 2) + t.alike(result, racks) + t.pass() +}) + +test('filterBySearch - returns all racks when search contains only commas/whitespace', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1', group: { id: 'group-1', name: 'Group 1' } } + ] + + const result = filterBySearch(racks, ' ,, , ') + t.is(result.length, 1) + t.alike(result, racks) + t.pass() +}) + +test('filterBySearch - handles racks without group gracefully', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1' }, + { id: 'group-2_rack-1', name: 'Rack 3', group: { id: 'group-2', name: 'Group 2' } } + ] + + const byName = filterBySearch(racks, 'Rack 1') + t.is(byName.length, 1) + t.is(byName[0].id, 'group-1_rack-1') + + const byGroup = filterBySearch(racks, 'Group 2') + t.is(byGroup.length, 1) + t.is(byGroup[0].id, 'group-2_rack-1') + t.pass() +}) + +test('filterBySearch - does not match when field is missing and search is non-empty', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1' } + ] + + const result = filterBySearch(racks, 'Group 1') + t.is(result.length, 0) + t.pass() +}) + // ==================== sortRacks Tests ==================== test('sortRacks - sorts by efficiency descending', (t) => { @@ -357,6 +493,62 @@ test('listExplorerRacks - applies search filter', async (t) => { t.pass() }) +test('listExplorerRacks - supports comma-separated search terms', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { search: 'group-1,group-3' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 4, 'should return racks from both group-1 and group-3') + const groupIds = new Set(result.data.map(r => r.group.id)) + t.ok(groupIds.has('group-1')) + t.ok(groupIds.has('group-3')) + t.ok(!groupIds.has('group-2')) + t.ok(!groupIds.has('group-4')) + t.pass() +}) + +test('listExplorerRacks - search by group name returns matching racks', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 3, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { search: 'Group 2' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 2) + result.data.forEach(rack => { + t.is(rack.group.id, 'group-2') + }) + t.pass() +}) + +test('listExplorerRacks - returns all racks when search is missing', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 2, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: {} } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 4, 'all racks returned when no search provided') + t.pass() +}) + +test('listExplorerRacks - returns all racks when search is empty string', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 2, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { search: '' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 4, 'empty search should not filter anything out') + t.pass() +}) + test('listExplorerRacks - combined group + search filter', async (t) => { const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 4 }) const dcsThing = createMockDcsThing(miningConfig) diff --git a/workers/lib/server/handlers/explorer.handlers.js b/workers/lib/server/handlers/explorer.handlers.js index ba83d83..e059d08 100644 --- a/workers/lib/server/handlers/explorer.handlers.js +++ b/workers/lib/server/handlers/explorer.handlers.js @@ -102,18 +102,29 @@ function filterByGroups (racks, groups) { } /** - * Filters racks by search string (matches rack name or id, case-insensitive). + * Filters racks by search string (matches rack name or id, group name or id, case-insensitive). * * @param {Array} racks - Array of rack objects - * @param {string} search - Search term + * @param {string?} search - Search term * @returns {Array} Filtered racks */ function filterBySearch (racks, search) { - const term = search.toLowerCase() - return racks.filter(rack => - rack.name.toLowerCase().includes(term) || - rack.id.toLowerCase().includes(term) - ) + const terms = search?.split(',') + .map(term => term.trim().toLowerCase()) + .filter(Boolean) + + if (!terms?.length) { + return racks + } + + return racks.filter(rack => ( + terms.some(term => [ + rack.id, + rack.name, + rack.group?.id, + rack.group?.name + ].some(field => field?.toLowerCase().includes(term))) + )) } /** @@ -205,9 +216,7 @@ async function listExplorerRacks (ctx, req) { } } - if (req.query.search) { - racks = filterBySearch(racks, req.query.search) - } + racks = filterBySearch(racks, req.query.search) if (req.query.sort) { const sort = typeof req.query.sort === 'string' ? JSON.parse(req.query.sort) : req.query.sort From 0b6ffe15f9f6c9880e7c8e0b7f4fe60cf6868acc Mon Sep 17 00:00:00 2001 From: tekwani Date: Tue, 28 Apr 2026 11:20:36 +0530 Subject: [PATCH 34/63] feat: group hashrate by miner and container (#62) --- tests/unit/handlers/metrics.handlers.test.js | 69 +++++++++++++++++++ workers/lib/constants.js | 13 +++- .../lib/server/handlers/metrics.handlers.js | 35 +++++++++- workers/lib/server/routes/metrics.routes.js | 3 +- workers/lib/server/schemas/metrics.schemas.js | 1 + 5 files changed, 117 insertions(+), 4 deletions(-) diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index 69f3c5c..a7ca481 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -66,6 +66,75 @@ test('getHashrate - happy path', async (t) => { t.pass() }) +test('getHashrate - grouped by miner uses type group aggregation', async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ + ts: 1700006400000, + hashrate_mhs_5m_type_group_sum_aggr: 123456 + }] + } + } + }) + + const result = await getHashrate(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, groupBy: 'miner' } + }) + + t.is(capturedPayload.fields.hashrate_mhs_5m_type_group_sum, 1, 'should request type-group source field') + t.is(capturedPayload.aggrFields.hashrate_mhs_5m_type_group_sum_aggr, 1, 'should request type-group aggregate field') + t.is(result.log.length, 1, 'should map one grouped row') + t.is(result.log[0].hashrateMhs, 123456, 'should map grouped hashrate value') + t.alike(result.summary, {}, 'grouped response should return empty summary') + t.pass() +}) + +test('getHashrate - grouped by container uses container group aggregation', async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ + ts: 1700006400000, + hashrate_mhs_5m_container_group_sum_aggr: 777 + }] + } + } + }) + + const result = await getHashrate(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, groupBy: 'container' } + }) + + t.is(capturedPayload.fields.hashrate_mhs_5m_container_group_sum, 1, 'should request container-group source field') + t.is(capturedPayload.aggrFields.hashrate_mhs_5m_container_group_sum_aggr, 1, 'should request container-group aggregate field') + t.is(result.log.length, 1, 'should map grouped row') + t.is(result.log[0].hashrateMhs, 777, 'should map container grouped hashrate value') + t.alike(result.summary, {}, 'grouped response should return empty summary') + t.pass() +}) + +test('getHashrate - grouped mode handles empty results', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => [] } + }) + + const result = await getHashrate(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, groupBy: 'miner' } + }) + + t.is(result.log.length, 0, 'grouped log should be empty when no data is returned') + t.alike(result.summary, {}, 'grouped summary should still be empty object') + t.pass() +}) + test('getHashrate - missing start throws', async (t) => { const mockCtx = withDataProxy({ conf: { orks: [] }, diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 629be16..9de6d24 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -286,7 +286,8 @@ const MINER_CATEGORIES = { const LOG_KEYS = { STAT_RTD: 'stat-rtd', STAT_3H: 'stat-3h', - STAT_5M: 'stat-5m' + STAT_5M: 'stat-5m', + STAT_1D: 'stat-1D' } const WORKER_TAGS = { @@ -471,8 +472,15 @@ const DCS_EFFICIENCY_FIELDS = { 'last.snap.config.energy_layout': 1 } +const LOG_FIELDS = { + HASHRATE_SUM_TYPE_GROUP: 'hashrate_mhs_5m_type_group_sum', + HASHRATE_SUM_CONTAINER_GROUP: 'hashrate_mhs_5m_container_group_sum' +} + const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', + HASHRATE_SUM_TYPE_GROUP_AGGR: 'hashrate_mhs_5m_type_group_sum_aggr', + HASHRATE_SUM_CONTAINER_GROUP_AGGR: 'hashrate_mhs_5m_container_group_sum_aggr', SITE_POWER: 'site_power_w', ENERGY_AGGR: 'energy_aggr', ACTIVE_ENERGY_IN: 'active_energy_in_aggr', @@ -684,5 +692,6 @@ module.exports = { DCS_EFFICIENCY_FIELDS, EXPLORER_RACK_AGGR_FIELDS, EXPLORER_RACK_DEFAULT_LIMIT, - EXPLORER_RACK_MAX_LIMIT + EXPLORER_RACK_MAX_LIMIT, + LOG_FIELDS } diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index fa7dbdc..a3d04ac 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -9,7 +9,8 @@ const { MINER_CATEGORIES, LOG_KEYS, WORKER_TAGS, - DEVICE_LIST_FIELDS + DEVICE_LIST_FIELDS, + LOG_FIELDS } = require('../../constants') const { getStartOfDay, @@ -25,12 +26,15 @@ const { resolveInterval, getIntervalConfig } = require('../../metrics.utils') + async function getHashrate (ctx, req) { const { start, end } = validateStartEnd(req) const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() + if (req.query.groupBy) return getGoupedHashrate(ctx, req) + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { keys: [{ type: WORKER_TYPES.MINER, @@ -52,6 +56,35 @@ async function getHashrate (ctx, req) { return { log, summary } } +async function getGoupedHashrate (ctx, req) { + const { groupBy, start, end } = req.query + + const field = groupBy === WORKER_TYPES.MINER + ? LOG_FIELDS.HASHRATE_SUM_TYPE_GROUP + : LOG_FIELDS.HASHRATE_SUM_CONTAINER_GROUP + + const aggrField = groupBy === WORKER_TYPES.MINER + ? AGGR_FIELDS.HASHRATE_SUM_TYPE_GROUP_AGGR + : AGGR_FIELDS.HASHRATE_SUM_CONTAINER_GROUP_AGGR + + const res = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, { + type: WORKER_TYPES.MINER, + tag: WORKER_TAGS.MINER, + key: LOG_KEYS.STAT_1D, + start, + end, + fields: { [field]: 1 }, + aggrFields: { [aggrField]: 1 } + }) + + const log = res[0].reduce((aggr, val) => { + aggr.push({ ts: val.ts, hashrateMhs: val[aggrField] }) + return aggr + }, []) + + return { log, summary: {} } +} + function processHashrateData (results) { const daily = {} for (const entry of iterateRpcEntries(results)) { diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js index c6a59ac..6b16758 100644 --- a/workers/lib/server/routes/metrics.routes.js +++ b/workers/lib/server/routes/metrics.routes.js @@ -32,7 +32,8 @@ module.exports = (ctx) => { (req) => [ 'metrics/hashrate', req.query.start, - req.query.end + req.query.end, + req.query.groupBy ], ENDPOINTS.METRICS_HASHRATE, getHashrate diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index 8cbc39c..c6c6530 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -7,6 +7,7 @@ const schemas = { properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 0 }, + groupBy: { type: 'string', enum: ['miner', 'container'] }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] From b48c8e015b23528cd3d3ed5cd09902af52ca470d Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Tue, 28 Apr 2026 14:07:32 +0300 Subject: [PATCH 35/63] fix: finance v2 handler RPC regressions (#67) * fix: processBlockData parses HISTORICAL_BLOCKSIZES flat-array shape The mempool worker's getWrkExtData returns a flat array of block records [{ts, blockSize, blockReward, blockTotalFees}, ...], but processBlockData only handled nested .data/.blocks wrappers - it iterated entry's own field names, Number('ts') returned NaN, and produced an empty daily map. The SubsidyFee page then rendered "No data available". Detect the flat shape (entry has .ts/.timestamp/.time) and process it directly. Also surface blockSize on getSubsidyFees and getRevenueSummary log entries (and totalBlockSize/avgBlockSize on the summary) - needed for SubsidyFee's "Avg Fees in Sats/vByte" chart to render after this fix. * fix: cost-summary propagates per-day btcPrice Two compounding bugs in getCostSummary caused btcPrice=0 for every log entry, leaving ProductionCostPriceChart's overlay line at 0: 1. Wrong RPC key. The handler queried mempool with key: 'prices', which the worker doesn't recognize and so returns the live snapshot ({currentPrice, blockHeight, ...}). Switched to 'HISTORICAL_PRICES', which returns [{ts, priceUSD}, ...]. 2. processEbitdaPrices couldn't parse the production shape. Same class as the processBlockData fix (Bug C): the mempool worker returns a flat per-ORK array of records, not a wrapper. Detect when entry has .ts/.timestamp/.time and process it as the item directly; also accept priceUSD (the actual upstream field name) alongside price. The processEbitdaPrices fix incidentally makes getRevenueSummary and getHashRevenue use real per-day prices instead of falling back to currentBtcPrice. getEbitda still uses the wrong key: 'prices' (line 344) and is left for a follow-up. * fix: processTailLogData drills into .val for nested TAIL_LOG_RANGE_AGGR The TAIL_LOG_RANGE_AGGR RPC returns per-day items as {ts, val: {site_power_w, hashrate_mhs_5m_sum_aggr}}, but processTailLogData read item[AGGR_FIELDS.SITE_POWER] directly without drilling into .val, so powerW and hashrateMhs were 0 for every entry. RevenueSummary then showed 0 for Avg Power, Avg Hashrate, and Hashrate Capacity Factor; EBITDA's per-day power/hashrate were similarly zeroed. Mirror the .val/.flat fallback pattern that processConsumptionData already uses (workers/lib/server/handlers/finance.handlers.js:172). * fix: hash-revenue hashrate and network-hashrate populate from real RPC Two bugs in getHashRevenue caused hashrateMhs and networkHashrateMhs to be 0 for every log entry, leaving HashBalance entirely zeroed: 1. processHashrateData read item[AGGR_FIELDS.HASHRATE_SUM] directly, but TAIL_LOG_RANGE_AGGR wraps measurements as {ts, val: {hashrate_mhs_5m_sum_aggr}}. Same .val drilling fix as processTailLogData. 2. processNetworkHashrateData expected entries wrapped under .data, but the HISTORICAL_HASHRATE RPC returns a flat per-ORK array of {ts, avgHashrateMHs} records. Same flat-shape detection as processBlockData (Bug C). --- tests/unit/handlers/finance.handlers.test.js | 78 ++++++++++++++++++- tests/unit/handlers/finance.utils.test.js | 19 ++++- .../lib/server/handlers/finance.handlers.js | 46 +++++++---- workers/lib/server/handlers/finance.utils.js | 15 ++-- 4 files changed, 132 insertions(+), 26 deletions(-) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 0127a15..134ccd9 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -355,6 +355,34 @@ test('processTailLogData - processes power and hashrate', (t) => { t.pass() }) +test('processTailLogData - drills into .val (production shape)', (t) => { + const results = [ + [ + { + type: 'powermeter', + data: [ + { ts: 1700006400000, val: { site_power_w: 5000 } }, + { ts: 1700092800000, val: { site_power_w: 6000 } } + ] + }, + { + type: 'miner', + data: [ + { ts: 1700006400000, val: { hashrate_mhs_5m_sum_aggr: 100000 } }, + { ts: 1700092800000, val: { hashrate_mhs_5m_sum_aggr: 120000 } } + ] + } + ] + ] + + const daily = processTailLogData(results) + t.is(daily[1700006400000].powerW, 5000, 'extracts powerW from .val on day 1') + t.is(daily[1700006400000].hashrateMhs, 100000, 'extracts hashrateMhs from .val on day 1') + t.is(daily[1700092800000].powerW, 6000, 'extracts powerW from .val on day 2') + t.is(daily[1700092800000].hashrateMhs, 120000, 'extracts hashrateMhs from .val on day 2') + t.pass() +}) + test('processTailLogData - handles error results', (t) => { const results = [{ error: 'timeout' }] const daily = processTailLogData(results) @@ -371,6 +399,19 @@ test('processEbitdaPrices - processes valid data', (t) => { t.pass() }) +test('processEbitdaPrices - flat per-ork items with priceUSD (production shape)', (t) => { + const results = [ + [ + { ts: 1700006400000, priceUSD: 40000 }, + { ts: 1700092800000, priceUSD: 41500 } + ] + ] + const daily = processEbitdaPrices(results) + t.is(daily[1700006400000], 40000, 'should extract priceUSD for first day') + t.is(daily[1700092800000], 41500, 'should extract priceUSD for second day') + t.pass() +}) + test('calculateEbitdaSummary - calculates from log entries', (t) => { const log = [ { revenueBTC: 0.5, revenueUSD: 20000, totalCostsUSD: 5000, ebitdaSelling: 15000, ebitdaHodl: 15000 }, @@ -569,16 +610,18 @@ test('getSubsidyFees - empty ork results', async (t) => { test('calculateSubsidyFeesSummary - calculates from log entries', (t) => { const log = [ - { blockReward: 6.25, blockTotalFees: 0.5 }, - { blockReward: 6.25, blockTotalFees: 0.3 } + { blockReward: 6.25, blockTotalFees: 0.5, blockSize: 1500000 }, + { blockReward: 6.25, blockTotalFees: 0.3, blockSize: 1300000 } ] const summary = calculateSubsidyFeesSummary(log) t.is(summary.totalBlockReward, 12.5, 'should sum block rewards') t.is(summary.totalBlockTotalFees, 0.8, 'should sum block fees') + t.is(summary.totalBlockSize, 2800000, 'should sum block sizes') t.ok(summary.avgBlockReward !== null, 'should calculate avg block reward') t.is(summary.avgBlockReward, 6.25, 'should calculate correct avg block reward') t.ok(summary.avgBlockTotalFees !== null, 'should calculate avg block fees') + t.is(summary.avgBlockSize, 1400000, 'should calculate correct avg block size') t.pass() }) @@ -990,6 +1033,24 @@ test('processHashrateData - processes array data', (t) => { t.pass() }) +test('processHashrateData - drills into .val (production shape)', (t) => { + const results = [ + [ + { + type: 'miner', + data: [ + { ts: 1700006400000, val: { hashrate_mhs_5m_sum_aggr: 500000 } }, + { ts: 1700092800000, val: { hashrate_mhs_5m_sum_aggr: 600000 } } + ] + } + ] + ] + const daily = processHashrateData(results) + t.is(daily[1700006400000], 500000, 'extracts hashrate from .val on day 1') + t.is(daily[1700092800000], 600000, 'extracts hashrate from .val on day 2') + t.pass() +}) + test('processHashrateData - handles error results', (t) => { const results = [{ error: 'timeout' }] const daily = processHashrateData(results) @@ -1010,6 +1071,19 @@ test('processNetworkHashrateData - processes array data', (t) => { t.pass() }) +test('processNetworkHashrateData - flat per-ork items (production shape)', (t) => { + const results = [ + [ + { ts: 1700006400000, avgHashrateMHs: 1019725948656278 }, + { ts: 1700092800000, avgHashrateMHs: 1029591824888537 } + ] + ] + const daily = processNetworkHashrateData(results) + t.is(daily[1700006400000], 1019725948656278, 'extracts avgHashrateMHs day 1') + t.is(daily[1700092800000], 1029591824888537, 'extracts avgHashrateMHs day 2') + t.pass() +}) + test('processNetworkHashrateData - processes object-keyed data', (t) => { const results = [ [{ data: { 1700006400000: { avgHashrateMHs: 500000000000000 } } }] diff --git a/tests/unit/handlers/finance.utils.test.js b/tests/unit/handlers/finance.utils.test.js index b82f793..65a0adb 100644 --- a/tests/unit/handlers/finance.utils.test.js +++ b/tests/unit/handlers/finance.utils.test.js @@ -205,7 +205,8 @@ test('processBlockData - array items', (t) => { blocks: [{ ts: 1700006400000, blockReward: 6.25, - blockTotalFees: 0.5 + blockTotalFees: 0.5, + blockSize: 1500000 }] }] ] @@ -213,6 +214,22 @@ test('processBlockData - array items', (t) => { const key = Object.keys(daily)[0] t.is(daily[key].blockReward, 6.25, 'should extract blockReward') t.is(daily[key].blockTotalFees, 0.5, 'should extract blockTotalFees') + t.is(daily[key].blockSize, 1500000, 'should extract blockSize') + t.pass() +}) + +test('processBlockData - flat per-ork items (production shape)', (t) => { + const results = [ + [ + { ts: 1700006400000, blockSize: 1500000, blockHash: 'abc', blockReward: 6.25, blockTotalFees: 0.5 }, + { ts: 1700006400000, blockSize: 1200000, blockHash: 'def', blockReward: 6.25, blockTotalFees: 0.3 } + ] + ] + const daily = processBlockData(results) + const key = Object.keys(daily)[0] + t.is(daily[key].blockReward, 12.5, 'should sum blockReward across same-day items') + t.is(daily[key].blockTotalFees, 0.8, 'should sum blockTotalFees across same-day items') + t.is(daily[key].blockSize, 2700000, 'should sum blockSize across same-day items') t.pass() }) diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index 444e90d..5767a91 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -432,8 +432,9 @@ function processTailLogData (results) { for (const item of items) { const ts = getStartOfDay(item.ts || item.timestamp) if (!daily[ts]) daily[ts] = { powerW: 0, hashrateMhs: 0 } - daily[ts].powerW += (item[AGGR_FIELDS.SITE_POWER] || 0) - daily[ts].hashrateMhs += (item[AGGR_FIELDS.HASHRATE_SUM] || 0) + const val = item.val || item + daily[ts].powerW += (val[AGGR_FIELDS.SITE_POWER] || 0) + daily[ts].hashrateMhs += (val[AGGR_FIELDS.HASHRATE_SUM] || 0) } } } @@ -449,19 +450,21 @@ function processEbitdaPrices (results) { if (!Array.isArray(data)) continue for (const entry of data) { if (!entry) continue - const items = entry.data || entry.prices || entry + const rawTs = entry.ts || entry.timestamp || entry.time + const items = rawTs ? [entry] : (entry.data || entry.prices || entry) if (Array.isArray(items)) { for (const item of items) { const ts = getStartOfDay(item.ts || item.timestamp || item.time) - if (ts && item.price) { - daily[ts] = item.price + const price = item.priceUSD || item.price + if (ts && price) { + daily[ts] = price } } - } else if (typeof items === 'object' && !Array.isArray(items)) { + } else if (typeof items === 'object') { for (const [key, val] of Object.entries(items)) { const ts = getStartOfDay(Number(key)) if (ts) { - daily[ts] = typeof val === 'object' ? (val.USD || val.price || 0) : Number(val) || 0 + daily[ts] = typeof val === 'object' ? (val.USD || val.priceUSD || val.price || 0) : Number(val) || 0 } } } @@ -518,7 +521,7 @@ async function getCostSummary (ctx, req) { (cb) => ctx.dataProxy.requestData(RPC_METHODS.GET_WRK_EXT_DATA, { type: WORKER_TYPES.MEMPOOL, - query: { key: 'prices', start, end } + query: { key: 'HISTORICAL_PRICES', start, end } }).then(r => cb(null, r)).catch(cb), (cb) => ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG_RANGE_AGGR, { @@ -628,7 +631,8 @@ async function getSubsidyFees (ctx, req) { log.push({ ts, blockReward: block.blockReward, - blockTotalFees: block.blockTotalFees + blockTotalFees: block.blockTotalFees, + blockSize: block.blockSize }) } @@ -643,22 +647,27 @@ function calculateSubsidyFeesSummary (log) { return { totalBlockReward: 0, totalBlockTotalFees: 0, + totalBlockSize: 0, avgBlockReward: null, - avgBlockTotalFees: null + avgBlockTotalFees: null, + avgBlockSize: null } } const totals = log.reduce((acc, entry) => { acc.blockReward += entry.blockReward || 0 acc.blockTotalFees += entry.blockTotalFees || 0 + acc.blockSize += entry.blockSize || 0 return acc - }, { blockReward: 0, blockTotalFees: 0 }) + }, { blockReward: 0, blockTotalFees: 0, blockSize: 0 }) return { totalBlockReward: totals.blockReward, totalBlockTotalFees: totals.blockTotalFees, + totalBlockSize: totals.blockSize, avgBlockReward: safeDiv(totals.blockReward, log.length), - avgBlockTotalFees: safeDiv(totals.blockTotalFees, log.length) + avgBlockTotalFees: safeDiv(totals.blockTotalFees, log.length), + avgBlockSize: safeDiv(totals.blockSize, log.length) } } @@ -879,6 +888,7 @@ async function getRevenueSummary (ctx, req) { hashRevenueUSDPerPHsPerDay: safeDiv(revenueUSD, hashratePhs), blockReward: block.blockReward || 0, blockTotalFees: block.blockTotalFees || 0, + blockSize: block.blockSize || 0, curtailmentMWh, curtailmentRate, operationalIssuesRate, @@ -1087,7 +1097,8 @@ function processHashrateData (results) { const ts = getStartOfDay(item.ts || item.timestamp) if (!ts) continue if (!daily[ts]) daily[ts] = 0 - daily[ts] += (item[AGGR_FIELDS.HASHRATE_SUM] || 0) + const val = item.val || item + daily[ts] += (val[AGGR_FIELDS.HASHRATE_SUM] || 0) } } } @@ -1103,18 +1114,19 @@ function processNetworkHashrateData (results) { if (!Array.isArray(data)) continue for (const entry of data) { if (!entry) continue - const items = entry.data || entry + const rawTs = entry.ts || entry.timestamp || entry.time + const items = rawTs ? [entry] : (entry.data || entry) if (Array.isArray(items)) { for (const item of items) { if (!item) continue - const rawTs = item.ts || item.timestamp || item.time - const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + const itemTs = item.ts || item.timestamp || item.time + const ts = getStartOfDay(normalizeTimestampMs(itemTs)) if (!ts) continue if (item.avgHashrateMHs) { daily[ts] = item.avgHashrateMHs } } - } else if (typeof items === 'object' && !Array.isArray(items)) { + } else if (typeof items === 'object') { for (const [key, val] of Object.entries(items)) { const ts = getStartOfDay(Number(key)) if (!ts) continue diff --git a/workers/lib/server/handlers/finance.utils.js b/workers/lib/server/handlers/finance.utils.js index 8b6746c..853c8e5 100644 --- a/workers/lib/server/handlers/finance.utils.js +++ b/workers/lib/server/handlers/finance.utils.js @@ -94,25 +94,28 @@ function processBlockData (results) { if (!Array.isArray(data)) continue for (const entry of data) { if (!entry) continue - const items = entry.data || entry.blocks || entry + const rawTs = entry.ts || entry.timestamp || entry.time + const items = rawTs ? [entry] : (entry.data || entry.blocks || entry) if (Array.isArray(items)) { for (const item of items) { if (!item) continue - const rawTs = item.ts || item.timestamp || item.time - const ts = getStartOfDay(normalizeTimestampMs(rawTs)) + const itemTs = item.ts || item.timestamp || item.time + const ts = getStartOfDay(normalizeTimestampMs(itemTs)) if (!ts) continue - if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } + if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0, blockSize: 0 } daily[ts].blockReward += (item.blockReward || item.block_reward || item.subsidy || 0) daily[ts].blockTotalFees += (item.blockTotalFees || item.block_total_fees || item.totalFees || item.total_fees || 0) + daily[ts].blockSize += (item.blockSize || item.block_size || item.size || 0) } - } else if (typeof items === 'object' && !Array.isArray(items)) { + } else if (typeof items === 'object') { for (const [key, val] of Object.entries(items)) { const ts = getStartOfDay(Number(key)) if (!ts) continue - if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0 } + if (!daily[ts]) daily[ts] = { blockReward: 0, blockTotalFees: 0, blockSize: 0 } if (typeof val === 'object') { daily[ts].blockReward += (val.blockReward || val.block_reward || val.subsidy || 0) daily[ts].blockTotalFees += (val.blockTotalFees || val.block_total_fees || val.totalFees || val.total_fees || 0) + daily[ts].blockSize += (val.blockSize || val.block_size || val.size || 0) } } } From bbd641e1e55e5274e17e9bb031ffbfa3f8183fa6 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Tue, 28 Apr 2026 14:43:04 +0300 Subject: [PATCH 36/63] Fix memory leak in authCheck by implementing LRU caching and removing in-memory cache (#61) --- tests/unit/lib/authCheck.test.js | 32 +++++++++++++++++++++++++++++ tests/unit/lib/routeHelpers.test.js | 2 +- workers/http.node.wrk.js | 2 ++ workers/lib/constants.js | 3 +++ workers/lib/server/lib/authCheck.js | 17 +++++---------- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/tests/unit/lib/authCheck.test.js b/tests/unit/lib/authCheck.test.js index 9a90581..9591a6e 100644 --- a/tests/unit/lib/authCheck.test.js +++ b/tests/unit/lib/authCheck.test.js @@ -1,6 +1,7 @@ 'use strict' const test = require('brittle') +const LRU = require('lru') const { authCheck } = require('../../../workers/lib/server/lib/authCheck') test('authCheck - noAuth mode', async (t) => { @@ -74,6 +75,7 @@ test('authCheck - cached token', async (t) => { conf: { ttl: 3600 }, + lru_1m: new Map(), authLib: { resolveToken: async (token, ips) => { t.is(token, 'cached-token', 'should use cached token') @@ -181,6 +183,36 @@ test('authCheck - resolveToken throws error', async (t) => { t.pass() }) +test('authCheck - LRU evicts when max capacity reached', async (t) => { + let resolveTokenCalls = 0 + const mockCtx = { + noAuth: false, + conf: { ttl: 3600 }, + lru_1m: new LRU({ max: 1 }), + authLib: { + resolveToken: async () => { + resolveTokenCalls++ + return { userId: 'test-user' } + } + } + } + + const makeReq = (token) => ({ + headers: { authorization: `Bearer ${token}` }, + ip: '127.0.0.1', + _info: {} + }) + + await authCheck(mockCtx, makeReq('token-a'), {}) + t.is(resolveTokenCalls, 1, 'token-a resolved on first call') + + await authCheck(mockCtx, makeReq('token-b'), {}) + t.is(resolveTokenCalls, 2, 'token-b resolved; evicts token-a at max=1') + + await authCheck(mockCtx, makeReq('token-a'), {}) + t.is(resolveTokenCalls, 3, 'token-a re-resolved after eviction proves LRU is bounded') +}) + test('authCheck - no authLib', async (t) => { const mockCtx = { noAuth: false, diff --git a/tests/unit/lib/routeHelpers.test.js b/tests/unit/lib/routeHelpers.test.js index 11440cd..53d8615 100644 --- a/tests/unit/lib/routeHelpers.test.js +++ b/tests/unit/lib/routeHelpers.test.js @@ -69,7 +69,7 @@ test('createAuthOnRequest - calls capCheck when perms provided', async (t) => { noAuth: false, authLib: { resolveToken: async () => ({ userId: 'test' }), - tokenHasPerms: async () => true + tokenHasPerms: async () => { capCheckCalled = true; return true } } } diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index b5af9e1..dcf434e 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -11,6 +11,7 @@ const { UserService } = require('./lib/users') const { AlertsService } = require('./lib/alerts') const { auditLogger } = require('./lib/server/lib/auditLogger') const { createDataProxy } = require('./lib/data.proxy') +const { AUTH_CACHE_TTL } = require('./lib/constants') class WrkServerHttp extends TetherWrkBase { constructor (conf, ctx) { @@ -44,6 +45,7 @@ class WrkServerHttp extends TetherWrkBase { ['fac', 'bfx-facs-lru', '10s', '10s', { max: 10000, maxAge: 10000 }], ['fac', 'bfx-facs-lru', '15s', '15s', { max: 10000, maxAge: 15000 }], ['fac', 'bfx-facs-lru', '30s', '30s', { max: 10000, maxAge: 30000 }], + ['fac', 'bfx-facs-lru', '1m', '1m', { max: 10000, maxAge: AUTH_CACHE_TTL }], ['fac', 'bfx-facs-lru', '15m', '15m', { max: 10000, maxAge: 60000 * 15 }], ['fac', 'bfx-facs-db-sqlite', 'auth', 'auth', { name: 'miningos-app-node', persist: true }], ['fac', 'bfx-facs-http', 'c0', 'c0', { timeout: 30000, debug: false }, 0], diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 9de6d24..182a00a 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -537,6 +537,8 @@ const RPC_TIMEOUT = 15000 const RPC_CONCURRENCY_LIMIT = 2 const RPC_PAGE_LIMIT = 100 +const AUTH_CACHE_TTL = 60 * 1000 + const ACTIONS_MAX_QUERIES = 10 const ACTIONS_QUERIES_MAX_LENGTH = 1000 @@ -649,6 +651,7 @@ module.exports = { RPC_TIMEOUT, RPC_CONCURRENCY_LIMIT, RPC_PAGE_LIMIT, + AUTH_CACHE_TTL, ACTIONS_MAX_QUERIES, ACTIONS_QUERIES_MAX_LENGTH, USER_SETTINGS_TYPE, diff --git a/workers/lib/server/lib/authCheck.js b/workers/lib/server/lib/authCheck.js index 802a859..8e73dc9 100644 --- a/workers/lib/server/lib/authCheck.js +++ b/workers/lib/server/lib/authCheck.js @@ -1,10 +1,7 @@ 'use strict' const { extractIps, getAuthTokenFromHeaders } = require('../../utils') - -// Basic in-memory cache -const tokenCache = new Map() -const CACHE_TTL = 1 * 60 * 1000 // 1 minutes +const { AUTH_CACHE_TTL } = require('../../constants') async function authCheck (ctx, req, rep, tokenFromQuery = null) { req._info = req._info || {} @@ -23,11 +20,10 @@ async function authCheck (ctx, req, rep, tokenFromQuery = null) { const ips = extractIps(req) const cacheKey = `${token}:${ips.join(',')}` - const now = Date.now() - const cached = tokenCache.get(cacheKey) - if (cached && now - cached.timestamp < CACHE_TTL && (ctx.conf.ttl * 1000) > CACHE_TTL) { - req._info.user = cached.user + const cached = ctx.lru_1m?.get(cacheKey) + if (cached && (ctx.conf.ttl * 1000) > AUTH_CACHE_TTL) { + req._info.user = cached req._info.authToken = token return } @@ -43,10 +39,7 @@ async function authCheck (ctx, req, rep, tokenFromQuery = null) { }) } - tokenCache.set(cacheKey, { - user, - timestamp: now - }) + ctx.lru_1m?.set(cacheKey, user) req._info.user = user req._info.authToken = token From 79823dbc07d93f7ca6ba83f5259787a2015d3ab7 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:40:27 +0530 Subject: [PATCH 37/63] Support summary for grouped by in hashrate metrics api (#68) * Support summary for grouped by in hashrate metrics api * update groupKey --- tests/unit/handlers/metrics.handlers.test.js | 22 +++++++--- .../lib/server/handlers/metrics.handlers.js | 44 ++++++++++++++++++- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index a7ca481..e675a58 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -75,7 +75,7 @@ test('getHashrate - grouped by miner uses type group aggregation', async (t) => capturedPayload = payload return [{ ts: 1700006400000, - hashrate_mhs_5m_type_group_sum_aggr: 123456 + hashrate_mhs_5m_type_group_sum_aggr: { 'S19-Pro': 100000, 'S21': 23456 } }] } } @@ -88,8 +88,12 @@ test('getHashrate - grouped by miner uses type group aggregation', async (t) => t.is(capturedPayload.fields.hashrate_mhs_5m_type_group_sum, 1, 'should request type-group source field') t.is(capturedPayload.aggrFields.hashrate_mhs_5m_type_group_sum_aggr, 1, 'should request type-group aggregate field') t.is(result.log.length, 1, 'should map one grouped row') - t.is(result.log[0].hashrateMhs, 123456, 'should map grouped hashrate value') - t.alike(result.summary, {}, 'grouped response should return empty summary') + t.alike(result.log[0].hashrateMhs, { 'S19-Pro': 100000, 'S21': 23456 }, 'should map grouped hashrate value') + t.is(result.summary.totalHashrateMhs, 123456, 'should have site-wide total') + t.is(result.summary.avgHashrateMhs, 123456, 'should have site-wide average') + t.ok(result.summary.groupedBy, 'should have per-miner breakdown') + t.is(result.summary.groupedBy['S19-Pro'].totalHashrateMhs, 100000, 'should have per-miner total') + t.is(result.summary.groupedBy['S21'].totalHashrateMhs, 23456, 'should have per-miner total') t.pass() }) @@ -102,7 +106,7 @@ test('getHashrate - grouped by container uses container group aggregation', asyn capturedPayload = payload return [{ ts: 1700006400000, - hashrate_mhs_5m_container_group_sum_aggr: 777 + hashrate_mhs_5m_container_group_sum_aggr: { 'container-A': 500, 'container-B': 277 } }] } } @@ -115,8 +119,11 @@ test('getHashrate - grouped by container uses container group aggregation', asyn t.is(capturedPayload.fields.hashrate_mhs_5m_container_group_sum, 1, 'should request container-group source field') t.is(capturedPayload.aggrFields.hashrate_mhs_5m_container_group_sum_aggr, 1, 'should request container-group aggregate field') t.is(result.log.length, 1, 'should map grouped row') - t.is(result.log[0].hashrateMhs, 777, 'should map container grouped hashrate value') - t.alike(result.summary, {}, 'grouped response should return empty summary') + t.alike(result.log[0].hashrateMhs, { 'container-A': 500, 'container-B': 277 }, 'should map container grouped hashrate value') + t.is(result.summary.totalHashrateMhs, 777, 'should have site-wide total') + t.ok(result.summary.groupedBy, 'should have per-container breakdown') + t.is(result.summary.groupedBy['container-A'].totalHashrateMhs, 500, 'should have per-container total') + t.is(result.summary.groupedBy['container-B'].totalHashrateMhs, 277, 'should have per-container total') t.pass() }) @@ -131,7 +138,8 @@ test('getHashrate - grouped mode handles empty results', async (t) => { }) t.is(result.log.length, 0, 'grouped log should be empty when no data is returned') - t.alike(result.summary, {}, 'grouped summary should still be empty object') + t.is(result.summary.avgHashrateMhs, null, 'grouped empty summary should have null avg') + t.is(result.summary.totalHashrateMhs, 0, 'grouped empty summary should have zero total') t.pass() }) diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index a3d04ac..d19ae25 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -82,7 +82,9 @@ async function getGoupedHashrate (ctx, req) { return aggr }, []) - return { log, summary: {} } + const summary = calculateGroupedHashrateSummary(log, groupBy) + + return { log, summary } } function processHashrateData (results) { @@ -112,6 +114,45 @@ function calculateHashrateSummary (log) { } } +function calculateGroupedHashrateSummary (log, groupBy) { + if (!log.length) { + return { + avgHashrateMhs: null, + totalHashrateMhs: 0 + } + } + + const groupTotals = {} + const groupCounts = {} + + for (const entry of log) { + const hashrate = entry.hashrateMhs + if (typeof hashrate === 'object' && hashrate !== null) { + for (const [name, val] of Object.entries(hashrate)) { + const v = Number(val) || 0 + groupTotals[name] = (groupTotals[name] || 0) + v + groupCounts[name] = (groupCounts[name] || 0) + 1 + } + } + } + + const byGroup = {} + let siteTotal = 0 + for (const [name, total] of Object.entries(groupTotals)) { + byGroup[name] = { + avgHashrateMhs: safeDiv(total, groupCounts[name]), + totalHashrateMhs: total + } + siteTotal += total + } + + return { + avgHashrateMhs: safeDiv(siteTotal, log.length), + totalHashrateMhs: siteTotal, + groupedBy: byGroup + } +} + async function getConsumption (ctx, req) { const { start, end } = validateStartEnd(req) @@ -728,6 +769,7 @@ module.exports = { getHashrate, processHashrateData, calculateHashrateSummary, + calculateGroupedHashrateSummary, getConsumption, processConsumptionData, calculateConsumptionSummary, From 2a67b206a23b4688795fded855e10dacda7e5209 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 29 Apr 2026 13:20:53 +0300 Subject: [PATCH 38/63] fix: groups stats accepts rack ids and returns rack objects (#69) --- tests/unit/handlers/groups.handlers.test.js | 366 +++++++----------- tests/unit/lib/queryUtils.test.js | 28 +- workers/lib/constants.js | 9 +- .../lib/server/handlers/groups.handlers.js | 146 ++++--- workers/lib/server/lib/queryUtils.js | 8 +- workers/lib/server/routes/groups.routes.js | 2 +- workers/lib/server/schemas/groups.schemas.js | 4 +- 7 files changed, 247 insertions(+), 316 deletions(-) diff --git a/tests/unit/handlers/groups.handlers.test.js b/tests/unit/handlers/groups.handlers.test.js index 42c0920..1cea483 100644 --- a/tests/unit/handlers/groups.handlers.test.js +++ b/tests/unit/handlers/groups.handlers.test.js @@ -3,282 +3,192 @@ const test = require('brittle') const { getGroupStats, - composeGroupStats, - sumGroupedField + mapRackIdToKeys, + formatRackValues } = require('../../../workers/lib/server/handlers/groups.handlers') -const { extractKeyEntry } = require('../../../workers/lib/metrics.utils') -const { withDataProxy } = require('../helpers/mockHelpers') -// ==================== extractKeyEntry Tests ==================== - -test('extractKeyEntry - returns entry at index', (t) => { - const orkResult = [[{ hashrate: 100 }], [{ power: 200 }]] - const entry = extractKeyEntry(orkResult, 0) - t.alike(entry, { hashrate: 100 }, 'should return first key entry') - t.pass() -}) - -test('extractKeyEntry - returns null for non-array', (t) => { - t.is(extractKeyEntry(null, 0), null, 'null input returns null') - t.is(extractKeyEntry({}, 0), null, 'object input returns null') - t.pass() -}) - -test('extractKeyEntry - returns null for empty key result', (t) => { - t.is(extractKeyEntry([[]], 0), null, 'empty array returns null') - t.is(extractKeyEntry([], 0), null, 'missing index returns null') - t.pass() -}) +function createMockTailLogEntry () { + return { + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { + 'group-1_1-1': 5000000000, + 'group-1_2-1': 4000000000, + 'group-2_1-1': 3000000000, + 'group-2_2-1': 6000000000 + }, + power_w_pdu_rack_group_sum_aggr: { + 'group-1_1-1': 500000, + 'group-1_2-1': 400000, + 'group-2_1-1': 300000, + 'group-2_2-1': 600000 + }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { + 'group-1_1-1': 6, + 'group-1_2-1': 6, + 'group-2_1-1': 6, + 'group-2_2-1': 6 + } + } +} + +function createMockDcsThing () { + return { + id: 'dcs-1', + type: 'wrk-dcs-siemens', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { total_groups: 2, racks_per_group: 2, miners_per_rack: 20 } + } + } + } + } +} -// ==================== sumGroupedField Tests ==================== +function createMockCtx ({ dcsEnabled = true, tailLogEntry = createMockTailLogEntry(), dcsThing = createMockDcsThing() } = {}) { + return { + conf: { + featureConfig: dcsEnabled ? { centralDCSSetup: { enabled: true, tag: 't-dcs' } } : {} + }, + dataProxy: { + requestDataMap: async (method) => { + if (method === 'tailLogMulti') return [[[tailLogEntry]]] + if (method === 'listThings') return dcsEnabled ? [[dcsThing]] : [[]] + return [] + } + } + } +} -test('sumGroupedField - sums values for matching containers', (t) => { - const grouped = { 'C-01': 100, 'C-02': 200, 'C-03': 300 } - t.is(sumGroupedField(grouped, ['C-01', 'C-03']), 400, 'should sum matching containers') - t.pass() -}) +// ==================== mapRackIdToKeys ==================== -test('sumGroupedField - returns 0 for non-matching containers', (t) => { - const grouped = { 'C-01': 100 } - t.is(sumGroupedField(grouped, ['C-99']), 0, 'should return 0 for missing containers') - t.pass() -}) +test('mapRackIdToKeys - maps synth rack-N ids to Nth real key per group', (t) => { + const racks = [ + { id: 'group-1_rack-1', group: { id: 'group-1' } }, + { id: 'group-1_rack-2', group: { id: 'group-1' } }, + { id: 'group-2_rack-1', group: { id: 'group-2' } } + ] + const stats = { + hashrateByRack: { 'group-1_1-1': 1, 'group-1_2-1': 1, 'group-2_1-1': 1 }, + powerByRack: {}, + efficiencyByRack: {} + } -test('sumGroupedField - handles null/undefined input', (t) => { - t.is(sumGroupedField(null, ['C-01']), 0, 'null returns 0') - t.is(sumGroupedField(undefined, ['C-01']), 0, 'undefined returns 0') + const map = mapRackIdToKeys(racks, stats) + t.is(map.get('group-1_rack-1'), 'group-1_1-1') + t.is(map.get('group-1_rack-2'), 'group-1_2-1') + t.is(map.get('group-2_rack-1'), 'group-2_1-1') t.pass() }) -// ==================== composeGroupStats Tests ==================== - -test('composeGroupStats - aggregates container-grouped data across orks', (t) => { - const results = [ - [ - [{ - hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 50000, 'C-02': 30000 }, - power_w_container_group_sum_aggr: { 'C-01': 5000, 'C-02': 3000 }, - power_mode_low_cnt: { 'C-01': 2, 'C-02': 1 }, - power_mode_normal_cnt: { 'C-01': 5, 'C-02': 4 }, - power_mode_high_cnt: { 'C-01': 3, 'C-02': 3 }, - offline_cnt: { 'C-01': 1, 'C-02': 0 }, - error_cnt: { 'C-01': 0, 'C-02': 1 }, - not_mining_cnt: { 'C-01': 0, 'C-02': 0 }, - power_mode_sleep_cnt: { 'C-01': 1, 'C-02': 0 } - }] - ] +test('mapRackIdToKeys - undefined when no real key at that position', (t) => { + const racks = [ + { id: 'group-1_rack-1', group: { id: 'group-1' } }, + { id: 'group-1_rack-2', group: { id: 'group-1' } } ] + const stats = { + hashrateByRack: { 'group-1_1-1': 1 }, + powerByRack: {}, + efficiencyByRack: {} + } - const stats = composeGroupStats(results, ['C-01', 'C-02']) - t.is(stats.hashrateMhs, 80000, 'should sum hashrate for both containers') - t.is(stats.powerW, 8000, 'should sum power for both containers') - t.is(stats.onlineCount, 18, 'should sum online miners (low+normal+high)') - t.is(stats.minerCount, 21, 'should sum all miners across all statuses') - t.ok(typeof stats.efficiency === 'number', 'should have efficiency') + const map = mapRackIdToKeys(racks, stats) + t.is(map.get('group-1_rack-1'), 'group-1_1-1') + t.is(map.get('group-1_rack-2'), undefined) t.pass() }) -test('composeGroupStats - filters to requested containers only', (t) => { - const results = [ - [ - [{ - hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 50000, 'C-02': 30000, 'C-03': 20000 }, - power_w_container_group_sum_aggr: { 'C-01': 5000, 'C-02': 3000, 'C-03': 2000 }, - power_mode_normal_cnt: { 'C-01': 10, 'C-02': 8, 'C-03': 6 }, - power_mode_low_cnt: {}, - power_mode_high_cnt: {}, - offline_cnt: {}, - error_cnt: {}, - not_mining_cnt: {}, - power_mode_sleep_cnt: {} - }] - ] - ] +// ==================== formatRackValues ==================== - const stats = composeGroupStats(results, ['C-01']) - t.is(stats.hashrateMhs, 50000, 'should only include C-01 hashrate') - t.is(stats.powerW, 5000, 'should only include C-01 power') - t.is(stats.onlineCount, 10, 'should only include C-01 miners') +test('formatRackValues - leaves rack unchanged when no real key', (t) => { + const rack = { id: 'x', hashrate: { value: 0, unit: 'PH/s' } } + const out = formatRackValues(rack, undefined, { hashrateByRack: {}, powerByRack: {}, efficiencyByRack: {} }) + t.alike(out, rack) t.pass() }) -test('composeGroupStats - empty results', (t) => { - const stats = composeGroupStats([], ['C-01']) - t.is(stats.hashrateMhs, 0, 'hashrate should be 0') - t.is(stats.powerW, 0, 'power should be 0') - t.is(stats.minerCount, 0, 'miner count should be 0') - t.is(stats.onlineCount, 0, 'online count should be 0') - t.is(stats.efficiency, 0, 'efficiency should be 0 when no hashrate') +test('formatRackValues - overrides stats with values from the real key', (t) => { + const rack = { id: 'group-1_rack-1', hashrate: { value: 0, unit: 'PH/s' } } + const stats = { + hashrateByRack: { 'group-1_1-1': 5000000000 }, + powerByRack: { 'group-1_1-1': 500000 }, + efficiencyByRack: { 'group-1_1-1': 6 } + } + + const out = formatRackValues(rack, 'group-1_1-1', stats) + t.is(out.id, 'group-1_rack-1', 'public id is preserved') + t.ok(out.hashrate.value > 0) + t.is(out.consumption.value, 500, '500000 W → 500 kW') + t.ok(out.efficiency.value > 0) t.pass() }) -test('composeGroupStats - zero hashrate gives zero efficiency', (t) => { - const results = [ - [ - [{ - hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 0 }, - power_w_container_group_sum_aggr: { 'C-01': 5000 }, - power_mode_low_cnt: {}, - power_mode_normal_cnt: {}, - power_mode_high_cnt: {}, - offline_cnt: { 'C-01': 2 }, - error_cnt: {}, - not_mining_cnt: {}, - power_mode_sleep_cnt: {} - }] - ] - ] +// ==================== getGroupStats ==================== - const stats = composeGroupStats(results, ['C-01']) - t.is(stats.efficiency, 0, 'efficiency should be 0 with zero hashrate') - t.pass() -}) +test('getGroupStats - returns per-rack data with real values mapped to synth ids', async (t) => { + const ctx = createMockCtx() + const result = await getGroupStats(ctx, { query: { racks: 'group-1_rack-1,group-1_rack-2' } }) -test('composeGroupStats - handles missing fields gracefully', (t) => { - const results = [ - [ - [{}] - ] - ] + t.is(result.totalCount, 2) + const [first, second] = result.data + t.is(first.id, 'group-1_rack-1') + t.alike(first.group, { id: 'group-1', name: 'Group 1' }) + t.is(first.miners_count, 20) + t.ok(first.hashrate.value > 0, 'mapped to group-1_1-1 real values') + t.ok(first.consumption.value > 0) + t.ok(first.efficiency.value > 0) - const stats = composeGroupStats(results, ['C-01']) - t.is(stats.hashrateMhs, 0, 'missing fields default to 0') - t.is(stats.powerW, 0, 'missing power defaults to 0') + t.is(second.id, 'group-1_rack-2') + t.ok(second.hashrate.value > 0, 'mapped to group-1_2-1 real values') t.pass() }) -test('composeGroupStats - multi-ork aggregation', (t) => { - const results = [ - [ - [{ - hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 40000 }, - power_w_container_group_sum_aggr: { 'C-01': 4000 }, - power_mode_normal_cnt: { 'C-01': 8 }, - power_mode_low_cnt: {}, - power_mode_high_cnt: {}, - offline_cnt: { 'C-01': 1 }, - error_cnt: {}, - not_mining_cnt: {}, - power_mode_sleep_cnt: {} - }] - ], - [ - [{ - hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 20000 }, - power_w_container_group_sum_aggr: { 'C-01': 2000 }, - power_mode_normal_cnt: { 'C-01': 4 }, - power_mode_low_cnt: {}, - power_mode_high_cnt: {}, - offline_cnt: {}, - error_cnt: {}, - not_mining_cnt: {}, - power_mode_sleep_cnt: {} - }] - ] - ] +test('getGroupStats - cross-group selection', async (t) => { + const ctx = createMockCtx() + const result = await getGroupStats(ctx, { query: { racks: 'group-1_rack-1,group-2_rack-2' } }) - const stats = composeGroupStats(results, ['C-01']) - t.is(stats.hashrateMhs, 60000, 'should sum hashrate across orks') - t.is(stats.powerW, 6000, 'should sum power across orks') - t.is(stats.onlineCount, 12, 'should sum online across orks') - t.is(stats.minerCount, 13, 'should sum all miners across orks') + t.is(result.totalCount, 2) + const ids = result.data.map(r => r.id) + t.alike(ids, ['group-1_rack-1', 'group-2_rack-2']) + result.data.forEach(rack => t.ok(rack.hashrate.value > 0)) t.pass() }) -// ==================== getGroupStats Tests ==================== +test('getGroupStats - unknown rack ids are dropped', async (t) => { + const ctx = createMockCtx() + const result = await getGroupStats(ctx, { query: { racks: 'group-1_rack-1,group-99_rack-99' } }) -test('getGroupStats - happy path', async (t) => { - const mockCtx = withDataProxy({ - conf: { - orks: [{ rpcPublicKey: 'key1' }] - }, - net_r0: { - jRequest: async () => { - return [ - [{ - hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 60000, 'C-02': 40000 }, - power_w_container_group_sum_aggr: { 'C-01': 6000, 'C-02': 4000 }, - power_mode_normal_cnt: { 'C-01': 12, 'C-02': 8 }, - power_mode_low_cnt: {}, - power_mode_high_cnt: {}, - offline_cnt: { 'C-01': 1 }, - error_cnt: {}, - not_mining_cnt: {}, - power_mode_sleep_cnt: {} - }] - ] - } - } - }) - - const mockReq = { query: { containers: 'C-01,C-02' } } - const result = await getGroupStats(mockCtx, mockReq) - - t.is(result.hashrateMhs, 100000, 'should have hashrate for both containers') - t.is(result.powerW, 10000, 'should have power for both containers') - t.is(result.minerCount, 21, 'should have miner count') - t.is(result.onlineCount, 20, 'should have online count') - t.ok(typeof result.efficiency === 'number', 'should have efficiency') + t.is(result.totalCount, 1) + t.is(result.data[0].id, 'group-1_rack-1') t.pass() }) -test('getGroupStats - missing containers throws', async (t) => { - const mockCtx = withDataProxy({ - conf: { orks: [] }, - net_r0: { jRequest: async () => ({}) } - }) - +test('getGroupStats - missing racks throws', async (t) => { + const ctx = createMockCtx() try { - await getGroupStats(mockCtx, { query: {} }) + await getGroupStats(ctx, { query: {} }) t.fail('should have thrown') } catch (err) { - t.is(err.message, 'ERR_MISSING_CONTAINERS', 'should throw missing containers error') + t.is(err.message, 'ERR_MISSING_RACKS') } t.pass() }) -test('getGroupStats - empty containers string throws', async (t) => { - const mockCtx = withDataProxy({ - conf: { orks: [] }, - net_r0: { jRequest: async () => ({}) } - }) - +test('getGroupStats - empty racks string throws', async (t) => { + const ctx = createMockCtx() try { - await getGroupStats(mockCtx, { query: { containers: '' } }) + await getGroupStats(ctx, { query: { racks: '' } }) t.fail('should have thrown') } catch (err) { - t.is(err.message, 'ERR_MISSING_CONTAINERS', 'should throw for empty containers') + t.is(err.message, 'ERR_MISSING_RACKS') } t.pass() }) -test('getGroupStats - filters to requested containers', async (t) => { - const mockCtx = withDataProxy({ - conf: { - orks: [{ rpcPublicKey: 'key1' }] - }, - net_r0: { - jRequest: async () => { - return [ - [{ - hashrate_mhs_1m_container_group_sum_aggr: { 'C-01': 50000, 'C-02': 30000, 'C-03': 20000 }, - power_w_container_group_sum_aggr: { 'C-01': 5000, 'C-02': 3000, 'C-03': 2000 }, - power_mode_normal_cnt: { 'C-01': 10, 'C-02': 8, 'C-03': 6 }, - power_mode_low_cnt: {}, - power_mode_high_cnt: {}, - offline_cnt: {}, - error_cnt: {}, - not_mining_cnt: {}, - power_mode_sleep_cnt: {} - }] - ] - } - } - }) - - const result = await getGroupStats(mockCtx, { query: { containers: 'C-01' } }) - t.is(result.hashrateMhs, 50000, 'should only include C-01 hashrate') - t.is(result.powerW, 5000, 'should only include C-01 power') - t.is(result.onlineCount, 10, 'should only include C-01 miners') +test('getGroupStats - returns empty data when DCS disabled', async (t) => { + const ctx = createMockCtx({ dcsEnabled: false }) + const result = await getGroupStats(ctx, { query: { racks: 'group-1_rack-1' } }) + t.is(result.totalCount, 0) + t.alike(result.data, []) t.pass() }) diff --git a/tests/unit/lib/queryUtils.test.js b/tests/unit/lib/queryUtils.test.js index 7e09896..34ea718 100644 --- a/tests/unit/lib/queryUtils.test.js +++ b/tests/unit/lib/queryUtils.test.js @@ -8,7 +8,7 @@ const { buildSearchQuery, flattenOrkResults, sortItems, - parseContainers, + parseRacks, paginateResults } = require('../../../workers/lib/server/lib/queryUtils') @@ -277,28 +277,28 @@ test('paginateResults - offset beyond total', (t) => { t.pass() }) -// ==================== parseContainers Tests ==================== +// ==================== parseRacks Tests ==================== -test('parseContainers - parses comma-separated containers', (t) => { - const result = parseContainers({ query: { containers: 'C-01,C-02,C-03' } }) - t.alike(result, ['C-01', 'C-02', 'C-03'], 'should split on commas') +test('parseRacks - parses comma-separated racks', (t) => { + const result = parseRacks({ query: { racks: 'group-1,group-2,group-3' } }) + t.alike(result, ['group-1', 'group-2', 'group-3'], 'should split on commas') t.pass() }) -test('parseContainers - trims whitespace', (t) => { - const result = parseContainers({ query: { containers: 'C-01 , C-02 , C-03' } }) - t.alike(result, ['C-01', 'C-02', 'C-03'], 'should trim spaces') +test('parseRacks - trims whitespace', (t) => { + const result = parseRacks({ query: { racks: 'group-1 , group-2 , group-3' } }) + t.alike(result, ['group-1', 'group-2', 'group-3'], 'should trim spaces') t.pass() }) -test('parseContainers - returns undefined when no containers', (t) => { - t.is(parseContainers({ query: {} }), undefined, 'missing containers returns undefined') - t.is(parseContainers({ query: { containers: '' } }), undefined, 'empty string returns undefined') +test('parseRacks - returns undefined when no racks', (t) => { + t.is(parseRacks({ query: {} }), undefined, 'missing racks returns undefined') + t.is(parseRacks({ query: { racks: '' } }), undefined, 'empty string returns undefined') t.pass() }) -test('parseContainers - single container', (t) => { - const result = parseContainers({ query: { containers: 'C-01' } }) - t.alike(result, ['C-01'], 'single container returns array with one element') +test('parseRacks - single rack', (t) => { + const result = parseRacks({ query: { racks: 'group-1' } }) + t.alike(result, ['group-1'], 'single rack returns array with one element') t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 182a00a..e929af9 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -494,14 +494,7 @@ const AGGR_FIELDS = { OFFLINE_CNT: 'offline_cnt', SLEEP_CNT: 'power_mode_sleep_cnt', MAINTENANCE_CNT: 'maintenance_type_cnt', - CONTAINER_SPECIFIC_STATS: 'container_specific_stats_group_aggr', - HASHRATE_1M_CONTAINER_GROUP_SUM: 'hashrate_mhs_1m_container_group_sum_aggr', - POWER_W_CONTAINER_GROUP_SUM: 'power_w_container_group_sum_aggr', - POWER_MODE_LOW_CNT: 'power_mode_low_cnt', - POWER_MODE_NORMAL_CNT: 'power_mode_normal_cnt', - POWER_MODE_HIGH_CNT: 'power_mode_high_cnt', - ERROR_CNT: 'error_cnt', - NOT_MINING_CNT: 'not_mining_cnt' + CONTAINER_SPECIFIC_STATS: 'container_specific_stats_group_aggr' } const PERIOD_TYPES = { diff --git a/workers/lib/server/handlers/groups.handlers.js b/workers/lib/server/handlers/groups.handlers.js index f530841..7120557 100644 --- a/workers/lib/server/handlers/groups.handlers.js +++ b/workers/lib/server/handlers/groups.handlers.js @@ -4,24 +4,25 @@ const { LOG_KEYS, WORKER_TYPES, WORKER_TAGS, - AGGR_FIELDS + EXPLORER_RACK_AGGR_FIELDS, + DCS_POWER_METER_FIELDS } = require('../../constants') -const { extractKeyEntry } = require('../../metrics.utils') -const { parseContainers } = require('../lib/queryUtils') - -function sumGroupedField (grouped, containers) { - if (!grouped || typeof grouped !== 'object') return 0 - let total = 0 - for (const id of containers) { - total += grouped[id] || 0 - } - return total -} +const { + aggregateRackStats, + buildRackList +} = require('./explorer.handlers') +const { mhsToPhs, mhsToThs, parseRackId } = require('../../metrics.utils') +const { + isCentralDCSEnabled, + getDCSTag, + extractDcsThing +} = require('../../dcs.utils') +const { parseRacks } = require('../lib/queryUtils') async function getGroupStats (ctx, req) { - const containers = parseContainers(req) - if (!containers || !containers.length) { - throw new Error('ERR_MISSING_CONTAINERS') + const requestedRacks = parseRacks(req) + if (!requestedRacks || !requestedRacks.length) { + throw new Error('ERR_MISSING_RACKS') } const tailLogPayload = { @@ -29,64 +30,91 @@ async function getGroupStats (ctx, req) { { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } ], limit: 1, - aggrFields: { - [AGGR_FIELDS.HASHRATE_1M_CONTAINER_GROUP_SUM]: 1, - [AGGR_FIELDS.POWER_W_CONTAINER_GROUP_SUM]: 1, - [AGGR_FIELDS.POWER_MODE_LOW_CNT]: 1, - [AGGR_FIELDS.POWER_MODE_NORMAL_CNT]: 1, - [AGGR_FIELDS.POWER_MODE_HIGH_CNT]: 1, - [AGGR_FIELDS.OFFLINE_CNT]: 1, - [AGGR_FIELDS.ERROR_CNT]: 1, - [AGGR_FIELDS.NOT_MINING_CNT]: 1, - [AGGR_FIELDS.SLEEP_CNT]: 1 + aggrFields: EXPLORER_RACK_AGGR_FIELDS + } + + const dcsEnabled = isCentralDCSEnabled(ctx) + let dcsPayload = null + if (dcsEnabled) { + const dcsTag = getDCSTag(ctx) + dcsPayload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields: { id: 1, code: 1, type: 1, tags: 1, ...DCS_POWER_METER_FIELDS } } } - const results = await ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload) - return composeGroupStats(results, containers) + const [tailLogResults, dcsResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload), + dcsEnabled ? ctx.dataProxy.requestDataMap('listThings', dcsPayload) : Promise.resolve(null) + ]) + + const rackStats = aggregateRackStats(tailLogResults) + const dcsThing = dcsResults ? extractDcsThing(dcsResults) : null + const miningConfig = dcsThing?.last?.snap?.config?.mining || {} + + const allRacks = buildRackList(miningConfig, rackStats) + const realKeyById = mapRackIdToKeys(allRacks, rackStats) + + const requestedSet = new Set(requestedRacks) + const data = allRacks + .filter(rack => requestedSet.has(rack.id)) + .map(rack => formatRackValues(rack, realKeyById.get(rack.id), rackStats)) + + return { + data, + totalCount: data.length + } } -function composeGroupStats (results, containers) { - let hashrateMhs = 0 - let powerW = 0 - let onlineCount = 0 - let minerCount = 0 - - for (const orkResult of results) { - const minerEntry = extractKeyEntry(orkResult, 0) - if (!minerEntry) continue - - hashrateMhs += sumGroupedField(minerEntry[AGGR_FIELDS.HASHRATE_1M_CONTAINER_GROUP_SUM], containers) - powerW += sumGroupedField(minerEntry[AGGR_FIELDS.POWER_W_CONTAINER_GROUP_SUM], containers) - - const low = sumGroupedField(minerEntry[AGGR_FIELDS.POWER_MODE_LOW_CNT], containers) - const normal = sumGroupedField(minerEntry[AGGR_FIELDS.POWER_MODE_NORMAL_CNT], containers) - const high = sumGroupedField(minerEntry[AGGR_FIELDS.POWER_MODE_HIGH_CNT], containers) - const offline = sumGroupedField(minerEntry[AGGR_FIELDS.OFFLINE_CNT], containers) - const error = sumGroupedField(minerEntry[AGGR_FIELDS.ERROR_CNT], containers) - const notMining = sumGroupedField(minerEntry[AGGR_FIELDS.NOT_MINING_CNT], containers) - const sleep = sumGroupedField(minerEntry[AGGR_FIELDS.SLEEP_CNT], containers) - - onlineCount += low + normal + high - minerCount += low + normal + high + offline + error + notMining + sleep +function mapRackIdToKeys (racks, rackStats) { + const allRealKeys = new Set([ + ...Object.keys(rackStats.hashrateByRack), + ...Object.keys(rackStats.powerByRack), + ...Object.keys(rackStats.efficiencyByRack) + ]) + + const sortedByGroup = {} + for (const key of allRealKeys) { + const parsed = parseRackId(key) + if (!parsed) continue + ;(sortedByGroup[parsed.group] ||= []).push(key) + } + for (const list of Object.values(sortedByGroup)) { + list.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) } - const hashrateThs = hashrateMhs / 1000000 + const map = new Map() + const cursor = {} + for (const rack of racks) { + const groupId = rack.group.id + const pos = (cursor[groupId] = (cursor[groupId] ?? -1) + 1) + map.set(rack.id, sortedByGroup[groupId]?.[pos]) + } + return map +} + +function formatRackValues (rack, realKey, rackStats) { + if (!realKey) return rack + + const hashrateMhs = rackStats.hashrateByRack[realKey] || 0 + const powerW = rackStats.powerByRack[realKey] || 0 + const powerKw = Math.round(powerW / 10) / 100 + const hashrateThs = mhsToThs(hashrateMhs) const efficiency = hashrateThs > 0 ? Math.round((powerW / hashrateThs) * 10) / 10 - : 0 + : rackStats.efficiencyByRack[realKey] || 0 return { - efficiency, - hashrateMhs, - powerW, - minerCount, - onlineCount + ...rack, + efficiency: { value: efficiency, unit: 'W/TH/s' }, + hashrate: { value: mhsToPhs(hashrateMhs), unit: 'PH/s' }, + consumption: { value: powerKw, unit: 'kW' } } } module.exports = { getGroupStats, - composeGroupStats, - sumGroupedField + mapRackIdToKeys, + formatRackValues } diff --git a/workers/lib/server/lib/queryUtils.js b/workers/lib/server/lib/queryUtils.js index 37280cd..95f0550 100644 --- a/workers/lib/server/lib/queryUtils.js +++ b/workers/lib/server/lib/queryUtils.js @@ -172,10 +172,10 @@ function paginateResults (items, offset, limit) { } } -function parseContainers (req) { - const raw = req.query.containers +function parseRacks (req) { + const raw = req.query.racks if (!raw) return undefined - return raw.split(',').map(c => c.trim()).filter(Boolean) + return raw.split(',').map(r => r.trim()).filter(Boolean) } module.exports = { @@ -186,5 +186,5 @@ module.exports = { flattenOrkResults, sortItems, paginateResults, - parseContainers + parseRacks } diff --git a/workers/lib/server/routes/groups.routes.js b/workers/lib/server/routes/groups.routes.js index 75d1fe5..3c993b0 100644 --- a/workers/lib/server/routes/groups.routes.js +++ b/workers/lib/server/routes/groups.routes.js @@ -23,7 +23,7 @@ module.exports = (ctx) => { ctx, (req) => [ 'miners/groups/stats', - req.query.containers + req.query.racks ], ENDPOINTS.MINERS_GROUPS_STATS, getGroupStats, diff --git a/workers/lib/server/schemas/groups.schemas.js b/workers/lib/server/schemas/groups.schemas.js index 62054e2..fb782ba 100644 --- a/workers/lib/server/schemas/groups.schemas.js +++ b/workers/lib/server/schemas/groups.schemas.js @@ -5,10 +5,10 @@ const schemas = { groupsStats: { type: 'object', properties: { - containers: { type: 'string' }, + racks: { type: 'string' }, overwriteCache: { type: 'boolean' } }, - required: ['containers'] + required: ['racks'] } } } From 52de78bc0799456131b30ead74507a6868c70b45 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:33:13 +0530 Subject: [PATCH 39/63] Add groupBy for metrics consumption api (#70) --- tests/unit/handlers/metrics.handlers.test.js | 107 +++++++++++++++++- workers/lib/constants.js | 14 ++- .../lib/server/handlers/metrics.handlers.js | 85 ++++++++++++++ workers/lib/server/routes/metrics.routes.js | 3 +- workers/lib/server/schemas/metrics.schemas.js | 1 + 5 files changed, 204 insertions(+), 6 deletions(-) diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index e675a58..315ac85 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -8,6 +8,7 @@ const { getConsumption, processConsumptionData, calculateConsumptionSummary, + calculateGroupedConsumptionSummary, getEfficiency, processEfficiencyData, calculateEfficiencySummary, @@ -75,7 +76,7 @@ test('getHashrate - grouped by miner uses type group aggregation', async (t) => capturedPayload = payload return [{ ts: 1700006400000, - hashrate_mhs_5m_type_group_sum_aggr: { 'S19-Pro': 100000, 'S21': 23456 } + hashrate_mhs_5m_type_group_sum_aggr: { 'S19-Pro': 100000, S21: 23456 } }] } } @@ -88,12 +89,12 @@ test('getHashrate - grouped by miner uses type group aggregation', async (t) => t.is(capturedPayload.fields.hashrate_mhs_5m_type_group_sum, 1, 'should request type-group source field') t.is(capturedPayload.aggrFields.hashrate_mhs_5m_type_group_sum_aggr, 1, 'should request type-group aggregate field') t.is(result.log.length, 1, 'should map one grouped row') - t.alike(result.log[0].hashrateMhs, { 'S19-Pro': 100000, 'S21': 23456 }, 'should map grouped hashrate value') + t.alike(result.log[0].hashrateMhs, { 'S19-Pro': 100000, S21: 23456 }, 'should map grouped hashrate value') t.is(result.summary.totalHashrateMhs, 123456, 'should have site-wide total') t.is(result.summary.avgHashrateMhs, 123456, 'should have site-wide average') t.ok(result.summary.groupedBy, 'should have per-miner breakdown') t.is(result.summary.groupedBy['S19-Pro'].totalHashrateMhs, 100000, 'should have per-miner total') - t.is(result.summary.groupedBy['S21'].totalHashrateMhs, 23456, 'should have per-miner total') + t.is(result.summary.groupedBy.S21.totalHashrateMhs, 23456, 'should have per-miner total') t.pass() }) @@ -404,6 +405,106 @@ test('calculateConsumptionSummary - handles empty log', (t) => { t.pass() }) +test('getConsumption - grouped by miner uses type group aggregation', async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ + ts: 1700006400000, + power_w_type_group_sum_aggr: { 'S19-Pro': 3000000, S21: 2000000 } + }] + } + } + }) + + const result = await getConsumption(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, groupBy: 'miner' } + }) + + t.is(capturedPayload.fields.power_w_type_group_sum, 1, 'should request type-group source field') + t.is(capturedPayload.aggrFields.power_w_type_group_sum_aggr, 1, 'should request type-group aggregate field') + t.is(result.log.length, 1, 'should map one grouped row') + t.alike(result.log[0].powerW, { 'S19-Pro': 3000000, S21: 2000000 }, 'should map grouped power value') + t.ok(result.log[0].consumptionMWh, 'should have consumptionMWh object') + t.is(result.summary.totalConsumptionMWh, (5000000 * 24) / 1000000, 'should have site-wide total consumption') + t.ok(result.summary.groupedBy, 'should have per-miner breakdown') + t.is(result.summary.groupedBy['S19-Pro'].totalConsumptionMWh, (3000000 * 24) / 1000000, 'should have per-miner total') + t.is(result.summary.groupedBy.S21.totalConsumptionMWh, (2000000 * 24) / 1000000, 'should have per-miner total') + t.pass() +}) + +test('getConsumption - grouped by container uses container group aggregation', async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ + ts: 1700006400000, + power_w_container_group_sum_aggr: { 'container-A': 4000000, 'container-B': 1000000 } + }] + } + } + }) + + const result = await getConsumption(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, groupBy: 'container' } + }) + + t.is(capturedPayload.fields.power_w_container_group_sum, 1, 'should request container-group source field') + t.is(capturedPayload.aggrFields.power_w_container_group_sum_aggr, 1, 'should request container-group aggregate field') + t.is(result.log.length, 1, 'should map grouped row') + t.alike(result.log[0].powerW, { 'container-A': 4000000, 'container-B': 1000000 }, 'should map container grouped power value') + t.is(result.summary.totalConsumptionMWh, (5000000 * 24) / 1000000, 'should have site-wide total consumption') + t.ok(result.summary.groupedBy, 'should have per-container breakdown') + t.is(result.summary.groupedBy['container-A'].totalConsumptionMWh, (4000000 * 24) / 1000000, 'should have per-container total') + t.is(result.summary.groupedBy['container-B'].totalConsumptionMWh, (1000000 * 24) / 1000000, 'should have per-container total') + t.pass() +}) + +test('getConsumption - grouped mode handles empty results', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => [] } + }) + + const result = await getConsumption(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, groupBy: 'miner' } + }) + + t.is(result.log.length, 0, 'grouped log should be empty when no data is returned') + t.is(result.summary.avgPowerW, null, 'grouped empty summary should have null avg') + t.is(result.summary.totalConsumptionMWh, 0, 'grouped empty summary should have zero total') + t.pass() +}) + +test('calculateGroupedConsumptionSummary - calculates per-group and site-wide stats', (t) => { + const log = [ + { ts: 1700006400000, powerW: { 'S19-Pro': 3000000, S21: 2000000 } }, + { ts: 1700092800000, powerW: { 'S19-Pro': 3500000, S21: 1500000 } } + ] + + const summary = calculateGroupedConsumptionSummary(log, 'miner') + t.is(summary.totalConsumptionMWh, (10000000 * 24) / 1000000, 'should have site-wide total') + t.is(summary.avgPowerW, 5000000, 'should have site-wide average') + t.ok(summary.groupedBy, 'should have per-group breakdown') + t.is(summary.groupedBy['S19-Pro'].avgPowerW, 3250000, 'should average per-group power') + t.is(summary.groupedBy['S19-Pro'].totalConsumptionMWh, (6500000 * 24) / 1000000, 'should sum per-group consumption') + t.is(summary.groupedBy.S21.avgPowerW, 1750000, 'should average per-group power') + t.pass() +}) + +test('calculateGroupedConsumptionSummary - handles empty log', (t) => { + const summary = calculateGroupedConsumptionSummary([], 'miner') + t.is(summary.avgPowerW, null, 'should be null') + t.is(summary.totalConsumptionMWh, 0, 'should be zero') + t.pass() +}) + // ==================== Efficiency Tests ==================== test('getEfficiency - happy path', async (t) => { diff --git a/workers/lib/constants.js b/workers/lib/constants.js index e929af9..f972232 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -474,7 +474,9 @@ const DCS_EFFICIENCY_FIELDS = { const LOG_FIELDS = { HASHRATE_SUM_TYPE_GROUP: 'hashrate_mhs_5m_type_group_sum', - HASHRATE_SUM_CONTAINER_GROUP: 'hashrate_mhs_5m_container_group_sum' + HASHRATE_SUM_CONTAINER_GROUP: 'hashrate_mhs_5m_container_group_sum', + POWER_W_TYPE_GROUP_SUM: 'power_w_type_group_sum', + POWER_W_CONTAINER_GROUP_SUM: 'power_w_container_group_sum' } const AGGR_FIELDS = { @@ -494,7 +496,15 @@ const AGGR_FIELDS = { OFFLINE_CNT: 'offline_cnt', SLEEP_CNT: 'power_mode_sleep_cnt', MAINTENANCE_CNT: 'maintenance_type_cnt', - CONTAINER_SPECIFIC_STATS: 'container_specific_stats_group_aggr' + CONTAINER_SPECIFIC_STATS: 'container_specific_stats_group_aggr', + HASHRATE_1M_CONTAINER_GROUP_SUM: 'hashrate_mhs_1m_container_group_sum_aggr', + POWER_W_CONTAINER_GROUP_SUM: 'power_w_container_group_sum_aggr', + POWER_W_TYPE_GROUP_SUM: 'power_w_type_group_sum_aggr', + POWER_MODE_LOW_CNT: 'power_mode_low_cnt', + POWER_MODE_NORMAL_CNT: 'power_mode_normal_cnt', + POWER_MODE_HIGH_CNT: 'power_mode_high_cnt', + ERROR_CNT: 'error_cnt', + NOT_MINING_CNT: 'not_mining_cnt' } const PERIOD_TYPES = { diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index d19ae25..455c5aa 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -156,6 +156,8 @@ function calculateGroupedHashrateSummary (log, groupBy) { async function getConsumption (ctx, req) { const { start, end } = validateStartEnd(req) + if (req.query.groupBy) return getGroupedConsumption(ctx, req) + const startDate = new Date(start).toISOString() const endDate = new Date(end).toISOString() @@ -213,6 +215,88 @@ function calculateConsumptionSummary (log) { } } +async function getGroupedConsumption (ctx, req) { + const { groupBy, start, end } = req.query + + const isMinerGroup = groupBy === WORKER_TYPES.MINER + + const field = isMinerGroup + ? LOG_FIELDS.POWER_W_TYPE_GROUP_SUM + : LOG_FIELDS.POWER_W_CONTAINER_GROUP_SUM + + const aggrField = isMinerGroup + ? AGGR_FIELDS.POWER_W_TYPE_GROUP_SUM + : AGGR_FIELDS.POWER_W_CONTAINER_GROUP_SUM + + const res = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, { + type: WORKER_TYPES.MINER, + tag: WORKER_TAGS.MINER, + key: LOG_KEYS.STAT_1D, + start, + end, + fields: { [field]: 1 }, + aggrFields: { [aggrField]: 1 } + }) + + const log = res[0].reduce((aggr, val) => { + const powerW = val[aggrField] + aggr.push({ + ts: val.ts, + powerW, + consumptionMWh: typeof powerW === 'object' && powerW !== null + ? Object.fromEntries( + Object.entries(powerW).map(([k, v]) => [k, (Number(v) || 0) * 24 / 1000000]) + ) + : null + }) + return aggr + }, []) + + const summary = calculateGroupedConsumptionSummary(log, groupBy) + + return { log, summary } +} + +function calculateGroupedConsumptionSummary (log, groupBy) { + if (!log.length) { + return { + avgPowerW: null, + totalConsumptionMWh: 0 + } + } + + const groupTotals = {} + const groupCounts = {} + + for (const entry of log) { + const powerW = entry.powerW + if (typeof powerW === 'object' && powerW !== null) { + for (const [name, val] of Object.entries(powerW)) { + const v = Number(val) || 0 + groupTotals[name] = (groupTotals[name] || 0) + v + groupCounts[name] = (groupCounts[name] || 0) + 1 + } + } + } + + const byGroup = {} + let siteTotal = 0 + for (const [name, total] of Object.entries(groupTotals)) { + const avgPowerW = safeDiv(total, groupCounts[name]) + byGroup[name] = { + avgPowerW, + totalConsumptionMWh: (total * 24) / 1000000 + } + siteTotal += total + } + + return { + avgPowerW: safeDiv(siteTotal, log.length), + totalConsumptionMWh: (siteTotal * 24) / 1000000, + groupedBy: byGroup + } +} + async function getEfficiency (ctx, req) { const { start, end } = validateStartEnd(req) @@ -773,6 +857,7 @@ module.exports = { getConsumption, processConsumptionData, calculateConsumptionSummary, + calculateGroupedConsumptionSummary, getEfficiency, processEfficiencyData, calculateEfficiencySummary, diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js index 6b16758..9de5fea 100644 --- a/workers/lib/server/routes/metrics.routes.js +++ b/workers/lib/server/routes/metrics.routes.js @@ -50,7 +50,8 @@ module.exports = (ctx) => { (req) => [ 'metrics/consumption', req.query.start, - req.query.end + req.query.end, + req.query.groupBy ], ENDPOINTS.METRICS_CONSUMPTION, getConsumption diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index c6c6530..aec1061 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -17,6 +17,7 @@ const schemas = { properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 0 }, + groupBy: { type: 'string', enum: ['miner', 'container'] }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] From fe78440b4bdf1c4d65a10d5584145c7ffee8de95 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:50:36 +0530 Subject: [PATCH 40/63] Update the cooling system handlers to fix flow readings (#71) --- .../server/handlers/coolingSystem.handlers.js | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/workers/lib/server/handlers/coolingSystem.handlers.js b/workers/lib/server/handlers/coolingSystem.handlers.js index 755a53b..1ca0679 100644 --- a/workers/lib/server/handlers/coolingSystem.handlers.js +++ b/workers/lib/server/handlers/coolingSystem.handlers.js @@ -283,7 +283,9 @@ function buildMinersCircuit2View (equipment, config) { level: ct.level, level_sensor: towerLevelSensor, vibration: ct.vibration, - vibration_sensor: towerVibrationSensorId + vibration_sensor: towerVibrationSensorId, + capacity_flow: towerConfig.defaults?.tower_capacity || null, + capacity_gcal: towerConfig.defaults?.tower_capacity_gcal || null })) // Makeup water system @@ -624,22 +626,23 @@ function buildHvacCircuit2View (equipment, config) { const supplyFlow = getSensorReading(flows, supplyReturnConfig.supply_flow_sensor) const returnFlow = getSensorReading(flows, supplyReturnConfig.return_flow_sensor) + const supplyTempSensor = getSensorWithTag(temperatures, supplyReturnConfig.supply_temp_sensor) + const supplyFlowSensor = getSensorWithTag(flows, supplyReturnConfig.supply_flow_sensor) + const returnTempSensor = getSensorWithTag(temperatures, supplyReturnConfig.return_temp_sensor) + const returnFlowSensor = getSensorWithTag(flows, supplyReturnConfig.return_flow_sensor) + const supplyReturn = { supply: { + name: 'Supply To Tower', temperature: supplyTemp, flow: supplyFlow, - sensors: [ - getSensorWithTag(temperatures, supplyReturnConfig.supply_temp_sensor), - getSensorWithTag(flows, supplyReturnConfig.supply_flow_sensor) - ].filter(Boolean) + sensors: [supplyTempSensor, supplyFlowSensor].filter(Boolean) }, return: { + name: 'Return From Tower', temperature: returnTemp, flow: returnFlow, - sensors: [ - getSensorWithTag(temperatures, supplyReturnConfig.return_temp_sensor), - getSensorWithTag(flows, supplyReturnConfig.return_flow_sensor) - ].filter(Boolean) + sensors: [returnTempSensor, returnFlowSensor].filter(Boolean) } } @@ -655,25 +658,24 @@ function buildHvacCircuit2View (equipment, config) { const towerConfigRef = condenserConfig.tower || {} const towerLevelSensorId = towerConfigRef.level_sensor const towerLevel = getSensorReading(levels, towerLevelSensorId) + const towerVibrationSensorId = towerConfigRef.vibration_sensor + const towerFanId = towerConfigRef.fan - const towerData = (coolingTowers || []).map(ct => { - const isConfiguredTower = ct.equipment === towerConfigRef.equipment - return { - id: ct.equipment, - name: ct.equipment, - is_running: ct.is_running, - fan_status: ct.fan_status, - fan_speed: ct.fan_speed, - fan_power: ct.fan_power, - fan_id: isConfiguredTower ? towerConfigRef.fan : null, - level: ct.level, - level_sensor: isConfiguredTower ? towerConfigRef.level_sensor : null, - vibration: ct.vibration, - vibration_sensor: isConfiguredTower ? towerConfigRef.vibration_sensor : null, - capacity_mcal: condenserConfig.defaults?.tower_capacity_mcal || null, - capacity_flow: condenserConfig.defaults?.tower_flow || null - } - }) + const towerData = (coolingTowers || []).map(ct => ({ + id: ct.equipment, + name: ct.equipment, + is_running: ct.is_running, + fan_status: ct.fan_status, + fan_speed: ct.fan_speed, + fan_power: ct.fan_power, + fan_id: towerFanId || null, + level: ct.level, + level_sensor: towerLevelSensorId || null, + vibration: ct.vibration, + vibration_sensor: towerVibrationSensorId || null, + capacity_mcal: condenserConfig.defaults?.tower_capacity_mcal || null, + capacity_flow: condenserConfig.defaults?.tower_flow || null + })) const condenserPumps = filterPumpsByCircuit(pumps, 'HVAC_CONDENSER').map(formatPump) From add4db780f5c7c37cc2f7d89e0dce39f0e10a65e Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Fri, 1 May 2026 13:19:17 +0300 Subject: [PATCH 41/63] fix(finance): correct energy-balance period aggregation (#72) The shared aggregateByPeriod summed every numeric field, which is correct for totals but produced nonsense for rates/means (curtailment rate, btcPrice, sitePowerMW, etc.) on weekly/monthly buckets. - Add an optional `meanKeys` arg to aggregateByPeriod that averages listed fields instead of summing. Default behavior is unchanged for the 6 existing callers. - getEnergyBalance now passes meanKeys for sitePowerMW, btcPrice and the rate fields, then post-processes to recompute energyRevenue*_MW against the (correctly meaned) sitePowerMW denominator. - Add sitePowerMW + energyRevenueBTC_MW/energyRevenueUSD_MW to log entries; add avgPowerConsumption + avgEnergyCostPerMWh + avgOperationalCostPerMWh to summary. - Schema: add 'weekly' to the period enum. --- tests/unit/handlers/finance.handlers.test.js | 87 ++++++++++++++++++- tests/unit/lib/period.utils.test.js | 43 +++++++++ workers/lib/period.utils.js | 13 ++- .../lib/server/handlers/finance.handlers.js | 27 +++++- workers/lib/server/schemas/finance.schemas.js | 2 +- 5 files changed, 167 insertions(+), 5 deletions(-) diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 134ccd9..864b5fc 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -234,8 +234,8 @@ test('processCostsData - handles non-array input', (t) => { test('calculateSummary - calculates from log entries', (t) => { const log = [ - { revenueBTC: 0.5, revenueUSD: 20000, totalCostUSD: 5000, profitUSD: 15000, consumptionMWh: 100 }, - { revenueBTC: 0.3, revenueUSD: 12000, totalCostUSD: 3000, profitUSD: 9000, consumptionMWh: 60 } + { revenueBTC: 0.5, revenueUSD: 20000, energyCostUSD: 4000, totalCostUSD: 5000, profitUSD: 15000, consumptionMWh: 100, sitePowerMW: 4 }, + { revenueBTC: 0.3, revenueUSD: 12000, energyCostUSD: 2400, totalCostUSD: 3000, profitUSD: 9000, consumptionMWh: 60, sitePowerMW: 2 } ] const summary = calculateSummary(log) @@ -244,7 +244,10 @@ test('calculateSummary - calculates from log entries', (t) => { t.is(summary.totalCostUSD, 8000, 'should sum costs') t.is(summary.totalProfitUSD, 24000, 'should sum profit') t.is(summary.totalConsumptionMWh, 160, 'should sum consumption') + t.is(summary.avgPowerConsumption, 3, 'avgPowerConsumption averages sitePowerMW (4+2)/2') t.ok(summary.avgCostPerMWh !== null, 'should calculate avg cost per MWh') + t.ok(summary.avgEnergyCostPerMWh !== null, 'should calculate avg energy cost per MWh') + t.ok(summary.avgOperationalCostPerMWh !== null, 'should calculate avg operational cost per MWh') t.ok(summary.avgRevenuePerMWh !== null, 'should calculate avg revenue per MWh') t.pass() }) @@ -257,6 +260,86 @@ test('calculateSummary - handles empty log', (t) => { t.pass() }) +function makeMockCtx (days) { + return withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (_key, method, payload) => { + if (method === 'tailLogCustomRangeAggr') { + return [{ + type: 'powermeter', + data: days.map(d => ({ ts: d.ts, val: { site_power_w: d.powerW } })), + error: null + }] + } + if (method === 'getWrkExtData') { + if (payload.query && payload.query.key === 'transactions') { + return days.map(d => ({ ts: d.ts, transactions: [{ ts: d.ts, changed_balance: d.btc }] })) + } + if (payload.query && payload.query.key === 'HISTORICAL_PRICES') { + return days.map(d => ({ ts: d.ts, priceUSD: d.price })) + } + if (payload.query && payload.query.key === 'current_price') { + return [{ currentPrice: days[0].price }] + } + if (payload.query && payload.query.key === 'stats-history') { + return [] + } + } + if (method === 'getGlobalConfig') { + return { nominalPowerAvailability_MW: 10 } + } + return {} + } + }, + globalDataLib: { getGlobalData: async () => [] } + }) +} + +test('getEnergyBalance daily - per-day entries carry sitePowerMW and per-MW revenue', async (t) => { + const day1 = Date.UTC(2024, 0, 15) + const day2 = Date.UTC(2024, 0, 16) + const days = [ + { ts: day1, powerW: 5_000_000, btc: 0.5, price: 40000 }, + { ts: day2, powerW: 3_000_000, btc: 0.3, price: 40000 } + ] + + const result = await getEnergyBalance(makeMockCtx(days), { + query: { start: day1 - 1000, end: day2 + 86400000, period: 'daily' } + }, {}) + + t.is(result.log.length, 2, 'one entry per day') + for (const e of result.log) { + t.ok(e.sitePowerMW > 0, 'sitePowerMW present') + t.ok('energyRevenueBTC_MW' in e && 'energyRevenueUSD_MW' in e, 'per-MW revenue fields present') + } + t.is(result.log[0].sitePowerMW, 5, 'first day sitePowerMW = 5') + t.is(result.log[1].sitePowerMW, 3, 'second day sitePowerMW = 3') +}) + +test('getEnergyBalance monthly - rates use MEAN, totals use SUM, per-MW is RECOMPUTED', async (t) => { + const day1 = Date.UTC(2024, 0, 15) + const day2 = Date.UTC(2024, 0, 16) + const day3 = Date.UTC(2024, 0, 17) + const days = [ + { ts: day1, powerW: 5_000_000, btc: 0.5, price: 40000 }, + { ts: day2, powerW: 3_000_000, btc: 0.3, price: 40000 }, + { ts: day3, powerW: 4_000_000, btc: 0.4, price: 40000 } + ] + + const result = await getEnergyBalance(makeMockCtx(days), { + query: { start: day1 - 1000, end: day3 + 86400000, period: 'monthly' } + }, {}) + + t.is(result.log.length, 1, 'three days collapse to one monthly bucket') + const m = result.log[0] + t.ok(Math.abs(m.revenueBTC - 1.2) < 1e-9, 'revenueBTC summed (~1.2)') + t.ok(Math.abs(m.revenueUSD - 48000) < 1e-6, 'revenueUSD summed (~48000)') + t.is(m.sitePowerMW, 4, 'sitePowerMW averaged: (5+3+4)/3') + t.ok(Math.abs(m.energyRevenueUSD_MW - 12000) < 1e-6, 'per-MW recomputed from sum / mean, not summed daily values') + t.ok(Math.abs(m.energyRevenueBTC_MW - 0.3) < 1e-9, 'BTC per-MW recomputed') +}) + // ==================== EBITDA Tests ==================== test('getEbitda - happy path', async (t) => { diff --git a/tests/unit/lib/period.utils.test.js b/tests/unit/lib/period.utils.test.js index 46ebaf4..c13f7e0 100644 --- a/tests/unit/lib/period.utils.test.js +++ b/tests/unit/lib/period.utils.test.js @@ -76,6 +76,49 @@ test('aggregateByPeriod - handles invalid timestamps', (t) => { t.pass() }) +test('aggregateByPeriod - meanKeys option averages instead of summing', (t) => { + const ts = Date.UTC(2024, 0, 15) + const log = [ + { ts, total: 10, rate: 0.1 }, + { ts: ts + 86400000, total: 20, rate: 0.3 } + ] + const result = aggregateByPeriod(log, 'monthly', [], { meanKeys: ['rate'] }) + t.is(result.length, 1, 'one monthly bucket') + t.is(result[0].total, 30, 'sum keys still summed') + t.is(result[0].rate, 0.2, 'mean key averaged: (0.1+0.3)/2') +}) + +test('aggregateByPeriod - meanKeys skip null/undefined values when averaging', (t) => { + const ts = Date.UTC(2024, 0, 15) + const log = [ + { ts, rate: 0.1 }, + { ts: ts + 86400000, rate: null }, + { ts: ts + 2 * 86400000, rate: 0.3 } + ] + const result = aggregateByPeriod(log, 'monthly', [], { meanKeys: ['rate'] }) + t.is(result[0].rate, 0.2, 'null skipped: (0.1+0.3)/2') +}) + +test('aggregateByPeriod - meanKeys returns null when no entries have the value', (t) => { + const ts = Date.UTC(2024, 0, 15) + const log = [ + { ts, rate: null }, + { ts: ts + 86400000, rate: undefined } + ] + const result = aggregateByPeriod(log, 'monthly', [], { meanKeys: ['rate'] }) + t.is(result[0].rate, null, 'all-null group yields null') +}) + +test('aggregateByPeriod - omitting options preserves legacy sum-everything behaviour', (t) => { + const ts = Date.UTC(2024, 0, 15) + const log = [ + { ts, rate: 0.1 }, + { ts: ts + 86400000, rate: 0.3 } + ] + const result = aggregateByPeriod(log, 'monthly') + t.is(result[0].rate, 0.4, 'rate is summed when meanKeys not provided') +}) + test('getPeriodKey - daily returns start of day', (t) => { const ts = 1700050000000 const result = getPeriodKey(ts, 'daily') diff --git a/workers/lib/period.utils.js b/workers/lib/period.utils.js index 5f206a4..583b610 100644 --- a/workers/lib/period.utils.js +++ b/workers/lib/period.utils.js @@ -26,12 +26,13 @@ const PERIOD_CALCULATORS = { } } -const aggregateByPeriod = (log, period, nonMetricKeys = []) => { +const aggregateByPeriod = (log, period, nonMetricKeys = [], options = {}) => { if (period === PERIOD_TYPES.DAILY) { return log } const allNonMetricKeys = new Set([...NON_METRIC_KEYS, ...nonMetricKeys]) + const meanKeys = new Set(options.meanKeys || []) const grouped = log.reduce((acc, entry) => { let date @@ -68,12 +69,18 @@ const aggregateByPeriod = (log, period, nonMetricKeys = []) => { }, {}) const aggregatedResults = Object.entries(grouped).map(([groupKey, entries]) => { + const meanCounts = {} const aggregated = entries.reduce((acc, entry) => { Object.entries(entry).forEach(([key, val]) => { if (allNonMetricKeys.has(key)) { if (!acc[key] || acc[key] === null || acc[key] === undefined) { acc[key] = val } + } else if (meanKeys.has(key)) { + if (val !== null && val !== undefined && !isNaN(Number(val))) { + acc[key] = (acc[key] || 0) + Number(val) + meanCounts[key] = (meanCounts[key] || 0) + 1 + } } else { const numVal = Number(val) || 0 acc[key] = (acc[key] || 0) + numVal @@ -82,6 +89,10 @@ const aggregateByPeriod = (log, period, nonMetricKeys = []) => { return acc }, {}) + for (const key of meanKeys) { + aggregated[key] = meanCounts[key] ? aggregated[key] / meanCounts[key] : null + } + try { if (period === PERIOD_TYPES.MONTHLY) { const [year, month] = groupKey.split('-').map(Number) diff --git a/workers/lib/server/handlers/finance.handlers.js b/workers/lib/server/handlers/finance.handlers.js index 5767a91..42afc16 100644 --- a/workers/lib/server/handlers/finance.handlers.js +++ b/workers/lib/server/handlers/finance.handlers.js @@ -102,6 +102,7 @@ async function getEnergyBalance (ctx, req) { const powerW = consumption.powerW || 0 const powerMWh = (powerW * 24) / 1000000 + const sitePowerMW = powerW / 1000000 const revenueBTC = transactions.revenueBTC || 0 const revenueUSD = revenueBTC * btcPrice @@ -133,6 +134,7 @@ async function getEnergyBalance (ctx, req) { log.push({ ts, powerW, + sitePowerMW, consumptionMWh, revenueBTC, revenueUSD, @@ -149,7 +151,16 @@ async function getEnergyBalance (ctx, req) { }) } - const aggregated = aggregateByPeriod(log, period) + const aggregated = aggregateByPeriod(log, period, [], { + meanKeys: ['sitePowerMW', 'btcPrice', 'curtailmentRate', 'operationalIssuesRate', 'powerUtilization'] + }) + + for (const entry of aggregated) { + entry.energyRevenueBTC_MW = entry.sitePowerMW > 0 ? entry.revenueBTC / entry.sitePowerMW : 0 + entry.energyRevenueUSD_MW = entry.sitePowerMW > 0 ? entry.revenueUSD / entry.sitePowerMW : 0 + } + aggregated.sort((a, b) => Number(a.ts) - Number(b.ts)) + const summary = calculateSummary(aggregated) return { log: aggregated, summary } @@ -250,7 +261,10 @@ function calculateSummary (log) { totalCostUSD: 0, totalProfitUSD: 0, avgCostPerMWh: null, + avgEnergyCostPerMWh: null, + avgOperationalCostPerMWh: null, avgRevenuePerMWh: null, + avgPowerConsumption: 0, totalConsumptionMWh: 0, avgCurtailmentRate: null, avgOperationalIssuesRate: null, @@ -261,9 +275,14 @@ function calculateSummary (log) { const totals = log.reduce((acc, entry) => { acc.revenueBTC += entry.revenueBTC || 0 acc.revenueUSD += entry.revenueUSD || 0 + acc.energyCostUSD += entry.energyCostUSD || 0 acc.costUSD += entry.totalCostUSD || 0 acc.profitUSD += entry.profitUSD || 0 acc.consumptionMWh += entry.consumptionMWh || 0 + if (entry.sitePowerMW !== null && entry.sitePowerMW !== undefined) { + acc.sitePowerMWSum += entry.sitePowerMW + acc.sitePowerMWCount++ + } if (entry.curtailmentRate !== null && entry.curtailmentRate !== undefined) { acc.curtailmentRateSum += entry.curtailmentRate acc.curtailmentRateCount++ @@ -280,9 +299,12 @@ function calculateSummary (log) { }, { revenueBTC: 0, revenueUSD: 0, + energyCostUSD: 0, costUSD: 0, profitUSD: 0, consumptionMWh: 0, + sitePowerMWSum: 0, + sitePowerMWCount: 0, curtailmentRateSum: 0, curtailmentRateCount: 0, operationalIssuesRateSum: 0, @@ -297,7 +319,10 @@ function calculateSummary (log) { totalCostUSD: totals.costUSD, totalProfitUSD: totals.profitUSD, avgCostPerMWh: safeDiv(totals.costUSD, totals.consumptionMWh), + avgEnergyCostPerMWh: safeDiv(totals.energyCostUSD, totals.consumptionMWh), + avgOperationalCostPerMWh: safeDiv(totals.costUSD - totals.energyCostUSD, totals.consumptionMWh), avgRevenuePerMWh: safeDiv(totals.revenueUSD, totals.consumptionMWh), + avgPowerConsumption: safeDiv(totals.sitePowerMWSum, totals.sitePowerMWCount), totalConsumptionMWh: totals.consumptionMWh, avgCurtailmentRate: safeDiv(totals.curtailmentRateSum, totals.curtailmentRateCount), avgOperationalIssuesRate: safeDiv(totals.operationalIssuesRateSum, totals.operationalIssuesRateCount), diff --git a/workers/lib/server/schemas/finance.schemas.js b/workers/lib/server/schemas/finance.schemas.js index 5bf2666..4bc03a7 100644 --- a/workers/lib/server/schemas/finance.schemas.js +++ b/workers/lib/server/schemas/finance.schemas.js @@ -7,7 +7,7 @@ const schemas = { properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 0 }, - period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] }, + period: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'yearly'] }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] From aee490dcf14c227486d52534f736d7f64a2de0ed Mon Sep 17 00:00:00 2001 From: tekwani Date: Sat, 2 May 2026 10:51:51 +0530 Subject: [PATCH 42/63] chore: facs version update (#73) --- package-lock.json | 2085 ++++++++++-------- package.json | 31 +- tests/integration/api.test.js | 5 +- tests/unit/routes/ws.routes.test.js | 29 +- tests/unit/services.poolManager.test.js | 371 ---- worker.js | 3 +- workers/http.node.wrk.js | 26 +- workers/lib/globalData.js | 4 +- workers/lib/server/handlers/auth.handlers.js | 2 +- workers/lib/server/routes/ws.routes.js | 3 +- 10 files changed, 1274 insertions(+), 1285 deletions(-) delete mode 100644 tests/unit/services.poolManager.test.js diff --git a/package-lock.json b/package-lock.json index 23d75cf..8091e12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,21 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@fastify/websocket": "8.3.1", + "@bitfinex/bfx-facs-db-sqlite": "git+https://github.com/bitfinexcom/bfx-facs-db-sqlite.git", + "@bitfinex/bfx-facs-http": "git+https://github.com/bitfinexcom/bfx-facs-http.git", + "@bitfinex/bfx-facs-interval": "git+https://github.com/bitfinexcom/bfx-facs-interval.git", + "@bitfinex/bfx-facs-lru": "git+https://github.com/bitfinexcom/bfx-facs-lru.git", + "@bitfinex/bfx-svc-boot-js": "1.2.0", + "@bitfinex/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", + "@fastify/websocket": "11.2.0", + "@tetherto/hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git#v1.0.0", + "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#v1.0.0", + "@tetherto/svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git#v1.0.0", + "@tetherto/svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git#v1.0.0", + "@tetherto/tether-wrk-base": "git+https://github.com/tetherto/tether-wrk-base.git#v1.0.0", "async": "3.2.6", - "bfx-facs-db-sqlite": "git+https://github.com/bitfinexcom/bfx-facs-db-sqlite.git", - "bfx-facs-http": "git+https://github.com/bitfinexcom/bfx-facs-http.git", - "bfx-facs-interval": "git+https://github.com/bitfinexcom/bfx-facs-interval.git", - "bfx-facs-lru": "git+https://github.com/bitfinexcom/bfx-facs-lru.git", "debug": "4.4.1", - "hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git", - "lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", - "mingo": "6.4.6", - "svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git", - "svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git", - "svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git#pull/5/head", - "tether-wrk-base": "git+https://github.com/tetherto/tether-wrk-base.git" + "mingo": "6.4.6" }, "devDependencies": { "brittle": "3.18.0", @@ -32,10 +33,57 @@ } }, "node_modules/@bitfinex/bfx-facs-base": { + "version": "1.1.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-base.git#6f9aa34a68d40f1b416128a81c7384149f969877", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.1", + "lodash": "^4.18.1" + }, + "engines": { + "node": ">=16.0" + } + }, + "node_modules/@bitfinex/bfx-facs-db-sqlite": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-db-sqlite.git#9fde6a35b1367fa4717972623446e4c4c304c453", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", + "async": "^3.2.4", + "lodash": "^4.17.15", + "sqlite3": "5.1.7" + } + }, + "node_modules/@bitfinex/bfx-facs-http": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-base.git#c42f4dfeab9c759a9c7ec7177ab7af6b53279e94", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-http.git#46cd482878de7ab227f2b1c93bf070c68ca45e38", "license": "Apache-2.0", "dependencies": { + "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", + "async": "^2.6.3", + "lodash": "^4.17.21", + "node-fetch": "2.6.7" + }, + "engines": { + "node": ">=16.0" + } + }, + "node_modules/@bitfinex/bfx-facs-http/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/@bitfinex/bfx-facs-interval": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-interval.git#c60da6077e7897dfdc93989dcec81ef96feb0b5e", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", "async": "^3.2.1", "lodash": "^4.17.21" }, @@ -43,18 +91,47 @@ "node": ">=16.0" } }, + "node_modules/@bitfinex/bfx-facs-lru": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-lru.git#e9a6b2e65894e8fafe3e8dc43b3a942e7b33d472", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", + "async": "^3.2.1", + "lru": "3.1.0" + }, + "engines": { + "node": ">=16.0" + } + }, + "node_modules/@bitfinex/bfx-svc-boot-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@bitfinex/bfx-svc-boot-js/-/bfx-svc-boot-js-1.2.0.tgz", + "integrity": "sha512-9Mbg1pkJC4pU3/+tiVy+I3SJ0o4AQBA2Z66YMYY4w7SGhB+JogfBYiB2B7PQsNtIGh3UMCaSJBFjrVbhWgyIrg==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.18.1", + "yargs": "^17.2.1" + } + }, "node_modules/@bitfinex/bfx-wrk-base": { - "version": "1.0.1", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-wrk-base.git#dea1cd61dc276f4f78ef020ee9c5a6f131900363", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@bitfinex/bfx-wrk-base/-/bfx-wrk-base-1.1.0.tgz", + "integrity": "sha512-o5YVZhbbxSFBTCJ3v792BlhGkGZV3cGUlt73lTVUVy4PLkZDS5dLOGgCa+sYvEneZliOYo7r1xYer+JE+yzLRQ==", "license": "Apache-2.0", "dependencies": { "async": "^3.2.1", - "lodash": "^4.17.21" + "lodash": "^4.18.1" }, "engines": { "node": ">=16.0" } }, + "node_modules/@bitfinex/lib-js-util-base": { + "version": "2.0.2", + "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#e915c19224d8e334b3260ae3e979a50865722446", + "license": "MIT" + }, "node_modules/@bitfinexcom/lib-js-util-base": { "name": "@bitfinex/lib-js-util-base", "version": "2.0.2", @@ -114,6 +191,54 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -138,45 +263,66 @@ } }, "node_modules/@fastify/accept-negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", - "integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==", - "license": "MIT", - "engines": { - "node": ">=14" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" }, "node_modules/@fastify/ajv-compiler": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", - "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "fast-uri": "^2.0.0" + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" } }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" } }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", "funding": [ { "type": "github", @@ -187,147 +333,169 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/@fastify/cookie": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.4.0.tgz", - "integrity": "sha512-Th+pt3kEkh4MQD/Q2q1bMuJIB5NX/D5SwSpOKu3G/tjoGbwfpurIMJsWSPS0SJJ4eyjtmQ8OipDQspf8RbUOlg==", + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "cookie-signature": "^1.1.0", - "fastify-plugin": "^4.0.0" + "fast-json-stringify": "^6.0.0" } }, - "node_modules/@fastify/error": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", - "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", - "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-json-stringify": "^5.7.0" + "dequal": "^2.0.3" } }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", - "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "node_modules/@fastify/oauth2": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@fastify/oauth2/-/oauth2-8.2.0.tgz", + "integrity": "sha512-JQls0pZVt7k9j+qLrSf5OGflpHvylgEfSCVSA2oDaILCX3dwC/Z0UeI1J9M3luik7dtEbEFfpq0v9hF1v/46pQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "@fastify/cookie": "^11.0.2", + "fastify-plugin": "^5.1.0", + "simple-oauth2": "^5.1.0" } }, - "node_modules/@fastify/oauth2": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@fastify/oauth2/-/oauth2-7.9.0.tgz", - "integrity": "sha512-OsMr+M2FI7ib/UKZ8hC4SRnUBQqgJ0EsvAhn1qrdYJ9K/U5OwaM2sQM8fLEYbKYQRlH0oxC7lvdTm8Ncd5+ukA==", + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@fastify/cookie": "^9.0.4", - "fastify-plugin": "^4.5.1", - "simple-oauth2": "^5.0.0" + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" } }, "node_modules/@fastify/send": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", - "integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@lukeed/ms": "^2.0.1", + "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", - "http-errors": "2.0.0", - "mime": "^3.0.0" - } - }, - "node_modules/@fastify/send/node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" + "http-errors": "^2.0.0", + "mime": "^3" } }, "node_modules/@fastify/static": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-6.12.0.tgz", - "integrity": "sha512-KK1B84E6QD/FcQWxDI2aiUCwHxMJBI1KeCUzm1BwYpPY1b742+jeKruGHP2uOluuM6OkBPI8CIANrXcCRtC2oQ==", - "license": "MIT", - "dependencies": { - "@fastify/accept-negotiator": "^1.0.0", - "@fastify/send": "^2.0.0", - "content-disposition": "^0.5.3", - "fastify-plugin": "^4.0.0", - "glob": "^8.0.1", - "p-limit": "^3.1.0" - } - }, - "node_modules/@fastify/static/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", + "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@fastify/static/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@fastify/static/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" } }, "node_modules/@fastify/websocket": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-8.3.1.tgz", - "integrity": "sha512-hsQYHHJme/kvP3ZS4v/WMUznPBVeeQHHwAoMy1LiN6m/HuPfbdXq1MBJ4Nt8qX1YI+eVbog4MnOsU7MTozkwYA==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", + "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fastify-plugin": "^4.0.0", - "ws": "^8.0.0" + "duplexify": "^4.1.3", + "fastify-plugin": "^5.0.0", + "ws": "^8.16.0" } }, "node_modules/@gar/promisify": { @@ -400,6 +568,30 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -423,9 +615,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hyperswarm/rpc": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@hyperswarm/rpc/-/rpc-3.4.1.tgz", - "integrity": "sha512-Eb95tOXeM2QToihroYudY9edxmV6h8RaM0fGAv3VdTAaNKGdKUEwlffFFHrJFRns32m6ne23pEtJEgoZgzOqjg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@hyperswarm/rpc/-/rpc-3.5.0.tgz", + "integrity": "sha512-5VRhMQ4+AA/BJSTPpE3Q0mDkHKrQXkOPSDoLH/XSSERDv5Wl17UJd4NKKkNRKOGbg4OqWTnuKV2kVZVHebbtTA==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4", @@ -533,54 +725,6 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -685,14 +829,113 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", "license": "BSD-3-Clause" }, + "node_modules/@tetherto/hp-svc-facs-net": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/tetherto/hp-svc-facs-net.git#0d6b9a3833538ab419736656df220beef2513028", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "^1.1.0", + "@hyperswarm/rpc": "^3.4.0", + "async": "^3.2.1", + "debug": "^4.3.4", + "hyper-cmd-lib-keys": "^0.1.0", + "hyperdht": "^6.13.1", + "hyperswarm": "^4.7.14", + "lru": "^3.1.0" + }, + "engines": { + "node": ">=16.0" + } + }, + "node_modules/@tetherto/hp-svc-facs-store": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/tetherto/hp-svc-facs-store.git#c9d59ce19afa2a9761626f09baf7c71d7ee377fc", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "^1.1.0", + "async": "^3.2.6", + "autobase": "^7.18.0", + "corestore": "^7.4.5", + "hyperbee": "^2.26.3", + "hypercore": "^11.16.0" + }, + "engines": { + "node": ">=16.0" + } + }, + "node_modules/@tetherto/svc-facs-auth": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/tetherto/svc-facs-auth.git#0f0bb221a90174de3cdde87d63f94a44ff42e3a2", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "^1.1.0", + "@bitfinexcom/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", + "async": "3.2.5", + "bcrypt": "5.1.1" + } + }, + "node_modules/@tetherto/svc-facs-auth/node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", + "license": "MIT" + }, + "node_modules/@tetherto/svc-facs-httpd": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd.git#b11b1bac9143049ca998ff540d3135e9ed28e3b3", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "^1.1.0", + "@fastify/static": "^6.10.2", + "async": "^3.2.1", + "fastify": "^4.21.0" + } + }, + "node_modules/@tetherto/svc-facs-httpd-oauth2": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd-oauth2.git#5c09f77bf925a310013cd3bb8db6865983cca486", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "^1.1.0", + "@fastify/oauth2": "^7.2.2", + "async": "^3.2.1", + "debug": "^4.3.4" + } + }, + "node_modules/@tetherto/svc-facs-logging": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/tetherto/svc-facs-logging.git#abf27aab1dc97182074a3e98a0eddfd189d50202", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-facs-base": "^1.1.0", + "async": "^3.2.6", + "b4a": "^1.6.7", + "hyperswarm": "^4.13.1", + "pino": "^10.1.0", + "pino-abstract-transport": "^2.0.0" + } + }, + "node_modules/@tetherto/tether-wrk-base": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/tetherto/tether-wrk-base.git#f49b78d29c60a2e84e6a22aa2e0eb5b64b036b76", + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-svc-boot-js": "^1.2.0", + "@bitfinex/bfx-wrk-base": "^1.1.0", + "@tetherto/hp-svc-facs-net": "git+https://github.com/tetherto/hp-svc-facs-net.git#v1.0.0", + "@tetherto/hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git#v1.0.0", + "@tetherto/svc-facs-logging": "git+https://github.com/tetherto/svc-facs-logging.git#v1.0.0", + "async": "^3.2.6" + } + }, "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-3.0.1.tgz", + "integrity": "sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==", "license": "MIT", "optional": true, "engines": { - "node": ">= 6" + "node": ">= 10" } }, "node_modules/@types/istanbul-lib-coverage": { @@ -800,16 +1043,15 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -817,59 +1059,21 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + } }, "node_modules/ansi-regex": { "version": "5.0.1", @@ -902,18 +1106,17 @@ "license": "ISC" }, "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", "deprecated": "This package is no longer supported.", "license": "ISC", - "optional": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=10" } }, "node_modules/argparse": { @@ -1155,19 +1358,29 @@ } }, "node_modules/avvio": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", - "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@fastify/error": "^3.3.0", + "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -1272,9 +1485,9 @@ } }, "node_modules/bare-crypto": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.4.tgz", - "integrity": "sha512-JiCZ5l2YOG1y8J7yy1BCAKTCZrPnHLb7pDRIdurBTOn5oIwBQDIv8iH5Pl2V85vzjl1NZXRfNY4HZLsE942jJA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.5.tgz", + "integrity": "sha512-+QOwpHo4kCGRE3itG/hUrKpNOvDFSC+olSh3ARCgQV9Vh0LMpecFyl+t0+Cdfrw7oWbw+O/kqF/IGGqVCysl4g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1516,9 +1729,9 @@ } }, "node_modules/bare-module-resolve": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz", - "integrity": "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.2.tgz", + "integrity": "sha512-j+hiD5k99qec4KjJvYsI67q5AOBifmy9JG3oeMVxTmvrhn2sIdp8StrUvZu4YNgwTpO+NhniQG16N1ETDe1k5w==", "license": "Apache-2.0", "dependencies": { "bare-semver": "^1.0.0" @@ -1546,9 +1759,9 @@ } }, "node_modules/bare-os": { - "version": "3.8.7", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", - "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -1626,9 +1839,9 @@ } }, "node_modules/bare-stream": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", - "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", "license": "Apache-2.0", "dependencies": { "streamx": "^2.25.0", @@ -1688,9 +1901,9 @@ } }, "node_modules/bare-tcp": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.7.tgz", - "integrity": "sha512-rjpqNQ2cOCkNo3NeYA/W4GTK3DRkl8sDHO3uos+AEswUjLC8XXMQF8WrJCSjlIowCbS6NUVxKE92X5RGXjyefg==", + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.12.tgz", + "integrity": "sha512-x8Q1Iw93noY18hU0UlLpdS/AkgsAgD1TN2imSUT9aH2noZdoXo70qGJ8iEj5D+QwgT/1hFgk1gi8BoolYP9TUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1741,9 +1954,9 @@ } }, "node_modules/bare-url": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", - "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.2.tgz", + "integrity": "sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==", "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -1841,6 +2054,13 @@ "node": ">= 0.8" } }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -1855,96 +2075,14 @@ "node": ">= 10.0.0" } }, - "node_modules/bcrypt/node_modules/node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", - "license": "MIT" - }, - "node_modules/bfx-facs-base": { - "name": "@bitfinex/bfx-facs-base", - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-base.git#c42f4dfeab9c759a9c7ec7177ab7af6b53279e94", - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.1", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=16.0" - } - }, - "node_modules/bfx-facs-db-sqlite": { - "name": "@bitfinex/bfx-facs-db-sqlite", - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-db-sqlite.git#9fde6a35b1367fa4717972623446e4c4c304c453", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "async": "^3.2.4", - "lodash": "^4.17.15", - "sqlite3": "5.1.7" - } - }, - "node_modules/bfx-facs-http": { - "name": "@bitfinex/bfx-facs-http", - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-http.git#46cd482878de7ab227f2b1c93bf070c68ca45e38", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "async": "^2.6.3", - "lodash": "^4.17.21", - "node-fetch": "2.6.7" - }, - "engines": { - "node": ">=16.0" - } - }, - "node_modules/bfx-facs-http/node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" - } - }, - "node_modules/bfx-facs-interval": { - "name": "@bitfinex/bfx-facs-interval", - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-interval.git#c60da6077e7897dfdc93989dcec81ef96feb0b5e", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "async": "^3.2.1", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=16.0" - } - }, - "node_modules/bfx-facs-lru": { - "name": "@bitfinex/bfx-facs-lru", - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-lru.git#e9a6b2e65894e8fafe3e8dc43b3a942e7b33d472", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "async": "^3.2.1", - "lru": "3.1.0" - }, - "engines": { - "node": ">=16.0" - } - }, "node_modules/bfx-svc-boot-js": { "name": "@bitfinex/bfx-svc-boot-js", - "version": "1.1.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-svc-boot-js.git#be28ca5387eef34e651042e64d278feedf9b3a61", + "version": "1.2.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-svc-boot-js.git#a70b37a392ccf0fa1013c3d74d5916b727eac6f4", + "dev": true, "license": "Apache-2.0", "dependencies": { - "lodash": "^4.17.21", + "lodash": "^4.18.1", "yargs": "^17.2.1" } }, @@ -1984,9 +2122,9 @@ } }, "node_modules/blind-relay": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/blind-relay/-/blind-relay-1.4.0.tgz", - "integrity": "sha512-6xt7fDfCs6eGmNNym6I9N42jmjcMQn2qwwOVnkP9ZnrkXFk6c4/tdO1xqRmDEzKzV8gigd+DVdCUG/RUYnen7Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/blind-relay/-/blind-relay-1.5.0.tgz", + "integrity": "sha512-WwTaNv0Ea+L8PJq6KfQiwjuelyyDVDCV3Y8ym5cjoEBXjKK51oSy9Q3SHqSb1y2ddfz3yZZDvSg5xfN7ObayVw==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4", @@ -2010,13 +2148,24 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/brittle": { @@ -2120,6 +2269,52 @@ "node": ">= 10" } }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2307,37 +2502,18 @@ "license": "ISC" }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/content-disposition/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2346,21 +2522,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { - "node": ">=6.6.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/core-coupler": { @@ -2572,6 +2743,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2643,6 +2823,18 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3161,6 +3353,17 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -3184,6 +3387,19 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3220,6 +3436,30 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-n/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-n/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-n/node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3291,6 +3531,17 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3304,6 +3555,19 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3373,6 +3637,54 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -3462,12 +3774,6 @@ "node": ">=6" } }, - "node_modules/fast-content-type-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", - "license": "MIT" - }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -3494,57 +3800,9 @@ "license": "MIT" }, "node_modules/fast-json-stringify": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", - "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.1.0", - "ajv": "^8.10.0", - "ajv-formats": "^3.0.1", - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.1.0", - "json-schema-ref-resolver": "^1.0.1", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", "funding": [ { "type": "github", @@ -3555,13 +3813,15 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -3580,15 +3840,25 @@ } }, "node_modules/fast-uri": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", - "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", - "license": "MIT" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", - "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", "funding": [ { "type": "github", @@ -3601,28 +3871,37 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.0", + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", - "avvio": "^8.3.0", - "fast-content-type-parse": "^1.1.0", - "fast-json-stringify": "^5.8.0", - "find-my-way": "^8.0.0", - "light-my-request": "^5.11.0", - "pino": "^9.0.0", - "process-warning": "^3.0.0", - "proxy-addr": "^2.0.7", - "rfdc": "^1.3.0", - "secure-json-parse": "^2.7.0", - "semver": "^7.5.4", - "toad-cache": "^3.3.0" + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" } }, "node_modules/fastify-plugin": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", - "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, "node_modules/fastq": { @@ -3666,17 +3945,17 @@ "license": "MIT" }, "node_modules/find-my-way": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", - "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" + "safe-regex2": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/find-up": { @@ -3761,15 +4040,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -3847,24 +4117,24 @@ } }, "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", "deprecated": "This package is no longer supported.", "license": "ISC", - "optional": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" + "wide-align": "^1.1.2" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=10" } }, "node_modules/generate-object-property": { @@ -3978,21 +4248,17 @@ "license": "MIT" }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4011,6 +4277,15 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -4172,60 +4447,26 @@ "license": "ISC" }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/hp-svc-facs-net": { - "version": "0.0.1", - "resolved": "git+ssh://git@github.com/tetherto/hp-svc-facs-net.git#9879be0a2dc9614003900e3d2eeac237dfdd50c6", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "@hyperswarm/rpc": "^3.4.0", - "async": "^3.2.1", - "debug": "^4.3.4", - "hyper-cmd-lib-keys": "^0.1.0", - "hyperdht": "^6.13.1", - "hyperswarm": "^4.7.14", - "lru": "^3.1.0" - }, - "engines": { - "node": ">=16.0" - } - }, - "node_modules/hp-svc-facs-store": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/tetherto/hp-svc-facs-store.git#339527df5867edc5886e6fddd7ce1b1f4eb00e0d", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "async": "^3.2.6", - "autobase": "^7.18.0", - "corestore": "^7.4.5", - "hyperbee": "^2.26.3", - "hypercore": "^11.16.0" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" }, "engines": { - "node": ">=16.0" + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" } }, "node_modules/html-encoding-sniffer": { @@ -4322,6 +4563,19 @@ "node": ">=12" } }, + "node_modules/http-server/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4481,9 +4735,9 @@ } }, "node_modules/hyperdht": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.30.0.tgz", - "integrity": "sha512-LkfeAFVnOIvOpr2ILtJ38CzPgXmye7jXDS12xndVKfNoxzIrM0GonY+Bz5lHutoc08ZbT3Olr/w2NSeq5Pj0Ng==", + "version": "6.31.0", + "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.31.0.tgz", + "integrity": "sha512-FIAZGOpYcbRqnWgfPfBbXs/7J6rjDfJqHh9AQWFmmsKkBmwGn/m2kreIKD4ZMIOaWbM5YTwj7aHvBw/CMHYg0Q==", "license": "MIT", "dependencies": { "@hyperswarm/secret-stream": "^6.6.2", @@ -4694,9 +4948,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "optional": true, "engines": { @@ -4704,12 +4958,12 @@ } }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/is-array-buffer": { @@ -5228,19 +5482,28 @@ "license": "MIT" }, "node_modules/json-schema-ref-resolver": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", - "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "dequal": "^2.0.3" } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5312,23 +5575,43 @@ "node": ">= 0.8.0" } }, - "node_modules/lib-js-util-base": { - "name": "@bitfinex/lib-js-util-base", - "version": "2.0.2", - "resolved": "git+ssh://git@github.com/bitfinexcom/lib-js-util-base.git#e915c19224d8e334b3260ae3e979a50865722446", - "license": "MIT" - }, "node_modules/light-my-request": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", - "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause", "dependencies": { - "cookie": "^0.7.0", - "process-warning": "^3.0.0", - "set-cookie-parser": "^2.4.1" + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" } }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/load-json-file": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz", @@ -5486,16 +5769,15 @@ } }, "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "license": "MIT", "bin": { "mime": "cli.js" }, "engines": { - "node": ">=4" + "node": ">=10.0.0" } }, "node_modules/mimic-response": { @@ -5517,15 +5799,18 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -5716,9 +6001,9 @@ } }, "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, "node_modules/node-exports-info": { @@ -5795,6 +6080,105 @@ "node": ">= 10.12.0" } }, + "node_modules/node-gyp/node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/node-gyp/node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/node-gyp/node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/noise-curve-ed": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/noise-curve-ed/-/noise-curve-ed-2.1.0.tgz", @@ -5833,20 +6217,16 @@ } }, "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", "deprecated": "This package is no longer supported.", "license": "ISC", - "optional": true, "dependencies": { - "are-we-there-yet": "^3.0.0", + "are-we-there-yet": "^2.0.0", "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", + "gauge": "^3.0.0", "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, "node_modules/object-assign": { @@ -6039,6 +6419,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -6162,6 +6543,40 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -6186,22 +6601,22 @@ } }, "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", + "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" + "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" @@ -6222,21 +6637,14 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, - "node_modules/pino/node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" + "node_modules/pino/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } }, "node_modules/pkg-conf": { "version": "3.1.0", @@ -6380,9 +6788,19 @@ } }, "node_modules/process-warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, "node_modules/promise-inflight": { @@ -6430,9 +6848,9 @@ } }, "node_modules/protomux": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/protomux/-/protomux-3.10.1.tgz", - "integrity": "sha512-jgBqx8ZyaBWea/DFG4eOu1scOaeBwcnagiRC1XFVrjeGt7oAb0Pk5udPpBUpJ4DJBRjra50jD6YcZiQQTRqaaA==", + "version": "3.10.3", + "resolved": "https://registry.npmjs.org/protomux/-/protomux-3.10.3.tgz", + "integrity": "sha512-cUwyeEK9WwNA1SsAIj8Is5DEM7CYNVljQSUxid6HzXWtSHfob61mReopz8eJewsAKMdhk4fqCT2G5jiHja4UxA==", "license": "MIT", "dependencies": { "b4a": "^1.3.1", @@ -6470,19 +6888,6 @@ "protomux": "^3.10.1" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -6826,9 +7231,9 @@ "license": "Apache-2.0" }, "node_modules/ret": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", - "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", "license": "MIT", "engines": { "node": ">=10" @@ -6876,6 +7281,49 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/rocksdb-native": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.15.0.tgz", @@ -6919,15 +7367,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -6939,9 +7387,23 @@ } }, "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, "node_modules/safe-push-apply": { @@ -6980,12 +7442,25 @@ } }, "node_modules/safe-regex2": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", - "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ret": "~0.4.0" + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" } }, "node_modules/safe-stable-stringify": { @@ -7031,9 +7506,19 @@ "license": "MIT" }, "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause" }, "node_modules/semver": { @@ -7336,13 +7821,13 @@ } }, "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", + "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", "license": "MIT", "optional": true, "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -7448,6 +7933,12 @@ } } }, + "node_modules/sqlite3/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -7559,6 +8050,12 @@ "node": ">= 0.4" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/streamx": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", @@ -7579,26 +8076,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7778,117 +8255,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svc-facs-auth": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/tetherto/svc-facs-auth.git#87cfa2b791e6e0d1ad5f0ecb54854a9f3445afd1", - "license": "Apache-2.0", - "dependencies": { - "@bitfinexcom/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", - "async": "3.2.5", - "bcrypt": "5.1.1", - "bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git" - } - }, - "node_modules/svc-facs-auth/node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "license": "MIT" - }, - "node_modules/svc-facs-httpd": { - "version": "0.0.1", - "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd.git#c2ca996f29504941a3806ddd3d7cb803f8b94de9", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "@fastify/static": "^6.10.2", - "async": "^3.2.1", - "fastify": "^4.21.0" - } - }, - "node_modules/svc-facs-httpd-oauth2": { - "version": "0.0.1", - "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd-oauth2.git#7b92af92bf5027797c6202709f0b21a79b79cff9", - "license": "Apache-2.0", - "dependencies": { - "@fastify/oauth2": "^7.2.2", - "async": "^3.2.1", - "bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "debug": "^4.3.4" - } - }, - "node_modules/svc-facs-logging": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/tetherto/svc-facs-logging.git#85ebbd6df61ff347e7cd31a25b1d624a6e75ca9d", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", - "async": "^3.2.6", - "b4a": "^1.6.7", - "hyperswarm": "^4.13.1", - "pino": "^10.1.0", - "pino-abstract-transport": "^2.0.0" - } - }, - "node_modules/svc-facs-logging/node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/svc-facs-logging/node_modules/pino/node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/svc-facs-logging/node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/svc-facs-logging/node_modules/thread-stream": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", - "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -8013,19 +8379,6 @@ "node": ">=16" } }, - "node_modules/tether-wrk-base": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/tetherto/tether-wrk-base.git#7aba3e02b342344aa8d0e78050e94f205f33d5a9", - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-wrk-base": "git+https://github.com/bitfinexcom/bfx-wrk-base.git", - "async": "3.2.6", - "bfx-svc-boot-js": "git+https://github.com/bitfinexcom/bfx-svc-boot-js.git", - "hp-svc-facs-net": "git+https://github.com/tetherto/hp-svc-facs-net.git", - "hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git", - "svc-facs-logging": "git+https://github.com/tetherto/svc-facs-logging.git" - } - }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -8043,12 +8396,15 @@ "license": "MIT" }, "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", "license": "MIT", "dependencies": { "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/time-ordered-set": { @@ -8647,6 +9003,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index ca31d56..ad92fd2 100644 --- a/package.json +++ b/package.json @@ -29,20 +29,21 @@ "start": "node worker.js --wtype wrk-node-http --env production --port 3000" }, "dependencies": { - "@fastify/websocket": "8.3.1", + "@fastify/websocket": "11.2.0", + "@bitfinex/bfx-svc-boot-js": "1.2.0", + "@bitfinex/bfx-facs-db-sqlite": "git+https://github.com/bitfinexcom/bfx-facs-db-sqlite.git", + "@bitfinex/bfx-facs-http": "git+https://github.com/bitfinexcom/bfx-facs-http.git", + "@bitfinex/bfx-facs-interval": "git+https://github.com/bitfinexcom/bfx-facs-interval.git", + "@bitfinex/bfx-facs-lru": "git+https://github.com/bitfinexcom/bfx-facs-lru.git", + "@bitfinex/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", + "@tetherto/hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git#v1.0.0", + "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#v1.0.0", + "@tetherto/svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git#v1.0.0", + "@tetherto/svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git#v1.0.0", + "@tetherto/tether-wrk-base": "git+https://github.com/tetherto/tether-wrk-base.git#v1.0.0", "async": "3.2.6", - "bfx-facs-db-sqlite": "git+https://github.com/bitfinexcom/bfx-facs-db-sqlite.git", - "bfx-facs-http": "git+https://github.com/bitfinexcom/bfx-facs-http.git", - "bfx-facs-interval": "git+https://github.com/bitfinexcom/bfx-facs-interval.git", - "bfx-facs-lru": "git+https://github.com/bitfinexcom/bfx-facs-lru.git", "debug": "4.4.1", - "hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git", - "lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", - "mingo": "6.4.6", - "svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git", - "svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git", - "svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git", - "tether-wrk-base": "git+https://github.com/tetherto/tether-wrk-base.git" + "mingo": "6.4.6" }, "devDependencies": { "brittle": "3.18.0", @@ -51,6 +52,10 @@ "tether-svc-test-helper": "git+https://github.com/tetherto/tether-svc-test-helper.git" }, "overrides": { - "tar": ">=7.5.4" + "@tootallnate/once": "3.0.1", + "tar": ">=7.5.4", + "fastify": "5.8.5", + "@fastify/static": "9.1.3", + "@fastify/oauth2": "8.2.0" } } diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js index 4c5ef94..a0bfb03 100644 --- a/tests/integration/api.test.js +++ b/tests/integration/api.test.js @@ -4,7 +4,7 @@ const test = require('brittle') const fs = require('fs') const { createWorker } = require('tether-svc-test-helper').worker const { setTimeout: sleep } = require('timers/promises') -const HttpFacility = require('bfx-facs-http') +const HttpFacility = require('@bitfinex/bfx-facs-http') const { ENDPOINTS } = require('../../workers/lib/constants') const { MOCK_MINERS: mockMiners } = require('./helpers/mock-data') @@ -1007,7 +1007,8 @@ test('Api', { timeout: 90000 }, async (main) => { await main.test('Api: delete actions/voting/cancel', async (n) => { const api = `${appNodeBaseUrl}${ENDPOINTS.ACTIONS_CANCEL}?ids=1` - await testDeleteEndpointSecurityWithPermissions(n, httpClient, api, invalidToken, readonlyUser, 'ERR_WRITE_PERM_REQUIRED', siteOperatorUser, {}, encoding) + // Fastify rejects application/json with an empty body; send {} so json encoding is valid + await testDeleteEndpointSecurityWithPermissions(n, httpClient, api, invalidToken, readonlyUser, 'ERR_WRITE_PERM_REQUIRED', siteOperatorUser, { body: {} }, encoding) }) await main.test('Api: post users', async (n) => { diff --git a/tests/unit/routes/ws.routes.test.js b/tests/unit/routes/ws.routes.test.js index 1c64de4..4012ee5 100644 --- a/tests/unit/routes/ws.routes.test.js +++ b/tests/unit/routes/ws.routes.test.js @@ -94,24 +94,21 @@ test('ws routes - handler adds client to wsClients', async (t) => { const wsRoute = routes.find(r => r.websocket === true) if (wsRoute?.handler) { - const mockConn = { - socket: { - subscriptions: new Set(), - on: function (event, handler) { - if (event === 'close' || event === 'error') { - // Store handlers for testing - this._closeHandler = handler - this._errorHandler = handler - } else if (event === 'message') { - this._messageHandler = handler - } - }, - send: function () {} - } + const mockSocket = { + subscriptions: new Set(), + on: function (event, handler) { + if (event === 'close' || event === 'error') { + this._closeHandler = handler + this._errorHandler = handler + } else if (event === 'message') { + this._messageHandler = handler + } + }, + send: function () {} } - await wsRoute.handler(mockConn) - t.ok(mockCtx.wsClients.has(mockConn.socket), 'should add socket to wsClients') + await wsRoute.handler(mockSocket) + t.ok(mockCtx.wsClients.has(mockSocket), 'should add socket to wsClients') } t.pass() diff --git a/tests/unit/services.poolManager.test.js b/tests/unit/services.poolManager.test.js deleted file mode 100644 index e88a10b..0000000 --- a/tests/unit/services.poolManager.test.js +++ /dev/null @@ -1,371 +0,0 @@ -'use strict' - -const test = require('brittle') - -const { - getPoolStats, - getPoolConfigs, - getMinersWithPools, - getUnitsWithPoolData, - getPoolAlerts -} = require('../../workers/lib/server/services/poolManager') -const { withDataProxy } = require('./helpers/mockHelpers') - -function createMockCtx (responseData) { - return withDataProxy({ - conf: { - orks: [{ rpcPublicKey: 'key1' }] - }, - net_r0: { - jRequest: (pk, method, payload) => { - if (Array.isArray(responseData) && typeof payload?.limit === 'number') { - const offset = payload.offset || 0 - const limit = payload.limit - return Promise.resolve(responseData.slice(offset, offset + limit)) - } - return Promise.resolve(responseData) - } - } - }) -} - -function createMockPoolStatsResponse (pools) { - return [{ - stats: pools.map(p => ({ - poolType: p.poolType || 'f2pool', - username: p.username || 'worker1', - hashrate: p.hashrate || 100000, - hashrate_1h: p.hashrate_1h || 100000, - hashrate_24h: p.hashrate_24h || 95000, - worker_count: p.worker_count || 5, - active_workers_count: p.active_workers_count || 4, - balance: p.balance || 0.001, - unsettled: p.unsettled || 0, - revenue_24h: p.revenue_24h || 0.0001, - yearlyBalances: p.yearlyBalances || [], - timestamp: p.timestamp || Date.now() - })) - }] -} - -function createMockMiner (id, options = {}) { - return { - id, - code: options.code || 'AM-S19XP-0001', - type: options.type || 'miner-am-s19xp', - info: { - container: options.container || 'bitmain-imm-1', - serialNum: options.serialNum || 'HTM3X01', - nominalHashrateMhs: options.nominalHashrateMhs || 204000000 - }, - address: options.address || '192.168.1.100', - alerts: options.alerts || {} - } -} - -test('poolManager:getPoolStats returns correct aggregates', async function (t) { - const poolData = createMockPoolStatsResponse([ - { poolType: 'f2pool', username: 'worker1', hashrate: 100000, worker_count: 5, active_workers_count: 4, balance: 0.001 }, - { poolType: 'ocean', username: 'addr1', hashrate: 200000, worker_count: 10, active_workers_count: 8, balance: 0.002 } - ]) - - const mockCtx = createMockCtx(poolData) - - const result = await getPoolStats(mockCtx) - - t.is(result.totalPools, 2) - t.is(result.totalWorkers, 15) - t.is(result.activeWorkers, 12) - t.is(result.totalHashrate, 300000) - t.is(result.totalBalance, 0.003) - t.is(result.errors, 3) -}) - -test('poolManager:getPoolStats handles empty orks', async function (t) { - const mockCtx = { - conf: { orks: [] }, - net_r0: { jRequest: () => Promise.resolve([]) } - } - - const result = await getPoolStats(mockCtx) - - t.is(result.totalPools, 0) - t.is(result.totalWorkers, 0) - t.is(result.activeWorkers, 0) - t.is(result.totalHashrate, 0) - t.is(result.totalBalance, 0) -}) - -test('poolManager:getPoolStats deduplicates pools by key', async function (t) { - const poolData = createMockPoolStatsResponse([ - { poolType: 'f2pool', username: 'worker1', worker_count: 5, active_workers_count: 4 } - ]) - - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }, { rpcPublicKey: 'key2' }] }, - net_r0: { jRequest: () => Promise.resolve(poolData) } - } - - const result = await getPoolStats(mockCtx) - - t.is(result.totalPools, 1) - t.is(result.totalWorkers, 5) -}) - -test('poolManager:getPoolConfigs returns pool objects', async function (t) { - const poolData = createMockPoolStatsResponse([ - { poolType: 'f2pool', username: 'worker1', hashrate: 100000, balance: 0.001 }, - { poolType: 'ocean', username: 'addr1', hashrate: 200000, balance: 0.002 } - ]) - - const mockCtx = createMockCtx(poolData) - - const result = await getPoolConfigs(mockCtx) - - t.ok(Array.isArray(result)) - t.is(result.length, 2) - - const f2pool = result.find(p => p.pool === 'f2pool') - t.ok(f2pool) - t.is(f2pool.name, 'worker1') - t.is(f2pool.account, 'worker1') - t.is(f2pool.hashrate, 100000) - t.is(f2pool.balance, 0.001) -}) - -test('poolManager:getPoolConfigs returns empty for no data', async function (t) { - const mockCtx = createMockCtx([]) - - const result = await getPoolConfigs(mockCtx) - - t.ok(Array.isArray(result)) - t.is(result.length, 0) -}) - -test('poolManager:getMinersWithPools returns paginated results', async function (t) { - const miners = [] - for (let i = 0; i < 100; i++) { - miners.push(createMockMiner(`miner-${i}`, { code: `AM-S19XP-${i}` })) - } - - const mockCtx = createMockCtx(miners) - - const result = await getMinersWithPools(mockCtx, { page: 1, limit: 10 }) - - t.is(result.miners.length, 10) - t.is(result.total, 100) - t.is(result.page, 1) - t.is(result.limit, 10) - t.is(result.totalPages, 10) -}) - -test('poolManager:getMinersWithPools fetches all pages from ork workers', async function (t) { - const miners = [] - for (let i = 0; i < 250; i++) { - miners.push(createMockMiner(`miner-${i}`, { code: `AM-S19XP-${i}` })) - } - - const requestLog = [] - const mockCtx = { - conf: { orks: [{ rpcPublicKey: 'key1' }] }, - net_r0: { - jRequest: (pk, method, payload) => { - requestLog.push({ offset: payload.offset, limit: payload.limit }) - const offset = payload.offset || 0 - const limit = payload.limit - return Promise.resolve(miners.slice(offset, offset + limit)) - } - } - } - - const result = await getMinersWithPools(mockCtx, { page: 1, limit: 50 }) - - t.is(result.total, 250, 'should fetch all 250 miners across multiple pages') - t.is(result.miners.length, 50, 'should return requested page size') - t.is(result.totalPages, 5) - t.is(requestLog.length, 3, 'should make 3 RPC calls (100+100+50)') - t.is(requestLog[0].offset, 0) - t.is(requestLog[1].offset, 100) - t.is(requestLog[2].offset, 200) -}) - -test('poolManager:getMinersWithPools extracts model from type', async function (t) { - const miners = [ - createMockMiner('m1', { type: 'miner-am-s19xp', code: 'AM-S19XP-001' }), - createMockMiner('m2', { type: 'miner-wm-m56s', code: 'WM-M56S-001' }), - createMockMiner('m3', { type: 'miner-av-a1346', code: 'AV-A1346-001' }) - ] - - const mockCtx = createMockCtx(miners) - - const result = await getMinersWithPools(mockCtx, {}) - - t.is(result.miners[0].model, 'Antminer S19XP') - t.is(result.miners[1].model, 'Whatsminer M56S') - t.is(result.miners[2].model, 'Avalon A1346') -}) - -test('poolManager:getMinersWithPools filters by search', async function (t) { - const miners = [ - createMockMiner('miner-1', { code: 'AM-S19XP-0001', serialNum: 'HTM3X01' }), - createMockMiner('miner-2', { code: 'WM-M56S-0002', serialNum: 'WMT001' }) - ] - - const mockCtx = createMockCtx(miners) - - const result = await getMinersWithPools(mockCtx, { search: 'S19XP' }) - - t.is(result.total, 1) - t.is(result.miners[0].id, 'miner-1') -}) - -test('poolManager:getMinersWithPools filters by model', async function (t) { - const miners = [ - createMockMiner('m1', { type: 'miner-am-s19xp' }), - createMockMiner('m2', { type: 'miner-wm-m56s' }) - ] - - const mockCtx = createMockCtx(miners) - - const result = await getMinersWithPools(mockCtx, { model: 'whatsminer' }) - - t.is(result.total, 1) - t.is(result.miners[0].model, 'Whatsminer M56S') -}) - -test('poolManager:getMinersWithPools maps thing fields correctly', async function (t) { - const miners = [createMockMiner('miner-1', { - code: 'AM-S19XP-0165', - type: 'miner-am-s19xp', - container: 'bitmain-imm-1', - address: '10.0.0.1', - serialNum: 'HTM3X10', - nominalHashrateMhs: 204000000 - })] - - const mockCtx = createMockCtx(miners) - - const result = await getMinersWithPools(mockCtx, {}) - - const miner = result.miners[0] - t.is(miner.id, 'miner-1') - t.is(miner.code, 'AM-S19XP-0165') - t.is(miner.type, 'miner-am-s19xp') - t.is(miner.model, 'Antminer S19XP') - t.is(miner.container, 'bitmain-imm-1') - t.is(miner.ipAddress, '10.0.0.1') - t.is(miner.serialNum, 'HTM3X10') - t.is(miner.nominalHashrate, 204000000) -}) - -test('poolManager:getUnitsWithPoolData groups miners by container', async function (t) { - const miners = [ - createMockMiner('m1', { container: 'bitmain-imm-1' }), - createMockMiner('m2', { container: 'bitmain-imm-1' }), - createMockMiner('m3', { container: 'bitdeer-4a' }) - ] - - const mockCtx = createMockCtx(miners) - - const result = await getUnitsWithPoolData(mockCtx) - - t.ok(Array.isArray(result)) - t.is(result.length, 2) - - const imm1 = result.find(u => u.name === 'bitmain-imm-1') - t.ok(imm1) - t.is(imm1.minersCount, 2) -}) - -test('poolManager:getUnitsWithPoolData sums nominal hashrate', async function (t) { - const miners = [ - createMockMiner('m1', { container: 'unit-A', nominalHashrateMhs: 100000 }), - createMockMiner('m2', { container: 'unit-A', nominalHashrateMhs: 150000 }) - ] - - const mockCtx = createMockCtx(miners) - - const result = await getUnitsWithPoolData(mockCtx) - - const unitA = result.find(u => u.name === 'unit-A') - t.is(unitA.nominalHashrate, 250000) -}) - -test('poolManager:getUnitsWithPoolData reads container from info only', async function (t) { - const miners = [ - createMockMiner('m1', { container: 'bitmain-imm-2' }) - ] - - const mockCtx = createMockCtx(miners) - - const result = await getUnitsWithPoolData(mockCtx) - - t.is(result.length, 1) - t.is(result[0].name, 'bitmain-imm-2') -}) - -test('poolManager:getUnitsWithPoolData assigns unassigned for no container', async function (t) { - const miners = [ - createMockMiner('m1', { container: undefined }) - ] - miners[0].info.container = undefined - - const mockCtx = createMockCtx(miners) - - const result = await getUnitsWithPoolData(mockCtx) - - t.is(result[0].name, 'unassigned') -}) - -test('poolManager:getPoolAlerts returns pool-related alerts', async function (t) { - const miners = [ - createMockMiner('miner-1', { - alerts: { - wrong_miner_pool: { ts: Date.now() }, - wrong_miner_subaccount: { ts: Date.now() } - } - }), - createMockMiner('miner-2', { - alerts: { all_pools_dead: { ts: Date.now() } } - }) - ] - - const mockCtx = createMockCtx(miners) - - const result = await getPoolAlerts(mockCtx) - - t.ok(Array.isArray(result)) - t.is(result.length, 3) -}) - -test('poolManager:getPoolAlerts respects limit', async function (t) { - const miners = [] - for (let i = 0; i < 10; i++) { - miners.push(createMockMiner(`miner-${i}`, { - type: 'miner-am-s19xp', - code: `AM-S19XP-${i}`, - alerts: { wrong_miner_pool: { ts: Date.now() - i * 1000 } } - })) - } - - const mockCtx = createMockCtx(miners) - - const result = await getPoolAlerts(mockCtx, { limit: 5 }) - - t.is(result.length, 5) -}) - -test('poolManager:getPoolAlerts includes severity', async function (t) { - const miners = [ - createMockMiner('miner-1', { - alerts: { all_pools_dead: { ts: Date.now() } } - }) - ] - - const mockCtx = createMockCtx(miners) - - const result = await getPoolAlerts(mockCtx) - - t.is(result[0].severity, 'critical') - t.is(result[0].type, 'all_pools_dead') -}) diff --git a/worker.js b/worker.js index 4a18d69..ad44ca3 100644 --- a/worker.js +++ b/worker.js @@ -1,4 +1,5 @@ 'use strict' -const worker = require('tether-wrk-base/worker') +// used to spawn a `bfx-svc-js` service. Contains the worker CLI. +const worker = require('@bitfinex/bfx-svc-boot-js') module.exports = worker diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index dcf434e..d2e2584 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -2,7 +2,7 @@ const async = require('async') const WebsocketPlugin = require('@fastify/websocket') -const TetherWrkBase = require('tether-wrk-base/workers/base.wrk.tether') +const TetherWrkBase = require('@tetherto/tether-wrk-base/workers/base.wrk.tether') const AuthLib = require('./lib/auth') const debug = require('debug')('store:aggr') const libServer = require('./lib/server') @@ -41,15 +41,15 @@ class WrkServerHttp extends TetherWrkBase { this._loadOptionalConfig() this.setInitFacs([ - ['fac', 'bfx-facs-interval', '0', '0', {}, -10], - ['fac', 'bfx-facs-lru', '10s', '10s', { max: 10000, maxAge: 10000 }], - ['fac', 'bfx-facs-lru', '15s', '15s', { max: 10000, maxAge: 15000 }], - ['fac', 'bfx-facs-lru', '30s', '30s', { max: 10000, maxAge: 30000 }], - ['fac', 'bfx-facs-lru', '1m', '1m', { max: 10000, maxAge: AUTH_CACHE_TTL }], - ['fac', 'bfx-facs-lru', '15m', '15m', { max: 10000, maxAge: 60000 * 15 }], - ['fac', 'bfx-facs-db-sqlite', 'auth', 'auth', { name: 'miningos-app-node', persist: true }], - ['fac', 'bfx-facs-http', 'c0', 'c0', { timeout: 30000, debug: false }, 0], - ['fac', 'svc-facs-httpd', 'h0', 'h0', { + ['fac', '@bitfinex/bfx-facs-interval', '0', '0', {}, -10], + ['fac', '@bitfinex/bfx-facs-lru', '10s', '10s', { max: 10000, maxAge: 10000 }], + ['fac', '@bitfinex/bfx-facs-lru', '15s', '15s', { max: 10000, maxAge: 15000 }], + ['fac', '@bitfinex/bfx-facs-lru', '30s', '30s', { max: 10000, maxAge: 30000 }], + ['fac', '@bitfinex/bfx-facs-lru', '1m', '1m', { max: 10000, maxAge: AUTH_CACHE_TTL }], + ['fac', '@bitfinex/bfx-facs-lru', '15m', '15m', { max: 10000, maxAge: 60000 * 15 }], + ['fac', '@bitfinex/bfx-facs-db-sqlite', 'auth', 'auth', { name: 'miningos-app-node', persist: true }], + ['fac', '@bitfinex/bfx-facs-http', 'c0', 'c0', { timeout: 30000, debug: false }, 0], + ['fac', '@tetherto/svc-facs-httpd', 'h0', 'h0', { staticRootPath: this.conf.staticRootPath, staticOn404File: 'index.html', port: this.ctx.port, @@ -57,9 +57,9 @@ class WrkServerHttp extends TetherWrkBase { addDefaultRoutes: true, trustProxy: true }, 0], - ['fac', 'svc-facs-httpd-oauth2', 'h0', 'h0', {}, 0], - ['fac', 'svc-facs-httpd-oauth2', 'h1', 'h1', {}, 0], - ['fac', 'svc-facs-auth', 'a0', 'a0', () => ({ + ['fac', '@tetherto/svc-facs-httpd-oauth2', 'h0', 'h0', {}, 0], + ['fac', '@tetherto/svc-facs-httpd-oauth2', 'h1', 'h1', {}, 0], + ['fac', '@tetherto/svc-facs-auth', 'a0', 'a0', () => ({ sqlite: this.dbSqlite_auth, lru: this.lru_15m }), 3] diff --git a/workers/lib/globalData.js b/workers/lib/globalData.js index f088344..496c87a 100644 --- a/workers/lib/globalData.js +++ b/workers/lib/globalData.js @@ -1,9 +1,9 @@ 'use strict' -const utilsStore = require('hp-svc-facs-store/utils') +const utilsStore = require('@tetherto/hp-svc-facs-store/utils') const mingo = require('mingo') const { GLOBAL_DATA_TYPES, USER_SETTINGS_TYPE } = require('./constants') -const gLibUtilBase = require('lib-js-util-base') +const gLibUtilBase = require('@bitfinex/lib-js-util-base') const { isValidJsonObject } = require('./utils') class GlobalDataLib { diff --git a/workers/lib/server/handlers/auth.handlers.js b/workers/lib/server/handlers/auth.handlers.js index 42904eb..e5a6e06 100644 --- a/workers/lib/server/handlers/auth.handlers.js +++ b/workers/lib/server/handlers/auth.handlers.js @@ -1,6 +1,6 @@ 'use strict' -const gLibUtilBase = require('lib-js-util-base') +const gLibUtilBase = require('@bitfinex/lib-js-util-base') const { parseJsonQueryParam, getAuthTokenFromHeaders } = require('../../utils') async function getUserInfo (ctx, req) { diff --git a/workers/lib/server/routes/ws.routes.js b/workers/lib/server/routes/ws.routes.js index 899049f..1544d89 100644 --- a/workers/lib/server/routes/ws.routes.js +++ b/workers/lib/server/routes/ws.routes.js @@ -10,8 +10,7 @@ module.exports = (ctx) => [{ onRequest: async (req, rep) => { await authCheck(ctx, req, rep, req.query.token) }, - handler: async (conn) => { - const socket = conn.socket + handler: async (socket) => { socket.subscriptions = new Set() ctx.wsClients.add(socket) From efd8d3242dd390ff8b92cf94a5891e4b6f0daa51 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Sun, 3 May 2026 15:56:17 +0530 Subject: [PATCH 43/63] Update the dcs apis to support the new tags (#74) --- tests/unit/handlers/coolingSystem.handlers.test.js | 6 +++--- workers/lib/server/handlers/coolingSystem.handlers.js | 6 ++++-- workers/lib/server/handlers/energySystem.handlers.js | 1 + 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js index 3dd46df..966d3e3 100644 --- a/tests/unit/handlers/coolingSystem.handlers.test.js +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -62,8 +62,8 @@ const createMockEquipment = () => ({ { equipment: 'TC-7501', is_active: true, miner_side_out_temp: { value: 37.1, unit: '°C' }, tower_side_in_temp: { value: 29.2, unit: '°C' }, tower_side_out_temp: { value: 36.9, unit: '°C' }, tcv_position: { value: 55, unit: '%' } } ], cooling_towers: [ - { equipment: 'TR-7501', is_running: true, fan_status: 'Running', fan_power: { value: 60, unit: 'kW' }, level: { value: 82, unit: '%' }, vibration: { value: 0.8, unit: 'mm/s', status: 'Normal' } }, - { equipment: 'TR-7502', is_running: true, fan_status: 'Running', fan_power: { value: 45, unit: 'kW' }, level: { value: 85, unit: '%' }, vibration: { value: 0.6, unit: 'mm/s', status: 'Normal' } } + { equipment: 'TR-7501', is_running: true, fan_status: 'Running', fan_cv: { value: 60, unit: 'CV' }, level: { value: 82, unit: '%' }, vibration: { value: 0.8, unit: 'mm/s', status: 'Normal' } }, + { equipment: 'TR-7502', is_running: true, fan_status: 'Running', fan_cv: { value: 45, unit: 'CV' }, level: { value: 85, unit: '%' }, vibration: { value: 0.6, unit: 'mm/s', status: 'Normal' } } ], valves: [ { equipment: 'PCV-7502', position: { value: 12, unit: '%' } }, @@ -408,7 +408,7 @@ test('buildMinersCircuit2View - builds view from enriched equipment', (t) => { t.ok(view.heat_exchangers, 'should have heat_exchangers') t.ok(view.summary, 'should have summary') // Check enriched data with units - t.ok(view.cooling_towers[0].fan_power.unit, 'fan_power should have unit') + t.ok(view.cooling_towers[0].fan_cv.unit, 'fan_cv should have unit') t.ok(view.cooling_towers[0].level.unit, 'level should have unit') // Check tower sensor refs t.ok(view.cooling_towers[0].level_sensor, 'should have level_sensor ref') diff --git a/workers/lib/server/handlers/coolingSystem.handlers.js b/workers/lib/server/handlers/coolingSystem.handlers.js index 1ca0679..b20d922 100644 --- a/workers/lib/server/handlers/coolingSystem.handlers.js +++ b/workers/lib/server/handlers/coolingSystem.handlers.js @@ -278,12 +278,13 @@ function buildMinersCircuit2View (equipment, config) { is_running: ct.is_running, fan_status: ct.fan_status, fan_speed: ct.fan_speed, - fan_power: ct.fan_power, + fan_cv: ct.fan_cv, fan_id: towerFanId, level: ct.level, level_sensor: towerLevelSensor, vibration: ct.vibration, vibration_sensor: towerVibrationSensorId, + vibration_threshold: ct.vibration_threshold || null, capacity_flow: towerConfig.defaults?.tower_capacity || null, capacity_gcal: towerConfig.defaults?.tower_capacity_gcal || null })) @@ -667,12 +668,13 @@ function buildHvacCircuit2View (equipment, config) { is_running: ct.is_running, fan_status: ct.fan_status, fan_speed: ct.fan_speed, - fan_power: ct.fan_power, + fan_cv: ct.fan_cv, fan_id: towerFanId || null, level: ct.level, level_sensor: towerLevelSensorId || null, vibration: ct.vibration, vibration_sensor: towerVibrationSensorId || null, + vibration_threshold: ct.vibration_threshold || null, capacity_mcal: condenserConfig.defaults?.tower_capacity_mcal || null, capacity_flow: condenserConfig.defaults?.tower_flow || null })) diff --git a/workers/lib/server/handlers/energySystem.handlers.js b/workers/lib/server/handlers/energySystem.handlers.js index dc233dc..7592c75 100644 --- a/workers/lib/server/handlers/energySystem.handlers.js +++ b/workers/lib/server/handlers/energySystem.handlers.js @@ -117,6 +117,7 @@ function buildLayoutView (equipment, config, stats) { return { title: 'Energy Layout', + source_label: energyLayout.source_label || null, site_total: siteTotal, site_pm: siteMeter || null, main_protection: mainRelay || null, From ec96b53508d027162a1dbdd2fb82570cbfb69ccc Mon Sep 17 00:00:00 2001 From: tekwani Date: Fri, 8 May 2026 08:23:21 +0530 Subject: [PATCH 44/63] develop sync (#76) --- .github/actions/git-secrets/action.yml | 20 + .github/workflows/public-publish.yml | 35 ++ .github/workflows/release-pr.yml | 93 ++++ package-lock.json | 716 ++++++++++++++----------- package.json | 25 +- tests/integration/api.security.test.js | 581 ++++++++++++++++++++ tests/unit/routes/ws.routes.test.js | 2 +- workers/lib/server/routes/ws.routes.js | 2 +- 8 files changed, 1162 insertions(+), 312 deletions(-) create mode 100644 .github/actions/git-secrets/action.yml create mode 100644 .github/workflows/public-publish.yml create mode 100644 .github/workflows/release-pr.yml create mode 100644 tests/integration/api.security.test.js diff --git a/.github/actions/git-secrets/action.yml b/.github/actions/git-secrets/action.yml new file mode 100644 index 0000000..b91ef76 --- /dev/null +++ b/.github/actions/git-secrets/action.yml @@ -0,0 +1,20 @@ +name: 'Git Secrets Scan' +description: 'Scan repository for secrets using git-secrets' + +runs: + using: "composite" + steps: + - name: Install and configure git-secrets + shell: bash + run: | + git clone https://github.com/awslabs/git-secrets.git + cd git-secrets + git checkout 1.3.0 + sudo make install + cd .. + git secrets --install + git secrets --register-aws + + - name: Run git-secrets scan + shell: bash + run: git secrets --scan-history diff --git a/.github/workflows/public-publish.yml b/.github/workflows/public-publish.yml new file mode 100644 index 0000000..97acae2 --- /dev/null +++ b/.github/workflows/public-publish.yml @@ -0,0 +1,35 @@ +name: Release to public npm + +on: + pull_request: + types: [closed] + branches: + - main + +jobs: + approve-release: + if: "${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.title, 'Release: ') }}" + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: lts/* + registry-url: "https://registry.npmjs.org" + - run: | + VERSION=$(node -p "require('./package.json').version") + npm version "$VERSION" --no-git-tag-version --allow-same-version + git remote set-url origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" + if ! git ls-remote --tags origin | grep -q "refs/tags/v$VERSION$"; then + git tag "v$VERSION" + git push origin "v$VERSION" + fi + npm publish --access public --provenance --tag latest --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000..0deda5a --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,93 @@ +name: Release PR + +on: + workflow_dispatch: + inputs: + bump: + description: >- + Use none to open a release PR without changing package.json (first + release at the current version, e.g. 1.0.0). + required: true + type: choice + options: + - none + - major + - minor + - patch + - prerelease + preid: + description: Pre-release identifier (ignored when bump is none) + required: false + type: string + +jobs: + bump-version: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: true + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: lts/* + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} + + - name: Bump version + id: bump + env: + BUMP: ${{ inputs.bump }} + run: | + if [ "$BUMP" = "none" ]; then + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "branch=release/v$VERSION" >> "$GITHUB_OUTPUT" + else + PREID_ARG=${{ inputs.preid != '' && format('--preid={0}', inputs.preid) || '' }} + NEW_VERSION=$(npm version "$BUMP" $PREID_ARG --no-git-tag-version) + echo "version=${NEW_VERSION#v}" >> "$GITHUB_OUTPUT" + echo "branch=release/v${NEW_VERSION#v}" >> "$GITHUB_OUTPUT" + fi + + - name: Update lockfile + if: inputs.bump != 'none' + run: | + if [ -f package-lock.json ]; then + npm install --ignore-scripts --package-lock-only + fi + + - name: Create branch and push + env: + BUMP_INPUT: ${{ inputs.bump }} + run: | + git checkout -b ${{ steps.bump.outputs.branch }} + git add package.json + [ -f package-lock.json ] && git add package-lock.json + if [ "$BUMP_INPUT" = "none" ]; then + git commit --allow-empty -m "Release: v${{ steps.bump.outputs.version }}" + else + git commit -m "Release: v${{ steps.bump.outputs.version }}" + fi + git push origin ${{ steps.bump.outputs.branch }} + + - name: Open Pull Request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr create \ + --title "Release: v${{ steps.bump.outputs.version }}" \ + --body "Release PR for \`v${{ steps.bump.outputs.version }}\`." \ + --base main \ + --head ${{ steps.bump.outputs.branch }} diff --git a/package-lock.json b/package-lock.json index 8091e12..572274f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "miningos-app-node", - "version": "0.0.1", + "name": "@tetherto/miningos-app-node", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "miningos-app-node", - "version": "0.0.1", + "name": "@tetherto/miningos-app-node", + "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-db-sqlite": "git+https://github.com/bitfinexcom/bfx-facs-db-sqlite.git", @@ -25,11 +25,17 @@ "debug": "4.4.1", "mingo": "6.4.6" }, + "bin": { + "miningos-app-node": "worker.js" + }, "devDependencies": { "brittle": "3.18.0", "http-server": "14.1.1", "standard": "17.1.0", "tether-svc-test-helper": "git+https://github.com/tetherto/tether-svc-test-helper.git" + }, + "engines": { + "node": ">=20" } }, "node_modules/@bitfinex/bfx-facs-base": { @@ -208,6 +214,13 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -542,9 +555,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/wreck": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", - "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.1.tgz", + "integrity": "sha512-UwTeGBfAnB/1mkw4gD6IQGI/bgMu7iGmqgT8K+xxye3z4ZHhCZlmS2wuHBJmENhBJSKqvoYzJ71ds3Xfq4gofQ==", "license": "BSD-3-Clause", "dependencies": { "@hapi/boom": "^10.0.1", @@ -568,6 +581,13 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -659,15 +679,6 @@ "node": ">=18.0.0" } }, - "node_modules/@isaacs/fs-minipass/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -953,9 +964,9 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "dev": true, "license": "ISC" }, @@ -1312,14 +1323,14 @@ } }, "node_modules/autobase": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.27.3.tgz", - "integrity": "sha512-eH0UUYYO2kvy9Vug0KLj/mjjSGEslA6YL7axBlPsArlmadBDJmkYj3olpbWVqnsA+VtTndiHGQZyC/004x/aVw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.28.0.tgz", + "integrity": "sha512-pyV0K5HuHtiDij43qw4RO3mdwqtiAgPB1YyzQcDMf8ypBU6UaA55mmtlUx5C50cHpwILls9RfR2dVEtIAD8tPg==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.1", "bare-events": "^2.2.0", - "compact-encoding": "^2.16.0", + "compact-encoding": "^3.0.0", "core-coupler": "^2.0.0", "debounceify": "^1.0.0", "encryption-encoding": "^1.0.3", @@ -1392,10 +1403,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bare-abort": { "version": "2.0.13", @@ -1468,14 +1482,14 @@ } }, "node_modules/bare-cov": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/bare-cov/-/bare-cov-1.2.1.tgz", - "integrity": "sha512-SHNqb/WG9udUEj8m5qGSehoUQ9o0Xzv8nrf4HIdmG+KNU/XdhPLhTP5VPrHnSAMAXhAzEB4Q+2IYZ0x9qPzN1g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/bare-cov/-/bare-cov-1.2.2.tgz", + "integrity": "sha512-d/b4HdL5ohZDwBvDegJeSkmo1X2MGlm22jXzuY2D7wl2W1+7nk/b7+HMogwgUa+zi8rVVRJQKHCHPHYBJpvlvw==", "dev": true, "license": "Apache-2.0", "dependencies": { "bare-fs": "^4.1.2", - "bare-inspector": "^4.0.1", + "bare-inspector": "^6.0.1", "bare-path": "^3.0.0", "bare-process": "^4.2.1", "bare-url": "^2.1.5", @@ -1485,9 +1499,9 @@ } }, "node_modules/bare-crypto": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.5.tgz", - "integrity": "sha512-+QOwpHo4kCGRE3itG/hUrKpNOvDFSC+olSh3ARCgQV9Vh0LMpecFyl+t0+Cdfrw7oWbw+O/kqF/IGGqVCysl4g==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.6.tgz", + "integrity": "sha512-xaK6n2bZgpJD8fdRdRsWvBiwoiHpQXDMcA/g06jg1M0bQ95OhIj4/kRPLcgGfFEgr6ewmGOJPAGQGai54/wm+A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1604,9 +1618,9 @@ "license": "Apache-2.0" }, "node_modules/bare-http-parser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.1.3.tgz", - "integrity": "sha512-+dhVvQi6brHq14L/XHNRQ+TLuVE76VjRmMt61wVEtS+Od8xUslfMHWJN/ZjIIt3RtTG6vPuA+x9cOh7KrkBJsA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.1.4.tgz", + "integrity": "sha512-DL+7fTEUWzAEj/Baw9e/BwNAidARbxuUf5bonQ/Wt3VPUdJNyf562ydaono9ZkQBAUw0NydzYEI97rSs/93ruA==", "dev": true, "license": "Apache-2.0" }, @@ -1636,15 +1650,15 @@ } }, "node_modules/bare-https": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-2.1.3.tgz", - "integrity": "sha512-0TI/mJXQGYXmG7UUyWEG+KCJusayIAQLywUjFAskDoKuxfqVnGF+M/mTMrEV8J64DaIdU+5x761FvgmT7M68tA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-3.0.0.tgz", + "integrity": "sha512-W1GRSCzn+xXKf5bMcPs/hg6Ga1bxPqb7owGfS+tvlBQfPe5Q2STcanRuKZrgU60v5uKrhXH5cgWwM+DLqvXZgQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "bare-http1": "^4.4.0", "bare-tcp": "^2.2.0", - "bare-tls": "^2.0.0" + "bare-tls": "^3.0.0" } }, "node_modules/bare-inspect": { @@ -1661,9 +1675,9 @@ } }, "node_modules/bare-inspector": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/bare-inspector/-/bare-inspector-4.0.3.tgz", - "integrity": "sha512-yYk6+7tTVKJKHOja5+QBn989ETv8oVsmwoH0nCM+QYC6w5MC03GC4aQNK+lipAZSz1EaAj42WZ8Wewjpy0L9TA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/bare-inspector/-/bare-inspector-6.0.1.tgz", + "integrity": "sha512-rL+aKJ74FhF+6iJS2cV3C8js8nGF37H05ldiyMojgFP/N5eYShZ/mhSbTcDh1yriW5jndYd6xWQU0E/CE14Tiw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1671,10 +1685,10 @@ "bare-http1": "^4.0.0", "bare-stream": "^2.0.0", "bare-url": "^2.0.0", - "bare-ws": "^2.0.0" + "bare-ws": "^3.0.0" }, "engines": { - "bare": ">=1.2.0" + "bare": ">=1.25.0" }, "peerDependencies": { "bare-tcp": "*" @@ -1901,9 +1915,9 @@ } }, "node_modules/bare-tcp": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.12.tgz", - "integrity": "sha512-x8Q1Iw93noY18hU0UlLpdS/AkgsAgD1TN2imSUT9aH2noZdoXo70qGJ8iEj5D+QwgT/1hFgk1gi8BoolYP9TUw==", + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.13.tgz", + "integrity": "sha512-4KQPgqYugvK6QxcSnVGbl87XslBebxmXlv7Glf4M9iwwoSCDKtYmC1t6zsMctTNhzKXbWCId7mB4R9qLWj3JMw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1916,9 +1930,9 @@ } }, "node_modules/bare-tls": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-2.2.3.tgz", - "integrity": "sha512-dPYBGEXtgLceRFMfGaF2/rqR86/xMxMyrM9ootO/gaRKL/z2uNHJs7aP7IOBtnJF8eUCt5qwMzbKfrKgDIxPLg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.3.tgz", + "integrity": "sha512-OYjHUoHB0B82Le1O0ERPPK8C7qm2U9ZMzbl7vWF6L3EP+8Y93Fyf4MmVBWyrdO6mEfkzubT/0M55NGS02zRDnQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1954,9 +1968,9 @@ } }, "node_modules/bare-url": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.2.tgz", - "integrity": "sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", + "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -1996,16 +2010,16 @@ } }, "node_modules/bare-ws": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bare-ws/-/bare-ws-2.1.0.tgz", - "integrity": "sha512-2gEWlPK9iyBchACdIY6oQXgmDz3KLrChdwrPgmU3IVXOFTnxTqSUT27WE/+izd4QojHj/SsqDkQiD2HDvuTdAA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-ws/-/bare-ws-3.0.0.tgz", + "integrity": "sha512-q2v0UIQ0cFQBXQMp+0FRaoSk1EoYgOhzO7yio0TqBV6Rkfot97mbBYxb1ssw5faVCisVaFxQYY8113SMLYQBow==", "dev": true, "license": "Apache-2.0", "dependencies": { "bare-crypto": "^1.2.0", "bare-events": "^2.3.1", "bare-http1": "^4.0.0", - "bare-https": "^2.0.0", + "bare-https": "^3.0.0", "bare-stream": "^2.1.2" }, "peerDependencies": { @@ -2054,13 +2068,6 @@ "node": ">= 0.8" } }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -2075,17 +2082,6 @@ "node": ">= 10.0.0" } }, - "node_modules/bfx-svc-boot-js": { - "name": "@bitfinex/bfx-svc-boot-js", - "version": "1.2.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-svc-boot-js.git#a70b37a392ccf0fa1013c3d74d5916b727eac6f4", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "lodash": "^4.18.1", - "yargs": "^17.2.1" - } - }, "node_modules/big-sparse-array": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/big-sparse-array/-/big-sparse-array-1.0.3.tgz", @@ -2122,15 +2118,15 @@ } }, "node_modules/blind-relay": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/blind-relay/-/blind-relay-1.5.0.tgz", - "integrity": "sha512-WwTaNv0Ea+L8PJq6KfQiwjuelyyDVDCV3Y8ym5cjoEBXjKK51oSy9Q3SHqSb1y2ddfz3yZZDvSg5xfN7ObayVw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/blind-relay/-/blind-relay-1.6.1.tgz", + "integrity": "sha512-38YmKCl/m9NcTkwueBbcGIt9Zx3IT/Q9Nsluu6C5Gur6PqfE0zWPhPwCzia1hri/g0ICYxrqkgLtEaoWD5WeSQ==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4", "bare-events": "^2.2.0", "bits-to-bytes": "^1.3.0", - "compact-encoding": "^2.12.0", + "compact-encoding": "^3.0.0", "compact-encoding-bitfield": "^1.0.0", "protomux": "^3.5.1", "sodium-universal": "^5.0.0", @@ -2138,12 +2134,12 @@ } }, "node_modules/bogon": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.2.0.tgz", - "integrity": "sha512-FqOBr/1VMzCOsoJd+fzNUMarUYki2+TKt07A2+xaulsNx4r53iJ7MV5k0jbqg7W2U0CsLqxCZOrFibdG6h6HSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.3.0.tgz", + "integrity": "sha512-04tu0G/v0f2HPuQlSfhO635WwHDBCaITJ490rhxH9ttUlgPqOirZVKV02YB6NvPf5VkmbqJCm3bnZwrPk/eDSg==", "license": "MIT", "dependencies": { - "compact-encoding": "^2.11.0", + "compact-encoding": "^3.0.0", "compact-encoding-net": "^1.2.0" } }, @@ -2159,15 +2155,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/brace-expansion/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/brittle": { "version": "3.18.0", "resolved": "https://registry.npmjs.org/brittle/-/brittle-3.18.0.tgz", @@ -2229,16 +2216,6 @@ "semver": "^7.0.0" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cacache": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", @@ -2269,6 +2246,13 @@ "node": ">= 10" } }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, "node_modules/cacache/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -2302,6 +2286,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/cacache/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2315,6 +2312,19 @@ "node": "*" } }, + "node_modules/cacache/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2463,30 +2473,30 @@ } }, "node_modules/compact-encoding": { - "version": "2.19.2", - "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.2.tgz", - "integrity": "sha512-/YjhHQE/5L4F7l5Bht69dRbP9RV6zoJPeowi8bMKQxNKe3Nh6hOY8pBGoVE9fz5GaWfEd8fWJ2aU9sB4KZuMYg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.0.1.tgz", + "integrity": "sha512-djstau5F2ipPDZhJLGeAfL1OpBxpJUgWjHt7HnWU+bXNYCgN8//NlWujaKmHBaqkAHe+lQ7xY5mjE+KkblAcng==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.3.0" } }, "node_modules/compact-encoding-bitfield": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/compact-encoding-bitfield/-/compact-encoding-bitfield-1.0.0.tgz", - "integrity": "sha512-3nMVKUg+PF72UHfainmCL8uKvyWfxsjqOtUY+HiMPGLPCTjnwzoKfFAMo1Ad7nwTPdjBqtGK5b3BOFTFW4EBTg==", - "license": "ISC", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/compact-encoding-bitfield/-/compact-encoding-bitfield-1.1.0.tgz", + "integrity": "sha512-F6wliSKHi50BsBStmnsRJAzJgYSIYzdfSe3M0oHj4uQ1RcctJK/NQVD7wF51bj/DBMne2i5gUxrGUFkNZQns5w==", + "license": "Apache-2.0", "dependencies": { - "compact-encoding": "^2.4.1" + "compact-encoding": "^3.0.0" } }, "node_modules/compact-encoding-net": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/compact-encoding-net/-/compact-encoding-net-1.2.0.tgz", - "integrity": "sha512-LVXpNpF7PGQeHRVVLGgYWzuVoYAaDZvKUsUxRioGfkotzvOh4AzoQF1HBH3zMNaSnx7gJXuUr3hkjnijaH/Eng==", - "license": "ISC", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/compact-encoding-net/-/compact-encoding-net-1.3.0.tgz", + "integrity": "sha512-HIYjL3aMiSENApR691aMLngXt6rkTHb9HUkEukcx3YwIZpoMUVXHXyjBzoo9kVwC7KakooBA1RRWb46Cu6qtAQ==", + "license": "Apache-2.0", "dependencies": { - "compact-encoding": "^2.4.1" + "compact-encoding": "^3.0.0" } }, "node_modules/concat-map": { @@ -2776,15 +2786,15 @@ } }, "node_modules/dht-rpc": { - "version": "6.26.4", - "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.26.4.tgz", - "integrity": "sha512-bMI625c13DXaiYN8vHEFmcM4CIauwK1SJgOMWAAqsXhsKXO8WCdp0fgYwTHH40cBB6J9MkDXPa2f79Vj5Ah1iA==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz", + "integrity": "sha512-NsfgRlFDQnkA2+H+hJXMPLmBOn/TSIIc0cRMV8q42M4xc1uAXWtpvIIQsONF5tYcYC6px0bslrUGideN1h4S4A==", "license": "MIT", "dependencies": { "adaptive-timeout": "^1.0.1", "b4a": "^1.6.1", "bare-events": "^2.2.0", - "compact-encoding": "^2.11.0", + "compact-encoding": "^3.0.0", "compact-encoding-net": "^1.2.0", "fast-fifo": "^1.1.0", "kademlia-routing-table": "^1.0.1", @@ -3353,6 +3363,13 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, + "node_modules/eslint-plugin-import/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -3436,6 +3453,13 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-n/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-n/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -3531,6 +3555,13 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -3654,6 +3685,13 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -3800,9 +3838,9 @@ "license": "MIT" }, "node_modules/fast-json-stringify": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", - "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", "funding": [ { "type": "github", @@ -3840,9 +3878,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -3945,9 +3983,9 @@ "license": "MIT" }, "node_modules/find-my-way": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", - "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4059,6 +4097,19 @@ "node": ">= 8" } }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs-native-extensions": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/fs-native-extensions/-/fs-native-extensions-1.5.0.tgz", @@ -4277,15 +4328,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -4490,19 +4532,23 @@ "optional": true }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy": { @@ -4655,16 +4701,16 @@ } }, "node_modules/hypercore": { - "version": "11.28.1", - "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.28.1.tgz", - "integrity": "sha512-d8jqUilG7Gri8wT+sRdkFTYRPMHGyvBz/NCYHf5rX7+LoivRNRxQm5nWChT7caiUKbcsDak6h7IiEQ5wbD1wCg==", + "version": "11.30.1", + "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.30.1.tgz", + "integrity": "sha512-pu4zcs4DsKZWhRZRXNIC3RW+I8mYOG0VWDEWklVlt+INCpEbJoGwgqQSK8iIq1rAdOuT/omnELQNGnqtNI57OA==", "license": "MIT", "dependencies": { "@hyperswarm/secret-stream": "^6.0.0", "b4a": "^1.1.0", "bare-events": "^2.2.0", "big-sparse-array": "^1.0.3", - "compact-encoding": "^2.11.0", + "compact-encoding": "^3.0.0", "fast-fifo": "^1.3.0", "flat-tree": "^1.9.0", "hypercore-crypto": "^3.2.1", @@ -4684,13 +4730,13 @@ } }, "node_modules/hypercore-crypto": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.6.1.tgz", - "integrity": "sha512-ltIz2uDwy9pO/ZGTvqcjzyBkvt6O4cVm4r/nNxh0GFs/RbQtqP/i4wCvLEdmU7ptgtnw7fI67WYD1aHPuv4OVA==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.7.0.tgz", + "integrity": "sha512-SWxobptQf2V/+ZLCCDRTXqFzGfTPIkNIuDyLKXpEMfcpJ1/ec94N9aWgylHcWOHmRt14ng/KR7z0BugebWHK9Q==", "license": "MIT", "dependencies": { "b4a": "^1.6.6", - "compact-encoding": "^2.15.0", + "compact-encoding": "^3.0.0", "sodium-universal": "^5.0.0" } }, @@ -4714,15 +4760,15 @@ } }, "node_modules/hypercore-storage": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.8.0.tgz", - "integrity": "sha512-s35HllFMYvrkSHvkIIklVFmCI/bRVNO7H3pi4mKvWl0XLyhGGGM+NtJ7fpsh82oPRCmL9XtMRO+pT6LiMAO8MA==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.9.0.tgz", + "integrity": "sha512-MhufmceSHdst+wwysLg5ygSpsB75PiN8U5E1q1SYkAePVHiYbb4dEvKv4d9IyZRrJTBlqcWUEYf7oVosAixi9g==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.7", "bare-fs": "^4.0.1", "bare-path": "^3.0.0", - "compact-encoding": "^2.16.0", + "compact-encoding": "^3.0.0", "device-file": "^2.1.2", "flat-tree": "^1.12.1", "hypercore-crypto": "^3.4.2", @@ -4735,9 +4781,9 @@ } }, "node_modules/hyperdht": { - "version": "6.31.0", - "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.31.0.tgz", - "integrity": "sha512-FIAZGOpYcbRqnWgfPfBbXs/7J6rjDfJqHh9AQWFmmsKkBmwGn/m2kreIKD4ZMIOaWbM5YTwj7aHvBw/CMHYg0Q==", + "version": "6.32.0", + "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.32.0.tgz", + "integrity": "sha512-Y/SibEN7wue0E8ydh8lx9IX7SOE9o00b6tufPA7hRxAafXsisWUBrW36fIHdyxg148T4Z3olrooOGZDZ89LYag==", "license": "MIT", "dependencies": { "@hyperswarm/secret-stream": "^6.6.2", @@ -4745,7 +4791,7 @@ "bare-events": "^2.2.0", "blind-relay": "^1.3.0", "bogon": "^1.0.0", - "compact-encoding": "^2.4.1", + "compact-encoding": "^3.0.0", "dht-rpc": "^6.15.1", "hypercore-crypto": "^3.3.0", "hypercore-id-encoding": "^1.2.0", @@ -4765,23 +4811,23 @@ } }, "node_modules/hyperdht-address": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hyperdht-address/-/hyperdht-address-1.0.1.tgz", - "integrity": "sha512-v817eJkhryWNtMGQHLxVo6jtfEeIV9k429fRUWUKkp1bm+3/rzH8r5BFef9VT7Jq4Tvgt0Gw11FV32CacsqMZA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hyperdht-address/-/hyperdht-address-1.1.1.tgz", + "integrity": "sha512-Mu/+7SW2cwvHxMXswa5mOrhrS5BJyntViAuB25k8d8wNtA8eAPs4LaIq6TwN1SS/KQY02h1r+gM8cQeN/k360w==", "license": "Apache-2.0", "dependencies": { - "compact-encoding": "^2.19.0", + "compact-encoding": "^3.0.0", "hyperschema": "^1.20.1" } }, "node_modules/hyperschema": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.20.1.tgz", - "integrity": "sha512-7gnvaTNUs8FqdrU3NNE6nzhOneGyDlUlpROu9YFlHA61fE8ppKKf2gLZjZjsx/enNt+VFN4ITxy8ijypfyWBLw==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.21.0.tgz", + "integrity": "sha512-lEnIbLTUQf7w6FU6X+R3yUJhBwCzF4ewRnAaYOrPdcVWe1rbOf94PzMiXb5pBKxroCXzlrPLxVujxAsTrrHc8g==", "license": "Apache-2.0", "dependencies": { "bare-fs": "^4.0.1", - "compact-encoding": "^2.19.0", + "compact-encoding": "^3.0.0", "generate-object-property": "^2.0.0", "generate-string": "^1.0.1" } @@ -4958,9 +5004,9 @@ } }, "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -5058,13 +5104,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -5694,16 +5740,12 @@ } }, "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": "20 || >=22" } }, "node_modules/make-dir": { @@ -5758,6 +5800,32 @@ "node": ">= 10" } }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5823,16 +5891,12 @@ } }, "node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, "node_modules/minipass-collect": { @@ -5848,6 +5912,19 @@ "node": ">= 8" } }, + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/minipass-fetch": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", @@ -5866,6 +5943,19 @@ "encoding": "^0.1.12" } }, + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/minipass-flush": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", @@ -5879,6 +5969,19 @@ "node": ">= 8" } }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", @@ -5892,6 +5995,19 @@ "node": ">=8" } }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", @@ -5905,6 +6021,19 @@ "node": ">=8" } }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/minizlib": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", @@ -5919,6 +6048,19 @@ "node": ">= 8" } }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -5989,9 +6131,9 @@ } }, "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -6095,6 +6237,13 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/node-gyp/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, "node_modules/node-gyp/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -6559,24 +6708,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/path-scurry/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -6848,26 +6979,26 @@ } }, "node_modules/protomux": { - "version": "3.10.3", - "resolved": "https://registry.npmjs.org/protomux/-/protomux-3.10.3.tgz", - "integrity": "sha512-cUwyeEK9WwNA1SsAIj8Is5DEM7CYNVljQSUxid6HzXWtSHfob61mReopz8eJewsAKMdhk4fqCT2G5jiHja4UxA==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/protomux/-/protomux-3.11.0.tgz", + "integrity": "sha512-Ze43XaNHbL6zQ/zJwVYvue7Aj8pDB1HNnISsrFhAur3291Y+Iu2fMhOpySD73J/32BoBIUYrhl07lJsBR/fUVA==", "license": "MIT", "dependencies": { "b4a": "^1.3.1", - "compact-encoding": "^2.5.1", + "compact-encoding": "^3.0.0", "queue-tick": "^1.0.0", "safety-catch": "^1.0.1", "unslab": "^1.3.0" } }, "node_modules/protomux-rpc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/protomux-rpc/-/protomux-rpc-1.9.0.tgz", - "integrity": "sha512-+nOXXIDKZL849F6adj3R1SEi7PfPmvS6Y9HAArVC/RpONepRFq/Ot1LHVA+vyDKQMxBd8xTCHfy2fc3tchE1gA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/protomux-rpc/-/protomux-rpc-1.10.0.tgz", + "integrity": "sha512-+m5It9gRlnE1GfKyUrEMilujV5FFn56qmSprsDgA7YHNDMqpoyiMJLjnoFsOMLMFNxq5B5l3GASuZm9ZpjreKg==", "license": "Apache-2.0", "dependencies": { "bits-to-bytes": "^1.0.0", - "compact-encoding": "^2.6.1", + "compact-encoding": "^3.0.0", "compact-encoding-bitfield": "^1.0.0", "protomux": "^3.7.0", "safety-catch": "^1.0.2" @@ -6992,35 +7123,6 @@ "integrity": "sha512-u7xCM93XqKEvPTP6xZp2ehttcAemKnh73oKNf1FvzuVCfpt6dILDt1Kxl1LeBjm2iNIeR49VGFhy4Iz3yOun+Q==", "license": "MIT" }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -7281,6 +7383,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -7325,12 +7433,12 @@ } }, "node_modules/rocksdb-native": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.15.0.tgz", - "integrity": "sha512-czYx0hOzKAy+HYmJROZm7thvJ1tHFWHNkGI3nomumXWzrJghJdrWo4MIhehGX4OTff0lnOCoB2zu6ekzIKY0rQ==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.15.1.tgz", + "integrity": "sha512-T9inGQj9pLNxnWeW2hRGGT9+FGGGFqHUSmTzrEClFQRCo4Jg7YWfIwLasWOZy65VPo9BrwCz5ZHFf7n54Kakzw==", "license": "Apache-2.0", "dependencies": { - "compact-encoding": "^2.15.0", + "compact-encoding": "^3.0.0", "ready-resource": "^1.0.0", "refcounter": "^1.0.0", "require-addon": "^1.0.2", @@ -7387,23 +7495,9 @@ } }, "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, "node_modules/safe-push-apply": { @@ -7952,6 +8046,19 @@ "node": ">= 8" } }, + "node_modules/ssri/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -8028,9 +8135,9 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -8076,6 +8183,26 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8256,9 +8383,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -8314,15 +8441,6 @@ "node": ">=18" } }, - "node_modules/tar/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/tar/node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", @@ -8366,14 +8484,14 @@ } }, "node_modules/tether-svc-test-helper": { + "name": "@tetherto/tether-svc-test-helper", "version": "1.0.0", - "resolved": "git+ssh://git@github.com/tetherto/tether-svc-test-helper.git#eee83b8f16aa6a6b0479fed5c881edf62544311f", + "resolved": "git+ssh://git@github.com/tetherto/tether-svc-test-helper.git#534728262da110ce1005962e1dcbded34ef77de9", "dev": true, "license": "Apache-2.0", "dependencies": { - "async": "3.2.6", - "bfx-svc-boot-js": "git+https://github.com/bitfinexcom/bfx-svc-boot-js.git", - "raw-body": "2.5.2" + "@bitfinex/bfx-svc-boot-js": "^1.2.0", + "async": "^3.2.6" }, "engines": { "node": ">=16" @@ -8663,16 +8781,6 @@ "integrity": "sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==", "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unslab": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/unslab/-/unslab-1.3.0.tgz", diff --git a/package.json b/package.json index ad92fd2..ee0b66f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { - "name": "miningos-app-node", - "version": "0.0.1", - "description": "MiningOS App Node", + "name": "@tetherto/miningos-app-node", + "version": "1.0.0", + "description": "MiningOS App Node service", + "main": "worker.js", "keywords": [ "tether" ], @@ -11,15 +12,27 @@ "type": "git", "url": "git+https://github.com/tetherto/miningos-app-node.git" }, - "engine": { - "node": ">=20.0" + "homepage": "https://github.com/tetherto/miningos-app-node#readme", + "bugs": { + "url": "https://github.com/tetherto/miningos-app-node/issues" + }, + "engines": { + "node": ">=20" + }, + "files": [ + "worker.js", + "workers/" + ], + "type": "commonjs", + "bin": { + "miningos-app-node": "worker.js" }, "scripts": { "lint": "standard", "lint:fix": "standard --fix", "test": "npm run lint && npm run test:unit && npm run test:integration", "test:unit": "NODE_ENV=test brittle tests/unit/**/*.test.js", - "test:integration": "NODE_ENV=test npx brittle tests/integration/*.test.js", + "test:integration": "NODE_ENV=test npx brittle tests/integration/api.security.test.js && NODE_ENV=test npx brittle tests/integration/api.test.js && NODE_ENV=test npx brittle tests/integration/ws.test.js", "test:api": "NODE_ENV=test npx brittle tests/integration/api.test.js", "test:ws": "NODE_ENV=test npx brittle tests/integration/ws.test.js", "test:watch": "brittle tests/unit/**/*.js --watch", diff --git a/tests/integration/api.security.test.js b/tests/integration/api.security.test.js new file mode 100644 index 0000000..6de8a60 --- /dev/null +++ b/tests/integration/api.security.test.js @@ -0,0 +1,581 @@ +'use strict' + +const test = require('brittle') +const fs = require('fs') +const { createWorker } = require('tether-svc-test-helper').worker +const { setTimeout: sleep } = require('timers/promises') +const HttpFacility = require('@bitfinex/bfx-facs-http') +const { ENDPOINTS } = require('../../workers/lib/constants') + +test('Api security', { timeout: 90000 }, async (main) => { + const baseDir = 'tests/integration' + let worker + let httpClient + const appNodePort = 5000 + const ip = '127.0.0.1' + const appNodeBaseUrl = `http://${ip}:${appNodePort}` + const readonlyUser = 'readonly@test' + const siteOperatorUser = 'siteoperator@test' + const admin1 = 'admin1@test.test' + const admin2 = 'admin2@test.test' + const newCreatedUser = 'admin@test.test' + let superadminUser + const encoding = 'json' + const tokenExpiredUser = 'tokenexpire@test' + const invalidToken = 'invalid-token' + + const cleanIntegrationArtifacts = () => { + for (const dir of ['store', 'status', 'config', 'db']) { + fs.rmSync(`./${baseDir}/${dir}`, { recursive: true, force: true }) + } + } + + main.teardown(async () => { + await httpClient.stop() + await worker.stop() + // wait for worker to stop + await sleep(2000) + cleanIntegrationArtifacts() + }) + + const createConfig = () => { + if (!fs.existsSync(`./${baseDir}/config/facs`)) { + if (!fs.existsSync(`./${baseDir}/config`)) fs.mkdirSync(`./${baseDir}/config`) + fs.mkdirSync(`./${baseDir}/config/facs`) + } + if (!fs.existsSync(`./${baseDir}/db`)) fs.mkdirSync(`./${baseDir}/db`) + + const commonConf = { dir_log: 'logs', debug: 0, orks: { 'cluster-1': { rpcPublicKey: '' } }, cacheTiming: {}, featureConfig: {} } + const netConf = { r0: {} } + const httpdConf = { h0: {} } + const httpdOauthConf = { + h0: { method: 'google', credentials: { client: { id: 'i', secret: 's' } }, users: [{ email: readonlyUser }, { email: tokenExpiredUser }, { email: siteOperatorUser, write: true }] }, + h1: { method: 'microsoft', credentials: { client: { id: 'i', secret: 's' }, tenant: 'test-tenant' }, users: [] } + } + const authConf = require('../../config/facs/auth.config.json') + superadminUser = authConf.a0.superAdmin + + fs.writeFileSync(`./${baseDir}/config/common.json`, JSON.stringify(commonConf)) + fs.writeFileSync(`./${baseDir}/config/facs/net.config.json`, JSON.stringify(netConf)) + fs.writeFileSync(`./${baseDir}/config/facs/httpd.config.json`, JSON.stringify(httpdConf)) + fs.writeFileSync(`./${baseDir}/config/facs/httpd-oauth2.config.json`, JSON.stringify(httpdOauthConf)) + fs.writeFileSync(`./${baseDir}/config/facs/auth.config.json`, JSON.stringify(authConf)) + } + + const startWorker = async () => { + worker = createWorker({ + env: 'test', + wtype: 'wrk-node-http-test', + rack: 'test-rack', + tmpdir: baseDir, + storeDir: 'test-store', + serviceRoot: `${process.cwd()}/${baseDir}`, + port: appNodePort + }) + + await worker.start() + worker.worker.net_r0.jRequest = () => ({}) + } + + const createHttpClient = async () => { + httpClient = new HttpFacility({}, { ns: 'c0', timeout: 30000, debug: false }, { env: 'test' }) + await httpClient.start() + } + + const getTestToken = async (email) => { + worker.worker.authLib._auth.addHandlers({ + google: () => { return { email } } + }) + const token = await worker.worker.auth_a0.authCallbackHandler('google', { ip }) + return token + } + + const createUser = async (email, role, token) => { + if (!token) token = await getTestToken(superadminUser) + + await httpClient.post(`${appNodeBaseUrl}${ENDPOINTS.USERS}`, + { + headers: { Authorization: `Bearer ${token}` }, + body: { data: { email, role } }, + encoding + }) + } + + const testMissingAuthToken = async (httpClient, method, api, options = {}) => { + try { + await httpClient[method](api, { ...options, encoding: options.encoding || 'json' }) + throw new Error('Expected error for missing auth token but request succeeded') + } catch (e) { + if (!e.response || !e.response.message || !e.response.message.includes('ERR_AUTH_FAIL')) { + throw new Error(`Expected ERR_AUTH_FAIL but got: ${e.message || e}`) + } + return true + } + } + + const testInvalidAuthToken = async (httpClient, method, api, invalidToken, options = {}) => { + const headers = { Authorization: `Bearer ${invalidToken}` } + try { + await httpClient[method](api, { ...options, headers, encoding: options.encoding || 'json' }) + throw new Error('Expected error for invalid auth token but request succeeded') + } catch (e) { + if (!e.response || !e.response.message || !e.response.message.includes('ERR_AUTH_FAIL')) { + throw new Error(`Expected ERR_AUTH_FAIL but got: ${e.message || e}`) + } + return true + } + } + + const testValidAuthToken = async (httpClient, method, api, userEmail, options = {}) => { + const token = await getTestToken(userEmail) + const headers = { Authorization: `Bearer ${token}` } + try { + await httpClient[method](api, { ...options, headers, encoding: options.encoding || 'json' }) + return true + } catch (e) { + throw new Error(`Expected success but got error: ${e.message || e}`) + } + } + + const testInvalidPermissions = async (httpClient, method, api, userEmail, expectedError, options = {}) => { + const token = await getTestToken(userEmail) + const headers = { Authorization: `Bearer ${token}` } + try { + await httpClient[method](api, { ...options, headers, encoding: options.encoding || 'json' }) + throw new Error('Expected error for invalid permissions but request succeeded') + } catch (e) { + if (!e.response || !e.response.message || !e.response.message.includes(expectedError)) { + throw new Error(`Expected ${expectedError} but got: ${e.message || e}`) + } + return true + } + } + + const createAuthHeaders = async (userEmail) => { + const token = await getTestToken(userEmail) + return { Authorization: `Bearer ${token}` } + } + + const createEndpointSecurityTests = (httpClient, method, api, invalidToken, options = {}, userEmail = 'readonly@test', encoding = 'json') => { + const requestOptions = { ...options, encoding } + + return [ + { + name: 'api should fail for missing auth token', + test: () => testMissingAuthToken(httpClient, method, api, requestOptions) + }, + { + name: 'api should fail for invalid auth token', + test: () => testInvalidAuthToken(httpClient, method, api, invalidToken, requestOptions) + }, + { + name: 'api should succeed for valid auth token', + test: () => testValidAuthToken(httpClient, method, api, userEmail, requestOptions) + } + ] + } + + const createEndpointSecurityWithPermissionsTests = (httpClient, method, api, invalidToken, permissionUser, permissionError, validUser, options = {}, encoding = 'json') => { + return [ + { + name: 'api should fail for missing auth token', + test: () => testMissingAuthToken(httpClient, method, api, { ...options, encoding }) + }, + { + name: 'api should fail for invalid auth token', + test: () => testInvalidAuthToken(httpClient, method, api, invalidToken, { ...options, encoding }) + }, + { + name: 'api should fail for invalid permissions', + test: () => testInvalidPermissions(httpClient, method, api, permissionUser, permissionError, { ...options, encoding }) + }, + { + name: 'api should succeed for valid auth token', + test: () => testValidAuthToken(httpClient, method, api, validUser, { ...options, encoding }) + } + ] + } + + const runTestCases = async (n, testCases) => { + for (const testCase of testCases) { + await n.test(testCase.name, async (t) => { + try { + await testCase.test() + t.pass(testCase.name) + } catch (e) { + t.fail(e.message) + } + }) + } + } + + const testEndpointSecurity = async (n, httpClient, method, api, invalidToken, options = {}, userEmail = 'readonly@test', encoding = 'json') => { + const tests = createEndpointSecurityTests(httpClient, method, api, invalidToken, options, userEmail, encoding) + await runTestCases(n, tests) + } + + const testGetEndpointSecurity = async (n, httpClient, api, invalidToken, userEmail = 'readonly@test', encoding = 'json') => { + await testEndpointSecurity(n, httpClient, 'get', api, invalidToken, {}, userEmail, encoding) + } + + const testPostEndpointSecurity = async (n, httpClient, api, invalidToken, body, userEmail = 'readonly@test', encoding = 'json') => { + await testEndpointSecurity(n, httpClient, 'post', api, invalidToken, { body }, userEmail, encoding) + } + + const testPutEndpointSecurity = async (n, httpClient, api, invalidToken, body, userEmail = 'readonly@test', encoding = 'json') => { + await testEndpointSecurity(n, httpClient, 'put', api, invalidToken, { body }, userEmail, encoding) + } + + const testDeleteEndpointSecurity = async (n, httpClient, api, invalidToken, options = {}, userEmail = 'readonly@test', encoding = 'json') => { + await testEndpointSecurity(n, httpClient, 'delete', api, invalidToken, options, userEmail, encoding) + } + + const testPostEndpointSecurityWithPermissions = async (n, httpClient, api, invalidToken, body, permissionUser, permissionError, validUser, encoding = 'json') => { + const tests = createEndpointSecurityWithPermissionsTests(httpClient, 'post', api, invalidToken, permissionUser, permissionError, validUser, { body }, encoding) + await runTestCases(n, tests) + } + + const testPutEndpointSecurityWithPermissions = async (n, httpClient, api, invalidToken, body, permissionUser, permissionError, validUser, encoding = 'json') => { + const tests = createEndpointSecurityWithPermissionsTests(httpClient, 'put', api, invalidToken, permissionUser, permissionError, validUser, { body }, encoding) + await runTestCases(n, tests) + } + + const testDeleteEndpointSecurityWithPermissions = async (n, httpClient, api, invalidToken, permissionUser, permissionError, validUser, options = {}, encoding = 'json') => { + const tests = createEndpointSecurityWithPermissionsTests(httpClient, 'delete', api, invalidToken, permissionUser, permissionError, validUser, options, encoding) + await runTestCases(n, tests) + } + + const testEndpointWithAuth = async (t, httpClient, method, api, userEmail, options = {}) => { + const headers = await createAuthHeaders(userEmail) + try { + await httpClient[method](api, { ...options, headers, encoding: options.encoding || 'json' }) + t.pass() + return true + } catch (e) { + t.fail(`Expected success but got error: ${e.message || e}`) + return false + } + } + + const testEndpointWithAuthAndError = async (t, httpClient, method, api, userEmail, expectedError, options = {}) => { + const headers = await createAuthHeaders(userEmail) + try { + await httpClient[method](api, { ...options, headers, encoding: options.encoding || 'json' }) + t.fail('Expected error but request succeeded') + return false + } catch (e) { + const hasError = e.response && e.response.message && e.response.message.includes(expectedError) + if (!hasError) { + t.fail(`Expected ${expectedError} but got: ${e.message || e}`) + return false + } + t.pass() + return true + } + } + + cleanIntegrationArtifacts() + createConfig() + await startWorker() + await createHttpClient() + await sleep(2000) + await createUser(admin1, 'admin') + await createUser(admin2, 'admin') + + await main.test('Api: list-things', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.LIST_THINGS}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: list-racks', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.LIST_RACKS}?type=miner` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: post thing-comment', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.THING_COMMENT}` + const body = { thingId: 1, rackId: 1, comment: 'test' } + await testPostEndpointSecurity(n, httpClient, api, invalidToken, body, siteOperatorUser, encoding) + }) + + await main.test('Api: put thing-comment', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.THING_COMMENT}` + const body = { thingId: 1, rackId: 1, comment: 'test' } + await testPutEndpointSecurity(n, httpClient, api, invalidToken, body, siteOperatorUser, encoding) + }) + + await main.test('Api: delete thing-comment', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.THING_COMMENT}` + const body = { thingId: 1, rackId: 1, id: 1 } + await testDeleteEndpointSecurity(n, httpClient, api, invalidToken, { body }, siteOperatorUser, encoding) + }) + + await main.test('Api: get settings', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.SETTINGS}?rackId=1` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: put settings', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.SETTINGS}` + const body = { rackId: 1, entries: { val: 1 } } + await testPutEndpointSecurity(n, httpClient, api, invalidToken, body, siteOperatorUser, encoding) + }) + + await main.test('Api: get worker-config', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.WORKER_CONFIG}?type=miner` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get thing-config', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.THING_CONFIG}?type=miner&requestType=miner` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get tail-log', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.TAIL_LOG}?key=stat-5m` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get tail-log/multi', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.TAIL_LOG_MULTI}?keys=[]` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get tail-log/range-aggr', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.TAIL_LOG_RANGE_AGGR}?keys=[{"type":1,"startDate":1,"endDate":1}]` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get history-log', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.HISTORY_LOG}?logType=alerts` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get global/data', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.GLOBAL_DATA}?type=features` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: post global/data', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.GLOBAL_DATA}?type=features` + const body = { data: { site: 'A' } } + await testPostEndpointSecurityWithPermissions(n, httpClient, api, invalidToken, body, siteOperatorUser, 'ERR_AUTH_FAIL_NO_PERMS', admin1, encoding) + }) + + await main.test('Api: get user/settings', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.USER_SETTINGS}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: post user/settings', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.USER_SETTINGS}` + const body = { settings: { setting1: 'val-1' } } + await testPostEndpointSecurity(n, httpClient, api, invalidToken, body, siteOperatorUser, encoding) + }) + + await main.test('Api: get featureConfig', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.FEATURE_CONFIG}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get features', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.FEATURES}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: post features', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.FEATURES}` + const body = { data: { key: 'val' } } + await testPostEndpointSecurityWithPermissions(n, httpClient, api, invalidToken, body, siteOperatorUser, 'ERR_AUTH_FAIL_NO_PERMS', admin1, encoding) + }) + + await main.test('Api: get global-config', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.GLOBAL_CONFIG}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: post global-config', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.GLOBAL_CONFIG}` + const body = { data: { isAutoSleepAllowed: false } } + await testPostEndpointSecurityWithPermissions(n, httpClient, api, invalidToken, body, siteOperatorUser, 'ERR_AUTH_FAIL_NO_PERMS', admin1, encoding) + }) + + await main.test('Api: get site', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.SITE}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get permissions', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.PERMISSIONS}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get userinfo', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.USERINFO}` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get ext-data', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.EXT_DATA}?type=miner` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get actions', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.ACTIONS}?queries=[]` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get actions/batch', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.ACTIONS_BATCH}?ids=1,2` + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: get actions/:type/:id', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.ACTIONS_SINGLE}`.replace(':type', 'done').replace(':id', 1) + await testGetEndpointSecurity(n, httpClient, api, invalidToken, readonlyUser, encoding) + }) + + await main.test('Api: post actions/voting', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.ACTIONS_VOTING}` + const body = { query: {}, action: '', params: [] } + await testPostEndpointSecurity(n, httpClient, api, invalidToken, body, siteOperatorUser, encoding) + }) + + await main.test('Api: post actions/voting/batch', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.ACTIONS_VOTING_BATCH}` + const body = { batchActionsPayload: [], batchActionUID: '' } + await testPostEndpointSecurityWithPermissions(n, httpClient, api, invalidToken, body, readonlyUser, 'ERR_WRITE_PERM_REQUIRED', siteOperatorUser, encoding) + }) + + await main.test('Api: put actions/voting/:id/vote', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.ACTIONS_VOTE}`.replace(':id', 1) + const body = { approve: false } + await testPutEndpointSecurityWithPermissions(n, httpClient, api, invalidToken, body, readonlyUser, 'ERR_WRITE_PERM_REQUIRED', siteOperatorUser, encoding) + }) + + await main.test('Api: delete actions/voting/cancel', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.ACTIONS_CANCEL}?ids=1` + await testDeleteEndpointSecurityWithPermissions(n, httpClient, api, invalidToken, readonlyUser, 'ERR_WRITE_PERM_REQUIRED', siteOperatorUser, { body: {} }, encoding) + }) + + await main.test('Api: post users', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.USERS}` + + await n.test('api should fail due to invalid permissions', async (t) => { + await testEndpointWithAuthAndError(t, httpClient, 'post', api, readonlyUser, 'ERR_AUTH_FAIL_NO_PERMS', { + body: { data: { email: 'dev@test.test', role: 'dev' } }, + encoding + }) + }) + + await n.test('api should succeed for valid permissions (superadmin)', async (t) => { + await testEndpointWithAuth(t, httpClient, 'post', api, superadminUser, { + body: { data: { email: newCreatedUser, role: 'admin' } }, + encoding + }) + }) + + await n.test('api should succeed for valid permissions (admin)', async (t) => { + await testEndpointWithAuth(t, httpClient, 'post', api, newCreatedUser, { + body: { data: { email: 'dev@test.test', role: 'read_only_user' } }, + encoding + }) + }) + }) + + await main.test('Api: get users', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.USERS}` + + await n.test('api should fail due to invalid permissions', async (t) => { + await testEndpointWithAuthAndError(t, httpClient, 'get', api, readonlyUser, 'ERR_AUTH_FAIL_NO_PERMS', { encoding }) + }) + + await n.test('api should succeed for valid permissions', async (t) => { + await testEndpointWithAuth(t, httpClient, 'get', api, superadminUser, { encoding }) + }) + + await n.test('users list should not contain superadmin user data', async (t) => { + const headers = await createAuthHeaders(superadminUser) + const { body: data } = await httpClient.get(api, { headers, encoding }) + t.is(data.users.length > 1, true) + data.users.forEach(user => { + if (user.role === 'superadmin') t.fail() + }) + }) + + await n.test('admin user should not access other admins or superadmin user data', async (t) => { + const headers = await createAuthHeaders(admin1) + const { body: data } = await httpClient.get(api, { headers, encoding }) + t.is(data.users.length > 1, true) + data.users.forEach(user => { + if (user.role === 'superadmin' || user.role === 'admin') { + t.fail() + } + }) + }) + }) + + await main.test('Api: put users', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.USERS}` + + await n.test('api should fail due to invalid permissions', async (t) => { + await testEndpointWithAuthAndError(t, httpClient, 'put', api, readonlyUser, 'ERR_AUTH_FAIL_NO_PERMS', { + body: { data: { id: 8, email: 'dev@test.test', role: 'admin' } }, + encoding + }) + }) + + await n.test('api should succeed for valid permissions', async (t) => { + await testEndpointWithAuth(t, httpClient, 'put', api, superadminUser, { + body: { data: { id: 2, email: readonlyUser, role: 'admin' } }, + encoding + }) + }) + + await n.test('api should fail for missing admin permissions', async (t) => { + await testEndpointWithAuthAndError(t, httpClient, 'put', api, newCreatedUser, 'ERR_AUTH_FAIL_NO_PERMS', { + body: { data: { id: 8, email: 'dev@test.test', role: 'admin' } }, + encoding + }) + }) + }) + + await main.test('Api: post users/delete', async (n) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.USERS_DELETE}` + + await n.test('api should fail due to invalid permissions', async (t) => { + await testEndpointWithAuthAndError(t, httpClient, 'post', api, readonlyUser, 'ERR_AUTH_FAIL_NO_PERMS', { + body: { data: { id: 5 } }, + encoding + }) + }) + + await n.test('api should succeed for valid permissions', async (t) => { + await testEndpointWithAuth(t, httpClient, 'post', api, superadminUser, { + body: { data: { id: 5 } }, + encoding + }) + }) + + await n.test('api should fail for missing permissions', async (t) => { + await testEndpointWithAuthAndError(t, httpClient, 'post', api, siteOperatorUser, 'ERR_AUTH_FAIL_NO_PERMS', { + body: { data: { id: 2 } }, + encoding + }) + }) + }) + + await main.test('Token expiration: api should fail due to token expiration', async (t) => { + const api = `${appNodeBaseUrl}${ENDPOINTS.LIST_RACKS}?type=miner` + worker.worker.auth_a0.conf.ttl = 5 + const token = await getTestToken(tokenExpiredUser) + const headers = { Authorization: `Bearer ${token}` } + await sleep(6000) + try { + await httpClient.get(api, { headers, encoding }) + t.fail() + } catch (e) { + t.is(e.response.message.includes('ERR_AUTH_FAIL'), true) + } + }) +}) diff --git a/tests/unit/routes/ws.routes.test.js b/tests/unit/routes/ws.routes.test.js index 4012ee5..4478b8f 100644 --- a/tests/unit/routes/ws.routes.test.js +++ b/tests/unit/routes/ws.routes.test.js @@ -107,7 +107,7 @@ test('ws routes - handler adds client to wsClients', async (t) => { send: function () {} } - await wsRoute.handler(mockSocket) + await wsRoute.handler(mockSocket, {}) t.ok(mockCtx.wsClients.has(mockSocket), 'should add socket to wsClients') } diff --git a/workers/lib/server/routes/ws.routes.js b/workers/lib/server/routes/ws.routes.js index 1544d89..d57be99 100644 --- a/workers/lib/server/routes/ws.routes.js +++ b/workers/lib/server/routes/ws.routes.js @@ -10,7 +10,7 @@ module.exports = (ctx) => [{ onRequest: async (req, rep) => { await authCheck(ctx, req, rep, req.query.token) }, - handler: async (socket) => { + handler: async (socket, _request) => { socket.subscriptions = new Set() ctx.wsClients.add(socket) From 77c7f8edd31d332b4e6fa10ad2713c5d7b425e1c Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 14 May 2026 08:17:05 +0300 Subject: [PATCH 45/63] feat: adopt JWT-based svc-facs-auth (#53) --- config/facs/auth.config.json.example | 3 ++- tests/unit/lib/auth.test.js | 25 +++++++------------------ workers/lib/auth.js | 10 ++++++++-- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/config/facs/auth.config.json.example b/config/facs/auth.config.json.example index 3e72947..2e4af13 100644 --- a/config/facs/auth.config.json.example +++ b/config/facs/auth.config.json.example @@ -1,7 +1,8 @@ { "a0": { "superAdmin": "test@localhost", - "ttl": 86400, + "ttl": 900, + "jwtSecret": "REPLACE_WITH_LONG_RANDOM_HEX_SECRET", "saltRounds": 10, "superAdminPerms": [ "miner:rw", diff --git a/tests/unit/lib/auth.test.js b/tests/unit/lib/auth.test.js index 29b296f..8ed56c4 100644 --- a/tests/unit/lib/auth.test.js +++ b/tests/unit/lib/auth.test.js @@ -233,10 +233,7 @@ test('AuthLib - getTokenPerms with super admin', async (t) => { test('AuthLib - getTokenPerms with regular user', async (t) => { const mockAuth = { getTokenPerms: function (token) { - return { superadmin: false, perms: ['actions:r', 'miner:r'] } - }, - tokenHasPerms: async (token, perm) => { - return perm === 'actions:w' + return { superadmin: false, perms: ['actions:rw', 'miner:r'] } }, conf: { superAdminPerms: [] @@ -264,7 +261,6 @@ test('AuthLib - tokenHasPerms with super admin', async (t) => { getTokenPerms: function () { return { superadmin: true, perms: [] } }, - tokenHasPerms: async () => false, conf: { superAdminPerms: [] } @@ -276,7 +272,7 @@ test('AuthLib - tokenHasPerms with super admin', async (t) => { auth: mockAuth }) - const result = await authLib.tokenHasPerms('token', true, ['perm1', 'perm2']) + const result = await authLib.tokenHasPerms('token', true, ['perm1:r', 'perm2:r']) t.is(result, true, 'should return true for super admin') @@ -288,7 +284,6 @@ test('AuthLib - tokenHasPerms without write permission', async (t) => { getTokenPerms: function () { return { superadmin: false, perms: [] } }, - tokenHasPerms: async () => false, conf: { superAdminPerms: [] } @@ -300,7 +295,7 @@ test('AuthLib - tokenHasPerms without write permission', async (t) => { auth: mockAuth }) - const result = await authLib.tokenHasPerms('token', true, ['perm1']) + const result = await authLib.tokenHasPerms('token', true, ['perm1:r']) t.is(result, false, 'should return false when write required but not available') @@ -310,10 +305,7 @@ test('AuthLib - tokenHasPerms without write permission', async (t) => { test('AuthLib - tokenHasPerms with matchAll=true', async (t) => { const mockAuth = { getTokenPerms: function () { - return { superadmin: false, perms: [] } - }, - tokenHasPerms: async (token, perm) => { - return perm === 'perm1' + return { superadmin: false, perms: ['perm1:r'] } }, conf: { superAdminPerms: [] @@ -326,7 +318,7 @@ test('AuthLib - tokenHasPerms with matchAll=true', async (t) => { auth: mockAuth }) - const result = await authLib.tokenHasPerms('token', false, ['perm1', 'perm2'], true) + const result = await authLib.tokenHasPerms('token', false, ['perm1:r', 'perm2:r'], true) t.is(result, false, 'should return false when matchAll and not all perms match') @@ -336,10 +328,7 @@ test('AuthLib - tokenHasPerms with matchAll=true', async (t) => { test('AuthLib - tokenHasPerms with matchAll=false', async (t) => { const mockAuth = { getTokenPerms: function () { - return { superadmin: false, perms: [] } - }, - tokenHasPerms: async (token, perm) => { - return perm === 'perm1' + return { superadmin: false, perms: ['perm1:r'] } }, conf: { superAdminPerms: [] @@ -352,7 +341,7 @@ test('AuthLib - tokenHasPerms with matchAll=false', async (t) => { auth: mockAuth }) - const result = await authLib.tokenHasPerms('token', false, ['perm1', 'perm2'], false) + const result = await authLib.tokenHasPerms('token', false, ['perm1:r', 'perm2:r'], false) t.is(result, true, 'should return true when matchAll=false and at least one perm matches') diff --git a/workers/lib/auth.js b/workers/lib/auth.js index 90aaa37..5953ac8 100644 --- a/workers/lib/auth.js +++ b/workers/lib/auth.js @@ -9,6 +9,12 @@ class AuthLib { this._auth = auth } + _permsMatch (perms, perm) { + const [key, required] = perm.split(':') + const av = perms.find(p => p.startsWith(`${key}:`))?.split(':')[1] ?? '' + return [...required].every(c => av.includes(c)) + } + async migrateUsers (httpdAuth) { const users = await this._auth.listUsers() if (users.length > 1) { @@ -65,7 +71,7 @@ class AuthLib { async getTokenPerms (token) { const { superadmin: superAdmin, perms = [] } = this._auth.getTokenPerms(token) - const write = superAdmin || (await this._auth.tokenHasPerms(token, 'actions:w')) + const write = superAdmin || this._permsMatch(perms, 'actions:w') const applicablePerms = superAdmin ? (this._auth.conf.superAdminPerms ?? []) : perms const caps = applicablePerms.map(perm => perm.split(':')[0]) @@ -82,7 +88,7 @@ class AuthLib { return false } - const resolved = await Promise.all(requestedPerms.map(perm => this._auth.tokenHasPerms(token, perm))) + const resolved = requestedPerms.map(perm => this._permsMatch(perms.permissions, perm)) return matchAll ? resolved.every(res => res) From b0177fe3fa1cd1aec5201f87e77ce3024724f558 Mon Sep 17 00:00:00 2001 From: tekwani Date: Mon, 18 May 2026 10:15:53 +0530 Subject: [PATCH 46/63] feat: forecast endpoints (#80) --- package-lock.json | 58 ++++++++++--------- package.json | 2 +- tests/integration/api.security.test.js | 2 +- tests/integration/api.test.js | 4 +- tests/integration/ws.test.js | 2 +- .../handlers/coolingSystem.handlers.test.js | 2 +- tests/unit/lib/users.test.js | 6 +- ....test.js => cooling.system.routes.test.js} | 12 ++-- workers/lib/auth.js | 2 +- workers/lib/constants.js | 13 ++++- ...handlers.js => cooling.system.handlers.js} | 0 .../lib/server/handlers/energy.handlers.js | 27 +++++++++ ....handlers.js => energy.system.handlers.js} | 0 workers/lib/server/handlers/users.handlers.js | 3 +- workers/lib/server/index.js | 8 ++- ...tem.routes.js => cooling.system.routes.js} | 2 +- workers/lib/server/routes/energy.routes.js | 29 ++++++++++ ...stem.routes.js => energy.system.routes.js} | 2 +- workers/lib/server/schemas/energy.schemas.js | 17 ++++++ workers/lib/users.js | 9 ++- 20 files changed, 149 insertions(+), 51 deletions(-) rename tests/unit/routes/{coolingSystem.routes.test.js => cooling.system.routes.test.js} (93%) rename workers/lib/server/handlers/{coolingSystem.handlers.js => cooling.system.handlers.js} (100%) create mode 100644 workers/lib/server/handlers/energy.handlers.js rename workers/lib/server/handlers/{energySystem.handlers.js => energy.system.handlers.js} (100%) rename workers/lib/server/routes/{coolingSystem.routes.js => cooling.system.routes.js} (93%) create mode 100644 workers/lib/server/routes/energy.routes.js rename workers/lib/server/routes/{energySystem.routes.js => energy.system.routes.js} (91%) create mode 100644 workers/lib/server/schemas/energy.schemas.js diff --git a/package-lock.json b/package-lock.json index 572274f..52dab65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@bitfinex/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", "@fastify/websocket": "11.2.0", "@tetherto/hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git#v1.0.0", - "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#v1.0.0", + "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#pull/20/head", "@tetherto/svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git#v1.0.0", "@tetherto/svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git#v1.0.0", "@tetherto/tether-wrk-base": "git+https://github.com/tetherto/tether-wrk-base.git#v1.0.0", @@ -63,7 +63,7 @@ }, "node_modules/@bitfinex/bfx-facs-http": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-http.git#46cd482878de7ab227f2b1c93bf070c68ca45e38", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-http.git#a87b066f90df3408237687e2cdeb130eb327cbdb", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", @@ -86,7 +86,7 @@ }, "node_modules/@bitfinex/bfx-facs-interval": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-interval.git#c60da6077e7897dfdc93989dcec81ef96feb0b5e", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-interval.git#a8fdaaceab147b4e9ff0d528d5bef2e33cde0e48", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", @@ -876,7 +876,7 @@ }, "node_modules/@tetherto/svc-facs-auth": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/tetherto/svc-facs-auth.git#0f0bb221a90174de3cdde87d63f94a44ff42e3a2", + "resolved": "git+ssh://git@github.com/tetherto/svc-facs-auth.git#b182b8b84db238f821be1d2856fd66e5683b0d9b", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-base": "^1.1.0", @@ -1563,9 +1563,9 @@ } }, "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", + "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -1930,9 +1930,9 @@ } }, "node_modules/bare-tls": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.3.tgz", - "integrity": "sha512-OYjHUoHB0B82Le1O0ERPPK8C7qm2U9ZMzbl7vWF6L3EP+8Y93Fyf4MmVBWyrdO6mEfkzubT/0M55NGS02zRDnQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.4.tgz", + "integrity": "sha512-0zmlDYkHjsU3h/I3Z69QZetBZibMUlcLI+OtHhQHeso/73si7/wN58EslxmG3SRx/b5Vx2kzqexlEBMDRvFveg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2144,9 +2144,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -7616,9 +7616,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7915,9 +7915,9 @@ } }, "node_modules/socks": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", - "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "license": "MIT", "optional": true, "dependencies": { @@ -8514,17 +8514,23 @@ "license": "MIT" }, "node_modules/thread-stream": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", - "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.1.0.tgz", + "integrity": "sha512-Bw6h2iBDt16v6iHLChBIoVYU8CBo9GPsW8TG7h1hRVhqKhIkH6N8qkxNSmiOZTKsCLPbtWG4ViWLkU6KeKXpig==", "license": "MIT", "dependencies": { - "real-require": "^0.2.0" + "real-require": "^1.0.0" }, "engines": { "node": ">=20" } }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/time-ordered-set": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/time-ordered-set/-/time-ordered-set-2.0.1.tgz", @@ -9028,9 +9034,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index ee0b66f..9f04ffd 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@bitfinex/bfx-facs-lru": "git+https://github.com/bitfinexcom/bfx-facs-lru.git", "@bitfinex/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", "@tetherto/hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git#v1.0.0", - "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#v1.0.0", + "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#v1.1.1", "@tetherto/svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git#v1.0.0", "@tetherto/svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git#v1.0.0", "@tetherto/tether-wrk-base": "git+https://github.com/tetherto/tether-wrk-base.git#v1.0.0", diff --git a/tests/integration/api.security.test.js b/tests/integration/api.security.test.js index 6de8a60..98e2ae7 100644 --- a/tests/integration/api.security.test.js +++ b/tests/integration/api.security.test.js @@ -86,7 +86,7 @@ test('Api security', { timeout: 90000 }, async (main) => { worker.worker.authLib._auth.addHandlers({ google: () => { return { email } } }) - const token = await worker.worker.auth_a0.authCallbackHandler('google', { ip }) + const token = await worker.worker.auth_a0.authCallbackHandler('google', { socket: { remoteAddress: ip } }) return token } diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js index a0bfb03..e4e9603 100644 --- a/tests/integration/api.test.js +++ b/tests/integration/api.test.js @@ -101,7 +101,7 @@ test('Api', { timeout: 90000 }, async (main) => { worker.worker.authLib._auth.addHandlers({ google: () => { return { email } } }) - const token = await worker.worker.auth_a0.authCallbackHandler('google', { ip }) + const token = await worker.worker.auth_a0.authCallbackHandler('google', { socket: { remoteAddress: ip } }) return token } @@ -109,7 +109,7 @@ test('Api', { timeout: 90000 }, async (main) => { worker.worker.authLib._auth.addHandlers({ microsoft: () => { return { email } } }) - const token = await worker.worker.auth_a0.authCallbackHandler('microsoft', { ip }) + const token = await worker.worker.auth_a0.authCallbackHandler('microsoft', { socket: { remoteAddress: ip } }) return token } diff --git a/tests/integration/ws.test.js b/tests/integration/ws.test.js index 41f89cd..b2dcfd1 100644 --- a/tests/integration/ws.test.js +++ b/tests/integration/ws.test.js @@ -153,7 +153,7 @@ test('WebSocket endpoint', { timeout: 90000 }, async (main) => { worker.worker.authLib._auth.addHandlers({ google: () => { return { email } } }) - const token = await worker.worker.auth_a0.authCallbackHandler('google', { ip }) + const token = await worker.worker.auth_a0.authCallbackHandler('google', { socket: { remoteAddress: ip } }) return token } diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js index 966d3e3..7fe3bd1 100644 --- a/tests/unit/handlers/coolingSystem.handlers.test.js +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -12,7 +12,7 @@ const { buildHvacCircuit2View, buildHvacLayoutView, buildHvacAmbientView -} = require('../../../workers/lib/server/handlers/coolingSystem.handlers') +} = require('../../../workers/lib/server/handlers/cooling.system.handlers') const { COOLING_SYSTEM_PROJECTIONS } = require('../../../workers/lib/constants') const { extractDcsThing, getDCSTag, isCentralDCSEnabled } = require('../../../workers/lib/dcs.utils') diff --git a/tests/unit/lib/users.test.js b/tests/unit/lib/users.test.js index aafcf48..00c4ac5 100644 --- a/tests/unit/lib/users.test.js +++ b/tests/unit/lib/users.test.js @@ -127,7 +127,8 @@ test('UserService - updateUser', async (t) => { id: 123, email: 'updated@example.com', name: 'Updated User', - role: 'admin' + role: 'admin', + callerRoles: ['admin'] }) t.ok(genTokenCalled, 'should call genToken') @@ -161,7 +162,8 @@ test('UserService - updateUser with null name', async (t) => { id: 123, email: 'test@example.com', name: null, - role: 'admin' + role: 'admin', + callerRoles: ['admin'] }) t.is(updateUserArgs.name, null, 'should pass null name') diff --git a/tests/unit/routes/coolingSystem.routes.test.js b/tests/unit/routes/cooling.system.routes.test.js similarity index 93% rename from tests/unit/routes/coolingSystem.routes.test.js rename to tests/unit/routes/cooling.system.routes.test.js index bdb309d..68a495f 100644 --- a/tests/unit/routes/coolingSystem.routes.test.js +++ b/tests/unit/routes/cooling.system.routes.test.js @@ -6,12 +6,12 @@ const { createRoutesForTest } = require('../helpers/mockHelpers') const { ENDPOINTS } = require('../../../workers/lib/constants') test('coolingSystem routes - module structure', (t) => { - testModuleStructure(t, '../../../workers/lib/server/routes/coolingSystem.routes.js', 'coolingSystem') + testModuleStructure(t, '../../../workers/lib/server/routes/cooling.system.routes.js', 'coolingSystem') t.pass() }) test('coolingSystem routes - route definitions', (t) => { - const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + const routes = createRoutesForTest('../../../workers/lib/server/routes/cooling.system.routes.js') const routeUrls = routes.map(route => route.url) t.ok(routeUrls.includes(ENDPOINTS.COOLING_SYSTEM), 'should have cooling-system route') @@ -20,7 +20,7 @@ test('coolingSystem routes - route definitions', (t) => { }) test('coolingSystem routes - HTTP methods', (t) => { - const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + const routes = createRoutesForTest('../../../workers/lib/server/routes/cooling.system.routes.js') const coolingSystemRoute = routes.find(r => r.url === ENDPOINTS.COOLING_SYSTEM) t.ok(coolingSystemRoute, 'cooling-system route should exist') @@ -30,7 +30,7 @@ test('coolingSystem routes - HTTP methods', (t) => { }) test('coolingSystem routes - schema validation', (t) => { - const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + const routes = createRoutesForTest('../../../workers/lib/server/routes/cooling.system.routes.js') const coolingSystemRoute = routes.find(r => r.url === ENDPOINTS.COOLING_SYSTEM) t.ok(coolingSystemRoute, 'cooling-system route should exist') @@ -50,13 +50,13 @@ test('coolingSystem routes - schema validation', (t) => { }) test('coolingSystem routes - handler functions', (t) => { - const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + const routes = createRoutesForTest('../../../workers/lib/server/routes/cooling.system.routes.js') testHandlerFunctions(t, routes, 'coolingSystem') t.pass() }) test('coolingSystem routes - onRequest functions', (t) => { - const routes = createRoutesForTest('../../../workers/lib/server/routes/coolingSystem.routes.js') + const routes = createRoutesForTest('../../../workers/lib/server/routes/cooling.system.routes.js') routes.forEach(route => { t.ok(typeof route.onRequest === 'function', `coolingSystem route ${route.url} should have onRequest function`) diff --git a/workers/lib/auth.js b/workers/lib/auth.js index 5953ac8..626efc8 100644 --- a/workers/lib/auth.js +++ b/workers/lib/auth.js @@ -167,7 +167,7 @@ class AuthLib { return null } - return { email } + return { email: email.toLowerCase() } } } diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f972232..3035729 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -167,7 +167,9 @@ const ENDPOINTS = { // Site Efficiency endpoint SITE_EFFICIENCY: '/auth/site/efficiency', // Explorer endpoints - EXPLORER_RACKS: '/auth/explorer/racks' + EXPLORER_RACKS: '/auth/explorer/racks', + ENERGY_FORECAST: '/auth/energy/forecast', + ENERGY_AVAILABLE: '/auth/energy/available' } const HTTP_METHODS = { @@ -218,6 +220,7 @@ const GET_HISTORICAL_LOGS = 'getHistoricalLogs' const RPC_METHODS = { TAIL_LOG_RANGE_AGGR: 'tailLogCustomRangeAggr', GET_WRK_EXT_DATA: 'getWrkExtData', + SET_WRK_EXT_DATA: 'setWrkExtData', LIST_THINGS: 'listThings', GET_HISTORICAL_LOGS: 'getHistoricalLogs', TAIL_LOG: 'tailLog', @@ -519,6 +522,11 @@ const MINERPOOL_EXT_DATA_KEYS = { STATS: 'stats' } +const ELECTRICITY_EXT_DATA_KEYS = { + FORECAST: 'forecast', + AVAIL_ENERGY_MWH: 'availableEnergyMWh' +} + const NON_METRIC_KEYS = [ 'ts', 'site', @@ -699,5 +707,6 @@ module.exports = { EXPLORER_RACK_AGGR_FIELDS, EXPLORER_RACK_DEFAULT_LIMIT, EXPLORER_RACK_MAX_LIMIT, - LOG_FIELDS + LOG_FIELDS, + ELECTRICITY_EXT_DATA_KEYS } diff --git a/workers/lib/server/handlers/coolingSystem.handlers.js b/workers/lib/server/handlers/cooling.system.handlers.js similarity index 100% rename from workers/lib/server/handlers/coolingSystem.handlers.js rename to workers/lib/server/handlers/cooling.system.handlers.js diff --git a/workers/lib/server/handlers/energy.handlers.js b/workers/lib/server/handlers/energy.handlers.js new file mode 100644 index 0000000..34d28f8 --- /dev/null +++ b/workers/lib/server/handlers/energy.handlers.js @@ -0,0 +1,27 @@ +'use strict' + +const { WORKER_TYPES, RPC_METHODS, ELECTRICITY_EXT_DATA_KEYS } = require('../../constants') + +const getEnergyForecast = async (ctx, req) => { + return await ctx.dataProxy.requestDataMap( + RPC_METHODS.GET_WRK_EXT_DATA, + { + type: WORKER_TYPES.ELECTRICITY, + query: { key: ELECTRICITY_EXT_DATA_KEYS.FORECAST } + }) +} + +const setAvailableEnergy = async (ctx, req) => { + return await ctx.dataProxy.requestDataMap( + RPC_METHODS.SET_WRK_EXT_DATA, + { + type: WORKER_TYPES.ELECTRICITY, + key: ELECTRICITY_EXT_DATA_KEYS.AVAIL_ENERGY_MWH, + value: req.body.data + }) +} + +module.exports = { + getEnergyForecast, + setAvailableEnergy +} diff --git a/workers/lib/server/handlers/energySystem.handlers.js b/workers/lib/server/handlers/energy.system.handlers.js similarity index 100% rename from workers/lib/server/handlers/energySystem.handlers.js rename to workers/lib/server/handlers/energy.system.handlers.js diff --git a/workers/lib/server/handlers/users.handlers.js b/workers/lib/server/handlers/users.handlers.js index a465d0b..47f512f 100644 --- a/workers/lib/server/handlers/users.handlers.js +++ b/workers/lib/server/handlers/users.handlers.js @@ -64,7 +64,8 @@ async function updateUser (ctx, req, res) { throw new Error('ERR_USER_NOT_FOUND') } - const result = await ctx.userService.updateUser({ id, email, name, role }) + const callerRoles = JSON.parse(req._info.user.metadata.roles) + const result = await ctx.userService.updateUser({ id, email, name, role, callerRoles }) // Audit logging for sensitive operations auditLogger.logUserUpdate(id, email, updatedBy, { diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 2721205..699c260 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -17,9 +17,10 @@ const metricsRoutes = require('./routes/metrics.routes') const alertsRoutes = require('./routes/alerts.routes') const minersRoutes = require('./routes/miners.routes') const groupsRoutes = require('./routes/groups.routes') -const coolingSystemRoutes = require('./routes/coolingSystem.routes') -const energySystemRoutes = require('./routes/energySystem.routes') +const coolingSystemRoutes = require('./routes/cooling.system.routes') +const energySystemRoutes = require('./routes/energy.system.routes') const explorerRoutes = require('./routes/explorer.routes') +const energyRoutes = require('./routes/energy.routes') /** * Collect all routes into a flat array for server injection. @@ -46,7 +47,8 @@ function routes (ctx) { ...groupsRoutes(ctx), ...coolingSystemRoutes(ctx), ...energySystemRoutes(ctx), - ...explorerRoutes(ctx) + ...explorerRoutes(ctx), + ...energyRoutes(ctx) ] } diff --git a/workers/lib/server/routes/coolingSystem.routes.js b/workers/lib/server/routes/cooling.system.routes.js similarity index 93% rename from workers/lib/server/routes/coolingSystem.routes.js rename to workers/lib/server/routes/cooling.system.routes.js index dc650eb..233dfa1 100644 --- a/workers/lib/server/routes/coolingSystem.routes.js +++ b/workers/lib/server/routes/cooling.system.routes.js @@ -1,7 +1,7 @@ 'use strict' const { ENDPOINTS, HTTP_METHODS } = require('../../constants') -const { getCoolingSystemData } = require('../handlers/coolingSystem.handlers') +const { getCoolingSystemData } = require('../handlers/cooling.system.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') module.exports = (ctx) => [ diff --git a/workers/lib/server/routes/energy.routes.js b/workers/lib/server/routes/energy.routes.js new file mode 100644 index 0000000..bab4d29 --- /dev/null +++ b/workers/lib/server/routes/energy.routes.js @@ -0,0 +1,29 @@ +'use strict' + +const { ENDPOINTS, HTTP_METHODS, AUTH_CAPS } = require('../../constants') +const { getEnergyForecast, setAvailableEnergy } = require('../handlers/energy.handlers') +const { createCachedAuthRoute, createAuthRoute } = require('../lib/routeHelpers') +const schemas = require('../schemas/energy.schemas') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.ENERGY_FORECAST, + ...createCachedAuthRoute( + ctx, + (req) => ['energy-forecast'], + ENDPOINTS.ENERGY_FORECAST, + getEnergyForecast + ) + }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.ENERGY_AVAILABLE, + ...createAuthRoute(ctx, async (ctx, req) => { + return await setAvailableEnergy(ctx, req) + }, [`${AUTH_CAPS.m}:w`]), + schema: { + body: schemas.body.availableEnergy + } + } +] diff --git a/workers/lib/server/routes/energySystem.routes.js b/workers/lib/server/routes/energy.system.routes.js similarity index 91% rename from workers/lib/server/routes/energySystem.routes.js rename to workers/lib/server/routes/energy.system.routes.js index 4baded7..37fbedf 100644 --- a/workers/lib/server/routes/energySystem.routes.js +++ b/workers/lib/server/routes/energy.system.routes.js @@ -1,7 +1,7 @@ 'use strict' const { ENDPOINTS, HTTP_METHODS } = require('../../constants') -const { getEnergySystemData } = require('../handlers/energySystem.handlers') +const { getEnergySystemData } = require('../handlers/energy.system.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') module.exports = (ctx) => [ diff --git a/workers/lib/server/schemas/energy.schemas.js b/workers/lib/server/schemas/energy.schemas.js new file mode 100644 index 0000000..afb4a5a --- /dev/null +++ b/workers/lib/server/schemas/energy.schemas.js @@ -0,0 +1,17 @@ +'use strict' + +const schemas = { + body: { + availableEnergy: { + type: 'object', + properties: { + data: { + type: 'array' + } + }, + required: ['data'] + } + } +} + +module.exports = schemas diff --git a/workers/lib/users.js b/workers/lib/users.js index 4c76e11..532dbbf 100644 --- a/workers/lib/users.js +++ b/workers/lib/users.js @@ -34,11 +34,16 @@ class UserService { return userRows.filter(user => user.id !== 1).map(this.parseUserRow.bind(this)) } - async updateUser ({ id, email, name = null, role }) { + async updateUser ({ id, email, name = null, role, callerRoles }) { + const targetUser = await this._auth.getUserById(id) + if (!targetUser) { + throw new Error('ERR_USER_NOT_FOUND') + } + const token = await this._auth.genToken({ ips: ['127.0.0.1'], userId: id, - roles: [] + roles: callerRoles }) await this._auth.updateUser({ From a3dba5cdc0a1cd64f08a104d64c7d38960535bcd Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 20 May 2026 17:13:39 +0300 Subject: [PATCH 47/63] feat: add rack grouping to metrics/hashrate endpoint (#82) Extend GET /auth/metrics/hashrate with `groupBy=rack` and an optional `racks` CSV filter so charts can render a per-rack hashrate time series for a specific set of racks. - groupBy=rack reads the hashrate_mhs_5m_pdu_rack_group_sum aggregation from stat-1D, the same obj_concat aggregation family already used for type/container group grouping - racks=group-1_rack-1,group-2_rack-1 narrows each log entry's hashrate object and the summary breakdown to the requested racks - racks is cache-keyed and only applied when groupBy=rack --- tests/unit/handlers/metrics.handlers.test.js | 58 +++++++++++++++++++ workers/lib/constants.js | 2 + .../lib/server/handlers/metrics.handlers.js | 24 +++++--- workers/lib/server/routes/metrics.routes.js | 3 +- workers/lib/server/schemas/metrics.schemas.js | 3 +- 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index 315ac85..d5724c8 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -128,6 +128,64 @@ test('getHashrate - grouped by container uses container group aggregation', asyn t.pass() }) +test('getHashrate - grouped by rack uses rack group aggregation', async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ + ts: 1700006400000, + hashrate_mhs_5m_pdu_rack_group_sum_aggr: { + 'group-1_rack-1': 1000, 'group-1_rack-2': 2000, 'group-2_rack-1': 3000 + } + }] + } + } + }) + + const result = await getHashrate(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, groupBy: 'rack' } + }) + + t.is(capturedPayload.fields.hashrate_mhs_5m_pdu_rack_group_sum, 1, 'should request rack-group source field') + t.is(capturedPayload.aggrFields.hashrate_mhs_5m_pdu_rack_group_sum_aggr, 1, 'should request rack-group aggregate field') + t.is(result.log.length, 1, 'should map grouped row') + t.alike(result.log[0].hashrateMhs, { 'group-1_rack-1': 1000, 'group-1_rack-2': 2000, 'group-2_rack-1': 3000 }, 'should map all racks when no filter given') + t.is(result.summary.totalHashrateMhs, 6000, 'should total all racks') + t.ok(result.summary.groupedBy['group-1_rack-1'], 'should have per-rack breakdown') + t.pass() +}) + +test('getHashrate - grouped by rack filters to requested racks', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => [{ + ts: 1700006400000, + hashrate_mhs_5m_pdu_rack_group_sum_aggr: { + 'group-1_rack-1': 1000, 'group-1_rack-2': 2000, 'group-2_rack-1': 3000 + } + }] + } + }) + + const result = await getHashrate(mockCtx, { + query: { + start: 1700000000000, + end: 1700100000000, + groupBy: 'rack', + racks: 'group-1_rack-1, group-2_rack-1' + } + }) + + t.alike(result.log[0].hashrateMhs, { 'group-1_rack-1': 1000, 'group-2_rack-1': 3000 }, 'should keep only requested racks') + t.is(result.summary.totalHashrateMhs, 4000, 'summary should reflect filtered racks only') + t.absent(result.summary.groupedBy['group-1_rack-2'], 'filtered-out rack should be absent from summary') + t.pass() +}) + test('getHashrate - grouped mode handles empty results', async (t) => { const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 3035729..140bc62 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -478,6 +478,7 @@ const DCS_EFFICIENCY_FIELDS = { const LOG_FIELDS = { HASHRATE_SUM_TYPE_GROUP: 'hashrate_mhs_5m_type_group_sum', HASHRATE_SUM_CONTAINER_GROUP: 'hashrate_mhs_5m_container_group_sum', + HASHRATE_SUM_RACK_GROUP: 'hashrate_mhs_5m_pdu_rack_group_sum', POWER_W_TYPE_GROUP_SUM: 'power_w_type_group_sum', POWER_W_CONTAINER_GROUP_SUM: 'power_w_container_group_sum' } @@ -486,6 +487,7 @@ const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', HASHRATE_SUM_TYPE_GROUP_AGGR: 'hashrate_mhs_5m_type_group_sum_aggr', HASHRATE_SUM_CONTAINER_GROUP_AGGR: 'hashrate_mhs_5m_container_group_sum_aggr', + HASHRATE_SUM_RACK_GROUP_AGGR: 'hashrate_mhs_5m_pdu_rack_group_sum_aggr', SITE_POWER: 'site_power_w', ENERGY_AGGR: 'energy_aggr', ACTIVE_ENERGY_IN: 'active_energy_in_aggr', diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index 455c5aa..b2b4a26 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -26,6 +26,7 @@ const { resolveInterval, getIntervalConfig } = require('../../metrics.utils') +const { parseRacks } = require('../lib/queryUtils') async function getHashrate (ctx, req) { const { start, end } = validateStartEnd(req) @@ -56,16 +57,16 @@ async function getHashrate (ctx, req) { return { log, summary } } +const HASHRATE_GROUP_FIELDS = { + miner: { field: LOG_FIELDS.HASHRATE_SUM_TYPE_GROUP, aggrField: AGGR_FIELDS.HASHRATE_SUM_TYPE_GROUP_AGGR }, + container: { field: LOG_FIELDS.HASHRATE_SUM_CONTAINER_GROUP, aggrField: AGGR_FIELDS.HASHRATE_SUM_CONTAINER_GROUP_AGGR }, + rack: { field: LOG_FIELDS.HASHRATE_SUM_RACK_GROUP, aggrField: AGGR_FIELDS.HASHRATE_SUM_RACK_GROUP_AGGR } +} + async function getGoupedHashrate (ctx, req) { const { groupBy, start, end } = req.query - const field = groupBy === WORKER_TYPES.MINER - ? LOG_FIELDS.HASHRATE_SUM_TYPE_GROUP - : LOG_FIELDS.HASHRATE_SUM_CONTAINER_GROUP - - const aggrField = groupBy === WORKER_TYPES.MINER - ? AGGR_FIELDS.HASHRATE_SUM_TYPE_GROUP_AGGR - : AGGR_FIELDS.HASHRATE_SUM_CONTAINER_GROUP_AGGR + const { field, aggrField } = HASHRATE_GROUP_FIELDS[groupBy] const res = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, { type: WORKER_TYPES.MINER, @@ -77,8 +78,15 @@ async function getGoupedHashrate (ctx, req) { aggrFields: { [aggrField]: 1 } }) + const racks = groupBy === 'rack' ? parseRacks(req) : null + const rackFilter = racks && racks.length ? new Set(racks) : null + const log = res[0].reduce((aggr, val) => { - aggr.push({ ts: val.ts, hashrateMhs: val[aggrField] }) + let hashrateMhs = val[aggrField] + if (rackFilter && hashrateMhs && typeof hashrateMhs === 'object') { + hashrateMhs = Object.fromEntries(Object.entries(hashrateMhs).filter(([rack]) => rackFilter.has(rack))) + } + aggr.push({ ts: val.ts, hashrateMhs }) return aggr }, []) diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js index 9de5fea..832da34 100644 --- a/workers/lib/server/routes/metrics.routes.js +++ b/workers/lib/server/routes/metrics.routes.js @@ -33,7 +33,8 @@ module.exports = (ctx) => { 'metrics/hashrate', req.query.start, req.query.end, - req.query.groupBy + req.query.groupBy, + req.query.racks ], ENDPOINTS.METRICS_HASHRATE, getHashrate diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index aec1061..1791aa4 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -7,7 +7,8 @@ const schemas = { properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 0 }, - groupBy: { type: 'string', enum: ['miner', 'container'] }, + groupBy: { type: 'string', enum: ['miner', 'container', 'rack'] }, + racks: { type: 'string' }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] From eabac28461672795a37827d8c6c96b7796e49f1b Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Wed, 20 May 2026 18:37:32 +0300 Subject: [PATCH 48/63] feat: add rack grouping to metrics/consumption endpoint (#83) Extend GET /auth/metrics/consumption with `groupBy=rack` and an optional `racks` CSV filter, mirroring the rack grouping added to metrics/hashrate in #82. - groupBy=rack reads the power_w_pdu_rack_group_sum aggregation from stat-1D, the same obj_concat aggregation family already used for type/container group grouping - racks=group-1_rack-1,group-2_rack-1 narrows each log entry's powerW object (and the derived consumptionMWh) and the summary to those racks - racks is cache-keyed and only applied when groupBy=rack --- tests/unit/handlers/metrics.handlers.test.js | 58 +++++++++++++++++++ workers/lib/constants.js | 4 +- .../lib/server/handlers/metrics.handlers.js | 24 ++++---- workers/lib/server/routes/metrics.routes.js | 3 +- workers/lib/server/schemas/metrics.schemas.js | 3 +- 5 files changed, 79 insertions(+), 13 deletions(-) diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index d5724c8..ebb5143 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -524,6 +524,64 @@ test('getConsumption - grouped by container uses container group aggregation', a t.pass() }) +test('getConsumption - grouped by rack uses rack group aggregation', async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + capturedPayload = payload + return [{ + ts: 1700006400000, + power_w_pdu_rack_group_sum_aggr: { + 'group-1_rack-1': 1000000, 'group-1_rack-2': 2000000, 'group-2_rack-1': 3000000 + } + }] + } + } + }) + + const result = await getConsumption(mockCtx, { + query: { start: 1700000000000, end: 1700100000000, groupBy: 'rack' } + }) + + t.is(capturedPayload.fields.power_w_pdu_rack_group_sum, 1, 'should request rack-group source field') + t.is(capturedPayload.aggrFields.power_w_pdu_rack_group_sum_aggr, 1, 'should request rack-group aggregate field') + t.is(result.log.length, 1, 'should map grouped row') + t.alike(result.log[0].powerW, { 'group-1_rack-1': 1000000, 'group-1_rack-2': 2000000, 'group-2_rack-1': 3000000 }, 'should map all racks when no filter given') + t.is(result.summary.totalConsumptionMWh, (6000000 * 24) / 1000000, 'should total all racks') + t.ok(result.summary.groupedBy['group-1_rack-1'], 'should have per-rack breakdown') + t.pass() +}) + +test('getConsumption - grouped by rack filters to requested racks', async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async () => [{ + ts: 1700006400000, + power_w_pdu_rack_group_sum_aggr: { + 'group-1_rack-1': 1000000, 'group-1_rack-2': 2000000, 'group-2_rack-1': 3000000 + } + }] + } + }) + + const result = await getConsumption(mockCtx, { + query: { + start: 1700000000000, + end: 1700100000000, + groupBy: 'rack', + racks: 'group-1_rack-1, group-2_rack-1' + } + }) + + t.alike(result.log[0].powerW, { 'group-1_rack-1': 1000000, 'group-2_rack-1': 3000000 }, 'should keep only requested racks') + t.is(result.summary.totalConsumptionMWh, (4000000 * 24) / 1000000, 'summary should reflect filtered racks only') + t.absent(result.summary.groupedBy['group-1_rack-2'], 'filtered-out rack should be absent from summary') + t.pass() +}) + test('getConsumption - grouped mode handles empty results', async (t) => { const mockCtx = withDataProxy({ conf: { orks: [{ rpcPublicKey: 'key1' }] }, diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 140bc62..085d5cc 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -480,7 +480,8 @@ const LOG_FIELDS = { HASHRATE_SUM_CONTAINER_GROUP: 'hashrate_mhs_5m_container_group_sum', HASHRATE_SUM_RACK_GROUP: 'hashrate_mhs_5m_pdu_rack_group_sum', POWER_W_TYPE_GROUP_SUM: 'power_w_type_group_sum', - POWER_W_CONTAINER_GROUP_SUM: 'power_w_container_group_sum' + POWER_W_CONTAINER_GROUP_SUM: 'power_w_container_group_sum', + POWER_W_RACK_GROUP_SUM: 'power_w_pdu_rack_group_sum' } const AGGR_FIELDS = { @@ -505,6 +506,7 @@ const AGGR_FIELDS = { HASHRATE_1M_CONTAINER_GROUP_SUM: 'hashrate_mhs_1m_container_group_sum_aggr', POWER_W_CONTAINER_GROUP_SUM: 'power_w_container_group_sum_aggr', POWER_W_TYPE_GROUP_SUM: 'power_w_type_group_sum_aggr', + POWER_W_RACK_GROUP_SUM: 'power_w_pdu_rack_group_sum_aggr', POWER_MODE_LOW_CNT: 'power_mode_low_cnt', POWER_MODE_NORMAL_CNT: 'power_mode_normal_cnt', POWER_MODE_HIGH_CNT: 'power_mode_high_cnt', diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index b2b4a26..8dd23b9 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -223,18 +223,16 @@ function calculateConsumptionSummary (log) { } } +const CONSUMPTION_GROUP_FIELDS = { + miner: { field: LOG_FIELDS.POWER_W_TYPE_GROUP_SUM, aggrField: AGGR_FIELDS.POWER_W_TYPE_GROUP_SUM }, + container: { field: LOG_FIELDS.POWER_W_CONTAINER_GROUP_SUM, aggrField: AGGR_FIELDS.POWER_W_CONTAINER_GROUP_SUM }, + rack: { field: LOG_FIELDS.POWER_W_RACK_GROUP_SUM, aggrField: AGGR_FIELDS.POWER_W_RACK_GROUP_SUM } +} + async function getGroupedConsumption (ctx, req) { const { groupBy, start, end } = req.query - const isMinerGroup = groupBy === WORKER_TYPES.MINER - - const field = isMinerGroup - ? LOG_FIELDS.POWER_W_TYPE_GROUP_SUM - : LOG_FIELDS.POWER_W_CONTAINER_GROUP_SUM - - const aggrField = isMinerGroup - ? AGGR_FIELDS.POWER_W_TYPE_GROUP_SUM - : AGGR_FIELDS.POWER_W_CONTAINER_GROUP_SUM + const { field, aggrField } = CONSUMPTION_GROUP_FIELDS[groupBy] const res = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, { type: WORKER_TYPES.MINER, @@ -246,8 +244,14 @@ async function getGroupedConsumption (ctx, req) { aggrFields: { [aggrField]: 1 } }) + const racks = groupBy === 'rack' ? parseRacks(req) : null + const rackFilter = racks && racks.length ? new Set(racks) : null + const log = res[0].reduce((aggr, val) => { - const powerW = val[aggrField] + let powerW = val[aggrField] + if (rackFilter && powerW && typeof powerW === 'object') { + powerW = Object.fromEntries(Object.entries(powerW).filter(([rack]) => rackFilter.has(rack))) + } aggr.push({ ts: val.ts, powerW, diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js index 832da34..bd29650 100644 --- a/workers/lib/server/routes/metrics.routes.js +++ b/workers/lib/server/routes/metrics.routes.js @@ -52,7 +52,8 @@ module.exports = (ctx) => { 'metrics/consumption', req.query.start, req.query.end, - req.query.groupBy + req.query.groupBy, + req.query.racks ], ENDPOINTS.METRICS_CONSUMPTION, getConsumption diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index 1791aa4..84840a8 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -18,7 +18,8 @@ const schemas = { properties: { start: { type: 'integer', minimum: 0 }, end: { type: 'integer', minimum: 0 }, - groupBy: { type: 'string', enum: ['miner', 'container'] }, + groupBy: { type: 'string', enum: ['miner', 'container', 'rack'] }, + racks: { type: 'string' }, overwriteCache: { type: 'boolean' } }, required: ['start', 'end'] From 797068879044e1eeefa64ee1453621792001032b Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Fri, 22 May 2026 11:35:37 +0300 Subject: [PATCH 49/63] feat: work order + spare parts HTTP API (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: expose Work Order HTTP API + RBAC Adds the Work Order HTTP surface as thin wrappers over the existing things RPC plumbing: POST /auth/work-orders create (registerThing) GET /auth/work-orders list (listThings + type filter) GET /auth/work-orders/:id get (listThings + id+type filter) PATCH /auth/work-orders/:id update (updateThing) POST /auth/work-orders/:id/close close (updateThing status=closed) POST /auth/work-orders/:id/cancel cancel (updateThing status=cancelled) POST /auth/work-orders/:id/assign assign (updateThing assignedTo) GET /auth/work-orders/:id/audit history (getHistoricalLogs) Introduces a new RBAC resource work_order (r/rw) granted to operator/admin/repair_technician roles for write and to viewer roles for read. Strips the duplicated [HRPC_ERR]= prefix and bubbles structured rack errors verbatim. Skips capCheck when the gateway runs with --noauth so local curl-testing works through permissioned routes. * feat: searchable Work Order list with pagination envelope Adds filter shortcuts (q, assignee, creator, partId, status, type, from, to) to GET /auth/work-orders alongside the existing mingo ?query passthrough; q is a case-insensitive regex $or against code and info.issue, the rest map to info.* paths and run on the rack the same way every other list does. Pages now return the {data, totalCount, offset, limit, hasMore} envelope by calling listThings and getThingsCount in parallel, and every shortcut is wired into the cache key so cached pages do not bleed across filter combinations. * feat: spare-part PUT gated by an open Work Order + repair history Adds PUT /auth/spare-parts/:id which validates the supplied workOrderId resolves to an open or in_progress WO, pushes the underlying updateThing on the part rack with workOrderId injected into info, and appends a denormalised entry to the WO's partsMoves[] so the audit trail stays on the Work Order itself. GET /auth/spare-parts/:id/repair-history mingo-queries every WO that ever touched the part and returns each move row hydrated with workOrderCode in the standard {data, totalCount, offset, limit, hasMore} envelope. * feat: Work Order log entries + file upload/download/delete routes Adds POST /auth/work-orders/:id/log which forwards to the existing saveThingComment RPC so the WO comments[] doubles as the work log. Adds POST/GET/DELETE /auth/work-orders/:id/files routes backed by @fastify/multipart on the gateway and the new storeWorkOrderFile/loadWorkOrderFile/removeWorkOrderFile pass- throughs on the ork; uploads validate mime + size, append metadata to WO.info.files via pushAction, and reject mutations on closed or cancelled WOs. * feat: spare-part register flow + Type-1/Type-2 Work Order dispatch POST /auth/spare-parts now creates the part and a paired Type-1 WO in one call, returning {partId, workOrderId, workOrderCode}. POST /auth/work-orders resolves the target part for both flows from deviceIdentifier (id, code, serialNum, or macAddress) and rejects unknown deviceTypes with ERR_INVALID_DEVICE_TYPE / unresolved parts with ERR_PART_NOT_FOUND. * feat: accept warranty payload on Work Order create/update Adds an open `warranty: { vendor, fields }` shape to the create and update body schemas; vendor-specific field validation lives in the inventory worker so app-node stays agnostic of vendor differences. * feat: GET /auth/work-orders/:id/export with PDF + CSV output Export accepts either the WO uuid or its IVI-* code in the path and a required ?format=pdf|csv|docx querystring. CSV flattens one row per parts-movement entry (WO header columns repeated). PDF is server-rendered via PDFKit and includes header, triage, work log, parts movements, file references, and a vendor-specific warranty section. format=docx returns 501 with ERR_EXPORT_FORMAT_NOT_IMPLEMENTED until phase 2. * refactor: ship CSV only; pdf + docx return 501 (deferred to FE/phase 2) Drops the pdfkit dependency and the PDF renderer. The export endpoint now serves CSV directly; pdf and docx both 501 with ERR_EXPORT_FORMAT_NOT_IMPLEMENTED, keeping the route shape stable for when the frontend renders PDF client-side. * feat: align SPARE_PART_INITIAL_LOCATION to canonical 'Site Warehouse' Mirrors the new MINER_LOCATIONS enum from the inventory worker. * refactor: camelCase MINER_LOCATIONS values + SPARE_PART_INITIAL_LOCATION Mirrors the inventory worker constant. * feat: GET /auth/spare-parts with mingo-side location / status / q filters Querystring shortcuts (location, status, q) translate to a mingo query forwarded to the rack via listThings + getThingsCount — no app-node post-filtering. q is a case-insensitive regex against code, info.serialNum, and info.macAddress. WO things are excluded by default via type:{\$ne:...}, overridable by an explicit query.type. Cache key includes every filter shortcut so combinations don't collide. * refactor: extract escapeRegex, listThingsWithCount, stableJsonString to utils Both list handlers (work-orders + spare-parts) hand-rolled the same regex-escape + listThings/getThingsCount/pagination skeleton; collapses to a single utils helper used by both. Cache keys for the two GET list routes now canonicalize their JSON inputs via stableJsonString so semantically-equal queries share a cache slot instead of missing each other on key-order differences. * refactor: drop waitForThing — registerSparePart returns action ids, no polling App-node was polling listThings after each pushAction to surface the rack-assigned thing code in the response — exactly the 'submit-and-poll-in-app-node' anti-pattern the codebase had already rejected. registerSparePart now pre-generates partId/woId, fires both registerThing pushActions in parallel, and returns {partId, workOrderId, partActionId, workOrderActionId, errors}. Clients read the eventually-assigned codes from GET /auth/actions/done/:id like every other write in the app. Removes waitForThing + sleep from utils and the two timeout constants that only existed to tune it. * refactor: address peer-review #3 #6 #8 #10 #12 #20 #3 (atomicity) — updateSparePart now checks the part pushAction's errors[] before firing the WO partsMoves append. If the part write was rejected at push time, throws ERR_PART_UPDATE_PUSH_FAILED: with a 502 instead of silently enqueueing the WO append. Response shape also gains partActionId / workOrderActionId / workOrderAppendErrors so the FE can detect a post-push WO failure and trigger a manual reconcile. #6 (status casing) — WO status enum normalised to 'inProgress' so every info.* enum value follows the camelCase convention adopted for MINER_LOCATIONS. #8 (registerSparePart field sprawl) — drops the info.model→info.deviceModel→'unknown' fallback chain and the serialNum→macAddress→partId fallback. deviceModel and serialNum are now both required up front with explicit ERR_*_REQUIRED errors. #10 (action latency) — register/update responses include expectedActionLatencyMs sourced from ctx.conf.expectedActionLatencyMs (default 1000) so the FE can pace its /auth/actions/done/:id polling instead of guessing. #12 (export 501) — error message now carries the requested format (ERR_EXPORT_FORMAT_NOT_IMPLEMENTED:pdf / :docx) so the FE can tell which format was rejected without re-reading the request. #20 (pagination) — listThingsWithCount now slices the dedupe'd union down to the requested limit so multi-ork fan-out can't return more rows than the caller asked for. hasMore is now derived from offset + limit rather than offset + data.length. Multi-rack offset-pagination remains best-effort because each rack applies the offset locally; documented inline. * revert: restore 'in_progress' in WO status schema enum Mirrors the inventory rack revert — keeps existing 'in_progress' WO records valid for transitions. * revert: restore human-readable MINER_LOCATIONS values Mirrors the inventory rack revert. * refactor: call generic storeFile / loadFile / removeFile with file type Updates the work-order file handlers to use the renamed generic file RPCs and tags each call with FILE_TYPES.WORK_ORDER so the rack can dispatch on it. * fix: pass workOrderId + fileId to scoped file RPCs, surface blobCleared loadFile / removeFile are now called with { workOrderId, fileId } so the rack resolves the blob descriptor from the work order it belongs to — app-node no longer forwards a raw blobRef. deleteWorkOrderFile surfaces the rack's { cleared } result as blobCleared so the HTTP caller can tell whether the underlying blob was actually removed. * refactor: address PR #79 review feedback - resolve the work_order rack from the ork rack registry via listRacks (getWorkOrderRackId, cached on ctx) and drop the workOrderRackId config key — the rack id is discovered, not deployment config - relocate submitWorkOrderAction out of generic utils into the new server/lib/workOrders module alongside getWorkOrderRackId - add WORK_ORDER_TERMINAL_STATUSES constant, replacing the duplicated ['closed', 'cancelled'] literals across the WO handlers - workOrderExport: derive the CSV header and rows from one [name, extractor] schema instead of two hand-synced lists - rename pushOne -> pushSingleAction in registerSparePart * refactor: derive WO export CSV columns from json keys renderWorkOrderCsv builds the header from the work order's own properties — top-level code plus every info field, then each parts-movement entry's keys — so the CSV tracks the worker response schema with no hand-kept column list. Drops the CSV_HEADERS / WO_COLUMNS / MOVE_COLUMNS definitions. * refactor: remove non-functional comments from WO export and rack helpers * refactor: promote _csvEscape to a generic csvEscape util * refactor: rename WO and spare-parts modules to dot-separated file names Align the work order and spare parts file names with the repo convention (cooling.system.routes.js, energy.system.routes.js, etc.): camelCase basenames become dot-separated — spareParts.handlers.js -> spare.parts.handlers.js, workOrders.js -> work.orders.js, workOrderExport.js -> work.order.export.js. Updates every require() path, route registration in index.js, and test references. --- config/facs/auth.config.json.example | 21 +- package-lock.json | 55 ++- package.json | 5 +- .../handlers/spare.parts.handlers.test.js | 333 ++++++++++++++++++ .../work.order.files.handlers.test.js | 208 +++++++++++ .../handlers/work.orders.handlers.test.js | 323 +++++++++++++++++ tests/unit/lib/utils.test.js | 84 ++++- tests/unit/lib/work.order.export.test.js | 62 ++++ tests/unit/lib/work.orders.test.js | 94 +++++ tests/unit/routes/spare.parts.routes.test.js | 63 ++++ .../routes/work.order.files.routes.test.js | 28 ++ tests/unit/routes/work.orders.routes.test.js | 81 +++++ workers/http.node.wrk.js | 5 + workers/lib/constants.js | 65 +++- .../server/handlers/spare.parts.handlers.js | 270 ++++++++++++++ .../handlers/work.order.files.handlers.js | 165 +++++++++ .../server/handlers/work.orders.handlers.js | 231 ++++++++++++ workers/lib/server/index.js | 8 +- workers/lib/server/lib/work.order.export.js | 28 ++ workers/lib/server/lib/work.orders.js | 33 ++ .../lib/server/routes/spare.parts.routes.js | 57 +++ .../server/routes/work.order.files.routes.js | 48 +++ .../lib/server/routes/work.orders.routes.js | 118 +++++++ .../lib/server/schemas/spare.parts.schemas.js | 77 ++++ .../lib/server/schemas/work.orders.schemas.js | 145 ++++++++ workers/lib/utils.js | 48 ++- 26 files changed, 2636 insertions(+), 19 deletions(-) create mode 100644 tests/unit/handlers/spare.parts.handlers.test.js create mode 100644 tests/unit/handlers/work.order.files.handlers.test.js create mode 100644 tests/unit/handlers/work.orders.handlers.test.js create mode 100644 tests/unit/lib/work.order.export.test.js create mode 100644 tests/unit/lib/work.orders.test.js create mode 100644 tests/unit/routes/spare.parts.routes.test.js create mode 100644 tests/unit/routes/work.order.files.routes.test.js create mode 100644 tests/unit/routes/work.orders.routes.test.js create mode 100644 workers/lib/server/handlers/spare.parts.handlers.js create mode 100644 workers/lib/server/handlers/work.order.files.handlers.js create mode 100644 workers/lib/server/handlers/work.orders.handlers.js create mode 100644 workers/lib/server/lib/work.order.export.js create mode 100644 workers/lib/server/lib/work.orders.js create mode 100644 workers/lib/server/routes/spare.parts.routes.js create mode 100644 workers/lib/server/routes/work.order.files.routes.js create mode 100644 workers/lib/server/routes/work.orders.routes.js create mode 100644 workers/lib/server/schemas/spare.parts.schemas.js create mode 100644 workers/lib/server/schemas/work.orders.schemas.js diff --git a/config/facs/auth.config.json.example b/config/facs/auth.config.json.example index 2e4af13..259642c 100644 --- a/config/facs/auth.config.json.example +++ b/config/facs/auth.config.json.example @@ -26,7 +26,8 @@ "ticket:rw", "power_spot_forecast:rw", "pool_config:rw", - "pool_config_approve:rw" + "pool_config_approve:rw", + "work_order:rw" ], "roles": { "admin": [ @@ -51,7 +52,8 @@ "ticket:rw", "power_spot_forecast:rw", "pool_config:rw", - "pool_config_approve:rw" + "pool_config_approve:rw", + "work_order:rw" ], "reporting_tool_manager": [ "revenue:rw", @@ -76,7 +78,8 @@ "inventory:rw", "reporting:rw", "settings:rw", - "ticket:rw" + "ticket:rw", + "work_order:rw" ], "site_operator": [ "miner:rw", @@ -93,7 +96,8 @@ "comments:rw", "settings:rw", "ticket:rw", - "alerts:rw" + "alerts:rw", + "work_order:rw" ], "field_operator": [ "miner:r", @@ -109,7 +113,8 @@ "comments:rw", "settings:r", "ticket:r", - "alerts:r" + "alerts:r", + "work_order:r" ], "repair_technician": [ "miner:r", @@ -125,7 +130,8 @@ "comments:rw", "settings:r", "ticket:r", - "alerts:r" + "alerts:r", + "work_order:rw" ], "read_only_user": [ "miner:r", @@ -141,7 +147,8 @@ "comments:r", "settings:r", "ticket:r", - "alerts:r" + "alerts:r", + "work_order:r" ], "pool_manager": [ "pool_config:rw", diff --git a/package-lock.json b/package-lock.json index 52dab65..187d21b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,10 @@ "@bitfinex/bfx-facs-lru": "git+https://github.com/bitfinexcom/bfx-facs-lru.git", "@bitfinex/bfx-svc-boot-js": "1.2.0", "@bitfinex/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", + "@fastify/multipart": "^10.0.0", "@fastify/websocket": "11.2.0", "@tetherto/hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git#v1.0.0", - "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#pull/20/head", + "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#v1.1.1", "@tetherto/svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git#v1.0.0", "@tetherto/svc-facs-httpd-oauth2": "git+https://github.com/tetherto/svc-facs-httpd-oauth2.git#v1.0.0", "@tetherto/tether-wrk-base": "git+https://github.com/tetherto/tether-wrk-base.git#v1.0.0", @@ -312,6 +313,12 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@fastify/cookie": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", @@ -332,6 +339,22 @@ "fastify-plugin": "^5.0.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -402,6 +425,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz", + "integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/oauth2": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/@fastify/oauth2/-/oauth2-8.2.0.tgz", @@ -988,6 +1034,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1972,6 +2019,7 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -3128,6 +3176,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3335,6 +3384,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3433,6 +3483,7 @@ "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -3512,6 +3563,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -3528,6 +3580,7 @@ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", diff --git a/package.json b/package.json index 9f04ffd..ecabc64 100644 --- a/package.json +++ b/package.json @@ -42,13 +42,14 @@ "start": "node worker.js --wtype wrk-node-http --env production --port 3000" }, "dependencies": { - "@fastify/websocket": "11.2.0", - "@bitfinex/bfx-svc-boot-js": "1.2.0", "@bitfinex/bfx-facs-db-sqlite": "git+https://github.com/bitfinexcom/bfx-facs-db-sqlite.git", "@bitfinex/bfx-facs-http": "git+https://github.com/bitfinexcom/bfx-facs-http.git", "@bitfinex/bfx-facs-interval": "git+https://github.com/bitfinexcom/bfx-facs-interval.git", "@bitfinex/bfx-facs-lru": "git+https://github.com/bitfinexcom/bfx-facs-lru.git", + "@bitfinex/bfx-svc-boot-js": "1.2.0", "@bitfinex/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", + "@fastify/multipart": "^10.0.0", + "@fastify/websocket": "11.2.0", "@tetherto/hp-svc-facs-store": "git+https://github.com/tetherto/hp-svc-facs-store.git#v1.0.0", "@tetherto/svc-facs-auth": "git+https://github.com/tetherto/svc-facs-auth.git#v1.1.1", "@tetherto/svc-facs-httpd": "git+https://github.com/tetherto/svc-facs-httpd.git#v1.0.0", diff --git a/tests/unit/handlers/spare.parts.handlers.test.js b/tests/unit/handlers/spare.parts.handlers.test.js new file mode 100644 index 0000000..534c33e --- /dev/null +++ b/tests/unit/handlers/spare.parts.handlers.test.js @@ -0,0 +1,333 @@ +'use strict' + +const test = require('brittle') +const handlers = require('../../../workers/lib/server/handlers/spare.parts.handlers') +const { createMockCtxWithOrks } = require('../helpers/mockHelpers') + +const WO_RACK = 'inventory-work_order-rack-x' +const PART_RACK = 'inventory-miner_part-psu-rack-1' +const PART = { + id: 'p-1', + code: 'PSU-WM-CB6_V5-01', + rack: PART_RACK, + info: { location: 'Lab', status: 'active' } +} +const OPEN_WO = { + id: 'wo-1', + code: 'IVI-2-0001', + type: 'inventory-work_order', + info: { status: 'open', partsMoves: [] } +} +const CLOSED_WO = { + id: 'wo-1', + code: 'IVI-2-0001', + type: 'inventory-work_order', + info: { status: 'closed', partsMoves: [] } +} + +const mockAuthLib = { + getTokenPerms: async () => ({ permissions: ['inventory:rw', 'actions:rw'] }) +} +const userMeta = (email = 'op@test') => ({ + _info: { authToken: 'tok', user: { metadata: { email } } } +}) + +function buildCtx ({ wo = OPEN_WO, part = PART, pushResults = {} } = {}) { + const pushed = [] + const handler = async (_key, method, params) => { + if (method === 'listThings') { + if (params.query?.type === 'inventory-work_order') return wo ? [wo] : [] + if (params.query?.id === PART.id) return part ? [part] : [] + return [] + } + if (method === 'pushAction') { + pushed.push(params) + return pushResults[params.params[0].rackId] ?? { id: `act-${pushed.length}`, errors: [] } + } + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = WO_RACK + return { ctx, pushed } +} + +test('handlers: updateSparePart rejects location/status changes without workOrderId', async (t) => { + const { ctx } = buildCtx() + await t.exception( + () => handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, info: { location: 'Site Lab' } } + }), + /ERR_PART_MOVE_REQUIRES_WO/ + ) +}) + +test('handlers: updateSparePart rejects when WO is closed', async (t) => { + const { ctx } = buildCtx({ wo: CLOSED_WO }) + await t.exception( + () => handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + }), + /ERR_WO_INVALID_STATUS_TRANSITION/ + ) +}) + +test('handlers: updateSparePart 404s when WO is missing', async (t) => { + const { ctx } = buildCtx({ wo: null }) + await t.exception( + () => handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-missing', info: { location: 'Site Lab' } } + }), + /ERR_WORK_ORDER_NOT_FOUND/ + ) +}) + +test('handlers: updateSparePart pushes part update + WO partsMoves append on a valid move', async (t) => { + const { ctx, pushed } = buildCtx() + const out = await handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + }) + + t.is(pushed.length, 2, 'two actions pushed (part + WO)') + + const partAction = pushed.find(p => p.params[0].rackId === PART_RACK) + t.is(partAction.action, 'updateThing') + t.is(partAction.params[0].info.location, 'Site Lab') + t.is(partAction.params[0].info.workOrderId, 'wo-1', 'workOrderId injected into part info') + t.is(partAction.params[0].info.workOrderCode, 'IVI-2-0001', 'workOrderCode injected too') + + const woAction = pushed.find(p => p.params[0].rackId === WO_RACK) + const moves = woAction.params[0].info.partsMoves + t.is(moves.length, 1) + t.is(moves[0].partId, PART.id) + t.is(moves[0].partCode, PART.code) + t.is(moves[0].fromLocation, 'Lab') + t.is(moves[0].toLocation, 'Site Lab') + t.is(moves[0].workOrderCode, 'IVI-2-0001') + + t.ok(out.move, 'response includes the move record') +}) + +test('handlers: updateSparePart aborts WO append when the part pushAction returned errors', async (t) => { + const handler = async (_key, method, params) => { + if (method === 'listThings') { + if (params.query?.type === 'inventory-work_order') return [OPEN_WO] + return [PART] + } + if (method === 'pushAction') { + const isPart = params.params[0].rackId === PART_RACK + return { id: null, errors: isPart ? ['ERR_RACK_DOWN'] : [] } + } + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = WO_RACK + await t.exception( + () => handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + }), + /ERR_PART_UPDATE_PUSH_FAILED:ERR_RACK_DOWN/ + ) +}) + +test('handlers: updateSparePart skips WO checks when only non-move fields change', async (t) => { + const { ctx, pushed } = buildCtx() + await handlers.updateSparePart(ctx, { + ...userMeta(), + params: { id: PART.id }, + body: { rackId: PART_RACK, info: { serialNum: 'SN-NEW' } } + }) + t.is(pushed.length, 1, 'only part update pushed; no WO append') + t.is(pushed[0].params[0].info.serialNum, 'SN-NEW') + t.absent(pushed[0].params[0].info.workOrderId, 'no WO injected for non-move') +}) + +function buildRegisterCtx ({ pushResult } = {}) { + const pushed = [] + const handler = async (_key, method, params) => { + if (method === 'pushAction') { + pushed.push(params) + return pushResult ?? { id: `act-${pushed.length}`, errors: [] } + } + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = WO_RACK + return { ctx, pushed } +} + +test('handlers: registerSparePart rejects invalid deviceType', async (t) => { + const { ctx } = buildRegisterCtx() + await t.exception( + () => handlers.registerSparePart(ctx, { + ...userMeta(), + body: { rackId: PART_RACK, info: { deviceType: 'cooling', deviceModel: 'X', serialNum: 'SN' } } + }), + /ERR_INVALID_DEVICE_TYPE/ + ) +}) + +test('handlers: registerSparePart rejects missing deviceModel / serialNum', async (t) => { + const { ctx } = buildRegisterCtx() + await t.exception( + () => handlers.registerSparePart(ctx, { + ...userMeta(), + body: { rackId: PART_RACK, info: { deviceType: 'psu', serialNum: 'SN' } } + }), + /ERR_DEVICE_MODEL_REQUIRED/ + ) + await t.exception( + () => handlers.registerSparePart(ctx, { + ...userMeta(), + body: { rackId: PART_RACK, info: { deviceType: 'psu', deviceModel: 'M' } } + }), + /ERR_SERIAL_NUM_REQUIRED/ + ) +}) + +test('handlers: registerSparePart fires part + Type-1 WO pushActions in parallel and returns action ids', async (t) => { + const { ctx, pushed } = buildRegisterCtx() + const out = await handlers.registerSparePart(ctx, { + ...userMeta(), + body: { rackId: PART_RACK, info: { deviceType: 'psu', deviceModel: 'PSU-WM-CB6_V5', serialNum: 'SN-99' } } + }) + + t.is(pushed.length, 2, 'one part action, one WO action') + const partAction = pushed.find(p => p.params[0].rackId === PART_RACK) + const woAction = pushed.find(p => p.params[0].rackId === WO_RACK) + t.is(partAction.action, 'registerThing') + t.is(woAction.action, 'registerThing') + t.is(woAction.params[0].info.type, 1, 'Type-1 WO') + t.is(woAction.params[0].info.partsMoves[0].fromLocation, null) + t.is(woAction.params[0].info.partsMoves[0].toLocation, 'Site Warehouse') + t.is(woAction.params[0].info.partsMoves[0].partId, out.partId, 'WO partsMoves entry links to the pre-generated partId') + + t.ok(out.partId, 'returns partId') + t.ok(out.workOrderId, 'returns workOrderId') + t.ok(out.partActionId, 'returns partActionId for the client to poll') + t.ok(out.workOrderActionId, 'returns workOrderActionId for the client to poll') + t.alike(out.errors, [], 'no errors on happy path') +}) + +test('handlers: registerSparePart surfaces ork-side errors in the response', async (t) => { + const { ctx } = buildRegisterCtx({ pushResult: { id: null, errors: ['ERR_RACK_DOWN'] } }) + const out = await handlers.registerSparePart(ctx, { + ...userMeta(), + body: { rackId: PART_RACK, info: { deviceType: 'psu', deviceModel: 'X', serialNum: 'SN-X' } } + }) + t.alike(out.errors.sort(), ['ERR_RACK_DOWN', 'ERR_RACK_DOWN']) + t.is(out.partActionId, null) +}) + +function listFlow ({ items = [], total = 0 } = {}) { + let lastList, lastCount + const handler = async (_key, method, params) => { + if (method === 'listThings') { lastList = params; return items } + if (method === 'getThingsCount') { lastCount = params; return total } + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + return { + ctx, + get lastList () { return lastList }, + get lastCount () { return lastCount } + } +} + +test('handlers: listSpareParts excludes WO things via type $ne and paginates', async (t) => { + const flow = listFlow({ items: [{ id: 'p1' }, { id: 'p2' }], total: 9 }) + const out = await handlers.listSpareParts(flow.ctx, { query: { offset: 0, limit: 2 } }) + t.alike(flow.lastList.query.type, { $ne: 'inventory-work_order' }, 'WOs excluded by mingo') + t.alike(flow.lastCount.query.type, { $ne: 'inventory-work_order' }, 'count excludes WOs too') + t.alike(out.data.map(o => o.id), ['p1', 'p2']) + t.is(out.totalCount, 9) + t.is(out.hasMore, true) +}) + +test('handlers: listSpareParts honors an explicit type filter (overrides $ne default)', async (t) => { + const flow = listFlow() + await handlers.listSpareParts(flow.ctx, { query: { query: '{"type":"inventory-miner_part-psu"}' } }) + t.is(flow.lastList.query.type, 'inventory-miner_part-psu') +}) + +test('handlers: listSpareParts maps location/status shortcuts to info.* mingo paths', async (t) => { + const flow = listFlow() + await handlers.listSpareParts(flow.ctx, { + query: { location: 'Site Lab', status: 'faulty' } + }) + t.is(flow.lastList.query['info.location'], 'Site Lab') + t.is(flow.lastList.query['info.status'], 'faulty') +}) + +test('handlers: listSpareParts ?q builds case-insensitive $or against code / serialNum / macAddress', async (t) => { + const flow = listFlow() + await handlers.listSpareParts(flow.ctx, { query: { q: 'AB:CD:EF' } }) + const or = flow.lastList.query.$or + t.is(or.length, 3) + t.alike(or[0], { code: { $regex: 'AB:CD:EF', $options: 'i' } }) + t.alike(or[1], { 'info.serialNum': { $regex: 'AB:CD:EF', $options: 'i' } }) + t.alike(or[2], { 'info.macAddress': { $regex: 'AB:CD:EF', $options: 'i' } }) +}) + +test('handlers: listSpareParts ?q escapes regex metacharacters', async (t) => { + const flow = listFlow() + await handlers.listSpareParts(flow.ctx, { query: { q: 'a.b+c*' } }) + t.is(flow.lastList.query.$or[0].code.$regex, 'a\\.b\\+c\\*') +}) + +test('handlers: listSpareParts ANDs location/status/q in a single query payload', async (t) => { + const flow = listFlow() + await handlers.listSpareParts(flow.ctx, { + query: { location: 'Site Lab', status: 'faulty', q: 'PS-' } + }) + const q = flow.lastList.query + t.is(q['info.location'], 'Site Lab') + t.is(q['info.status'], 'faulty') + t.ok(Array.isArray(q.$or) && q.$or.length === 3) +}) + +test('handlers: getRepairHistory returns moves matching part id, newest first', async (t) => { + const wos = [ + { + id: 'wo-a', + code: 'IVI-2-0001', + info: { + partsMoves: [ + { partId: 'p-1', ts: 100, fromLocation: 'Lab', toLocation: 'Field' }, + { partId: 'other', ts: 110 } + ] + } + }, + { + id: 'wo-b', + code: 'IVI-2-0002', + info: { + partsMoves: [ + { partId: 'p-1', ts: 200, fromLocation: 'Field', toLocation: 'Lab' } + ] + } + } + ] + const ctx = createMockCtxWithOrks( + [{ rpcPublicKey: 'k' }], + async (_k, _m, params) => params.query?.['info.partsMoves.partId'] === 'p-1' ? wos : [] + ) + const out = await handlers.getRepairHistory(ctx, { params: { id: 'p-1' }, query: {} }) + t.is(out.totalCount, 2) + t.is(out.data[0].ts, 200, 'newest first') + t.is(out.data[0].workOrderCode, 'IVI-2-0002') + t.is(out.data[1].ts, 100) + t.is(out.data[1].workOrderCode, 'IVI-2-0001') +}) diff --git a/tests/unit/handlers/work.order.files.handlers.test.js b/tests/unit/handlers/work.order.files.handlers.test.js new file mode 100644 index 0000000..289404f --- /dev/null +++ b/tests/unit/handlers/work.order.files.handlers.test.js @@ -0,0 +1,208 @@ +'use strict' + +const test = require('brittle') +const handlers = require('../../../workers/lib/server/handlers/work.order.files.handlers') +const { createMockCtxWithOrks } = require('../helpers/mockHelpers') + +const RACK = 'inventory-work_order-rack-x' +const OPEN_WO = { + id: 'wo-1', + code: 'IVI-2-0001', + type: 'inventory-work_order', + info: { status: 'open', files: [] } +} +const CLOSED_WO = { + id: 'wo-1', + code: 'IVI-2-0001', + type: 'inventory-work_order', + info: { status: 'closed', files: [] } +} + +const mockAuthLib = { + getTokenPerms: async () => ({ permissions: ['inventory:rw', 'work_order:rw', 'actions:rw'] }) +} +const userMeta = (email = 'op@test') => ({ + _info: { authToken: 'tok', user: { metadata: { email } } } +}) + +function mockFile (mimetype, content, filename = 'file.bin') { + return { + filename, + mimetype, + toBuffer: async () => Buffer.from(content) + } +} + +function buildCtx ({ wo = OPEN_WO, storeResult, conf = {} } = {}) { + const pushed = [] + const fileCalls = [] + const handler = async (_key, method, params) => { + if (method === 'listThings') return wo ? [wo] : [] + if (method === 'storeFile' || method === 'loadFile' || method === 'removeFile') { + fileCalls.push({ method, params }) + } + if (method === 'storeFile') { + return storeResult ?? { + id: 'file-1', + name: params.name, + mime: params.mime, + size: Buffer.from(params.contentBase64, 'base64').length, + blobRef: { byteOffset: 0, blockOffset: 0, blockLength: 1, byteLength: 4 }, + ts: 100, + user: params.user + } + } + if (method === 'loadFile') return { contentBase64: Buffer.from('hello').toString('base64') } + if (method === 'removeFile') return { cleared: true } + if (method === 'pushAction') { pushed.push(params); return { id: 'act-1', errors: [] } } + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + ctx.authLib = mockAuthLib + ctx.conf = { ...ctx.conf, ...conf } + ctx._workOrderRackId = RACK + return { ctx, pushed, fileCalls } +} + +test('handlers: uploadWorkOrderFile 404s when WO is missing', async (t) => { + const { ctx } = buildCtx({ wo: null }) + await t.exception( + () => handlers.uploadWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-x' }, + file: async () => mockFile('image/png', 'AAAA') + }), + /ERR_WORK_ORDER_NOT_FOUND/ + ) +}) + +test('handlers: uploadWorkOrderFile rejects on closed WO', async (t) => { + const { ctx } = buildCtx({ wo: CLOSED_WO }) + await t.exception( + () => handlers.uploadWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + file: async () => mockFile('image/png', 'AAAA') + }), + /ERR_WO_INVALID_STATUS_TRANSITION/ + ) +}) + +test('handlers: uploadWorkOrderFile rejects disallowed mime', async (t) => { + const { ctx } = buildCtx() + await t.exception( + () => handlers.uploadWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + file: async () => mockFile('application/x-msdownload', 'AAAA', 'evil.exe') + }), + /ERR_FILE_MIME_NOT_ALLOWED/ + ) +}) + +test('handlers: uploadWorkOrderFile rejects oversize', async (t) => { + const { ctx } = buildCtx({ conf: { workOrderFileMaxBytes: 4 } }) + await t.exception( + () => handlers.uploadWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + file: async () => mockFile('image/png', 'AAAAAAAAA') + }), + /ERR_FILE_TOO_LARGE/ + ) +}) + +test('handlers: uploadWorkOrderFile rejects when file count cap reached', async (t) => { + const woWithCap = { + id: 'wo-1', info: { status: 'open', files: Array.from({ length: 2 }, (_, i) => ({ id: `f-${i}` })) } + } + const { ctx } = buildCtx({ wo: woWithCap, conf: { workOrderFileCountCap: 2 } }) + await t.exception( + () => handlers.uploadWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + file: async () => mockFile('image/png', 'AAAA') + }), + /ERR_WO_FILE_COUNT_CAP_REACHED/ + ) +}) + +test('handlers: uploadWorkOrderFile happy path stores blob and appends file metadata', async (t) => { + const { ctx, pushed, fileCalls } = buildCtx() + const out = await handlers.uploadWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + file: async () => mockFile('image/png', 'AAAA', 'photo.png') + }) + t.is(out.id, 'file-1') + t.is(out.mime, 'image/png') + t.is(out.size, 4) + t.is(pushed.length, 1, 'one pushAction updateThing fired') + t.is(pushed[0].action, 'updateThing') + t.is(pushed[0].params[0].info.files.length, 1) + t.is(pushed[0].params[0].info.files[0].id, 'file-1') + t.is(fileCalls[0].method, 'storeFile', 'uses the generic storeFile RPC') + t.is(fileCalls[0].params.type, 'work_order', 'tags the call with the work_order file type') +}) + +test('handlers: downloadWorkOrderFile 404s for unknown file id', async (t) => { + const { ctx } = buildCtx({ + wo: { id: 'wo-1', info: { status: 'open', files: [{ id: 'f-known', blobRef: 'ref' }] } } + }) + await t.exception( + () => handlers.downloadWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1', fileId: 'f-other' } + }, { header: () => {}, send: () => {} }), + /ERR_WO_FILE_NOT_FOUND/ + ) +}) + +test('handlers: downloadWorkOrderFile streams blob content with content-type header', async (t) => { + const wo = { id: 'wo-1', info: { status: 'open', files: [{ id: 'f-1', name: 'photo.png', mime: 'image/png', blobRef: 'ref' }] } } + const { ctx } = buildCtx({ wo }) + + const headers = {} + let sent + const rep = { + header: (k, v) => { headers[k] = v }, + send: (buf) => { sent = buf } + } + await handlers.downloadWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1', fileId: 'f-1' } + }, rep) + t.is(headers['content-type'], 'image/png') + t.is(headers['content-disposition'], 'attachment; filename="photo.png"') + t.is(Buffer.compare(sent, Buffer.from('hello')), 0) +}) + +test('handlers: deleteWorkOrderFile removes blob + strips metadata', async (t) => { + const wo = { id: 'wo-1', info: { status: 'open', files: [{ id: 'f-1', blobRef: 'ref' }, { id: 'f-2', blobRef: 'ref2' }] } } + const { ctx, pushed, fileCalls } = buildCtx({ wo }) + const out = await handlers.deleteWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1', fileId: 'f-1' } + }) + t.is(out.id, 'f-1') + t.is(out.blobCleared, true, 'surfaces whether the rack cleared the blob') + t.is(pushed.length, 1) + const updated = pushed[0].params[0].info.files + t.is(updated.length, 1) + t.is(updated[0].id, 'f-2') + const removeCall = fileCalls.find(c => c.method === 'removeFile') + t.is(removeCall.params.workOrderId, 'wo-1', 'scopes the rack call to the owning WO') + t.is(removeCall.params.fileId, 'f-1', 'passes fileId, not a raw blobRef') +}) + +test('handlers: deleteWorkOrderFile blocked on closed WO', async (t) => { + const wo = { id: 'wo-1', info: { status: 'closed', files: [{ id: 'f-1', blobRef: 'ref' }] } } + const { ctx } = buildCtx({ wo }) + await t.exception( + () => handlers.deleteWorkOrderFile(ctx, { + ...userMeta(), + params: { id: 'wo-1', fileId: 'f-1' } + }), + /ERR_WO_INVALID_STATUS_TRANSITION/ + ) +}) diff --git a/tests/unit/handlers/work.orders.handlers.test.js b/tests/unit/handlers/work.orders.handlers.test.js new file mode 100644 index 0000000..bfeca37 --- /dev/null +++ b/tests/unit/handlers/work.orders.handlers.test.js @@ -0,0 +1,323 @@ +'use strict' + +const test = require('brittle') +const handlers = require('../../../workers/lib/server/handlers/work.orders.handlers') +const { createMockCtxWithOrks } = require('../helpers/mockHelpers') + +const RACK = 'inventory-work_order-rack-x' + +const userMeta = (email = 'op@test') => ({ + _info: { authToken: 'tok', user: { metadata: { email } } } +}) + +const mockAuthLib = { + getTokenPerms: async () => ({ permissions: ['inventory:rw', 'work_order:rw', 'actions:rw'] }) +} + +function buildSubmitFlow ({ rackId = RACK, parts = [] } = {}) { + let lastPush + const handler = async (_key, method, params) => { + if (method === 'pushAction') { + lastPush = params + return { id: 'action-1', errors: [] } + } + if (method === 'listThings') return parts + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = rackId + return { ctx, get lastPush () { return lastPush } } +} + +test('handlers: createWorkOrder Type 2 resolves part and forwards body as info', async (t) => { + const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'AM-1' } }] }) + await handlers.createWorkOrder(flow.ctx, { + ...userMeta(), + body: { + type: 2, + deviceType: 'miner', + deviceModel: 'antminer-s19xp', + deviceIdentifier: 'AM-1', + issue: 'fan stopped' + } + }) + t.is(flow.lastPush.action, 'registerThing') + t.is(flow.lastPush.params[0].info.deviceIdentifier, 'AM-1') + t.is(flow.lastPush.params[0].info.partsMoves[0].partId, 'part-1') + t.is(flow.lastPush.params[0].info.partsMoves[0].role, 'diagnosis') +}) + +test('handlers: createWorkOrder rejects unknown deviceType with ERR_INVALID_DEVICE_TYPE', async (t) => { + const flow = buildSubmitFlow() + await t.exception( + () => handlers.createWorkOrder(flow.ctx, { + ...userMeta(), + body: { type: 2, deviceType: 'cooling', deviceModel: 'm', deviceIdentifier: 'x', issue: 'i' } + }), + /ERR_INVALID_DEVICE_TYPE/ + ) +}) + +test('handlers: createWorkOrder 400s ERR_PART_NOT_FOUND when deviceIdentifier resolves to nothing', async (t) => { + const flow = buildSubmitFlow({ parts: [] }) + await t.exception( + () => handlers.createWorkOrder(flow.ctx, { + ...userMeta(), + body: { type: 2, deviceType: 'psu', deviceModel: 'm', deviceIdentifier: 'unknown-sn', issue: 'i' } + }), + /ERR_PART_NOT_FOUND/ + ) +}) + +test('handlers: updateWorkOrder forwards warranty payload to updateThing', async (t) => { + const flow = buildSubmitFlow() + await handlers.updateWorkOrder(flow.ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + body: { warranty: { vendor: 'microbt', fields: { rmaNumber: 'RMA-1', faultCode: 'E03' } } } + }) + t.is(flow.lastPush.action, 'updateThing') + t.is(flow.lastPush.params[0].info.warranty.vendor, 'microbt') + t.is(flow.lastPush.params[0].info.warranty.fields.rmaNumber, 'RMA-1') +}) + +test('handlers: closeWorkOrder maps to updateThing with status=closed and finalResult', async (t) => { + const flow = buildSubmitFlow() + await handlers.closeWorkOrder(flow.ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + body: { finalResult: 'replaced PSU' } + }) + t.is(flow.lastPush.action, 'updateThing') + t.is(flow.lastPush.params[0].id, 'wo-1') + t.is(flow.lastPush.params[0].info.status, 'closed') + t.is(flow.lastPush.params[0].info.finalResult, 'replaced PSU') +}) + +test('handlers: cancelWorkOrder maps to updateThing with status=cancelled', async (t) => { + const flow = buildSubmitFlow() + await handlers.cancelWorkOrder(flow.ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + body: { reason: 'duplicate' } + }) + t.is(flow.lastPush.params[0].info.status, 'cancelled') + t.is(flow.lastPush.params[0].info.cancelReason, 'duplicate') +}) + +test('handlers: assignWorkOrder maps to updateThing with assignedTo', async (t) => { + const flow = buildSubmitFlow() + await handlers.assignWorkOrder(flow.ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + body: { assignedTo: 'tech@test' } + }) + t.is(flow.lastPush.params[0].info.assignedTo, 'tech@test') +}) + +function listFlow ({ items = [], total = 0 } = {}) { + let lastList, lastCount + const handler = async (_key, method, params) => { + if (method === 'listThings') { lastList = params; return items } + if (method === 'getThingsCount') { lastCount = params; return total } + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + return { + ctx, + get lastList () { return lastList }, + get lastCount () { return lastCount } + } +} + +test('handlers: listWorkOrders returns paginated envelope with type pinned', async (t) => { + const flow = listFlow({ items: [{ id: 'a' }, { id: 'b' }], total: 7 }) + const out = await handlers.listWorkOrders(flow.ctx, { query: { offset: 0, limit: 2 } }) + t.is(flow.lastList.query.type, 'inventory-work_order', 'list pinned to WO type') + t.is(flow.lastCount.query.type, 'inventory-work_order', 'count pinned to WO type') + t.alike(out.data.map(o => o.id), ['a', 'b']) + t.is(out.totalCount, 7) + t.is(out.offset, 0) + t.is(out.limit, 2) + t.is(out.hasMore, true) +}) + +test('handlers: listWorkOrders passes a JSON-encoded mingo query straight through', async (t) => { + const flow = listFlow() + await handlers.listWorkOrders(flow.ctx, { query: { query: '{"info.status":"open"}' } }) + t.is(flow.lastList.query['info.status'], 'open', 'mingo passthrough') + t.is(flow.lastList.query.type, 'inventory-work_order', 'type still pinned') +}) + +test('handlers: listWorkOrders ?q builds a regex $or against code and info.issue', async (t) => { + const flow = listFlow() + await handlers.listWorkOrders(flow.ctx, { query: { q: 'IVI-2-0001' } }) + const or = flow.lastList.query.$or + t.is(or.length, 2) + t.alike(or[0], { code: { $regex: 'IVI-2-0001' } }) + t.alike(or[1], { 'info.issue': { $regex: 'IVI-2-0001', $options: 'i' } }) +}) + +test('handlers: listWorkOrders ?q escapes regex metacharacters', async (t) => { + const flow = listFlow() + await handlers.listWorkOrders(flow.ctx, { query: { q: 'a.b+c*' } }) + t.is(flow.lastList.query.$or[0].code.$regex, 'a\\.b\\+c\\*') +}) + +test('handlers: listWorkOrders shortcuts map to mingo paths', async (t) => { + const flow = listFlow() + await handlers.listWorkOrders(flow.ctx, { + query: { + assignee: 'u123', + creator: 'op@test', + partId: 'PSU-WM-CB6_V5-01', + status: 'open', + type: 2, + from: 1700000000000, + to: 1700864000000 + } + }) + t.is(flow.lastList.query['info.assignedTo'], 'u123') + t.is(flow.lastList.query['info.createdBy'], 'op@test') + t.is(flow.lastList.query['info.partsMoves.partCode'], 'PSU-WM-CB6_V5-01') + t.is(flow.lastList.query['info.status'], 'open') + t.is(flow.lastList.query['info.type'], 2) + t.alike(flow.lastList.query['info.createdAt'], { $gte: 1700000000000, $lte: 1700864000000 }) +}) + +test('handlers: listWorkOrders ?from alone produces a $gte-only range', async (t) => { + const flow = listFlow() + await handlers.listWorkOrders(flow.ctx, { query: { from: 100 } }) + t.alike(flow.lastList.query['info.createdAt'], { $gte: 100 }) +}) + +test('handlers: getWorkOrder filters by id+type and 404s when nothing found', async (t) => { + const ctx = createMockCtxWithOrks( + [{ rpcPublicKey: 'k' }], + async (_k, _m, params) => params.query?.id === 'found' ? [{ id: 'found', code: 'IVI-2-0001' }] : [] + ) + const ok = await handlers.getWorkOrder(ctx, { params: { id: 'found' } }) + t.is(ok.id, 'found') + await t.exception( + () => handlers.getWorkOrder(ctx, { params: { id: 'missing' } }), + /ERR_WORK_ORDER_NOT_FOUND/ + ) +}) + +test('handlers: appendWorkLogEntry rejects when WO is closed/cancelled', async (t) => { + const ctx = createMockCtxWithOrks( + [{ rpcPublicKey: 'k' }], + async (_k, method) => method === 'listThings' ? [{ id: 'wo-1', info: { status: 'closed' } }] : null + ) + ctx._workOrderRackId = RACK + await t.exception( + () => handlers.appendWorkLogEntry(ctx, { + ...userMeta(), params: { id: 'wo-1' }, body: { text: 'late entry' } + }), + /ERR_WO_INVALID_STATUS_TRANSITION/ + ) +}) + +test('handlers: appendWorkLogEntry 404s when WO is missing', async (t) => { + const ctx = createMockCtxWithOrks( + [{ rpcPublicKey: 'k' }], + async (_k, method) => method === 'listThings' ? [] : null + ) + ctx._workOrderRackId = RACK + await t.exception( + () => handlers.appendWorkLogEntry(ctx, { + ...userMeta(), params: { id: 'wo-missing' }, body: { text: 'x' } + }), + /ERR_WORK_ORDER_NOT_FOUND/ + ) +}) + +test('handlers: appendWorkLogEntry calls saveThingComment with the right rack/thingId/user', async (t) => { + let captured + const ctx = createMockCtxWithOrks( + [{ rpcPublicKey: 'k' }], + async (_k, method, params) => { + if (method === 'listThings') return [{ id: 'wo-1', info: { status: 'open' } }] + if (method === 'saveThingComment') { captured = params; return 1 } + return null + } + ) + ctx._workOrderRackId = RACK + await handlers.appendWorkLogEntry(ctx, { + ...userMeta(), + params: { id: 'wo-1' }, + body: { text: 'replaced PSU' } + }) + t.is(captured.rackId, RACK) + t.is(captured.thingId, 'wo-1') + t.is(captured.comment, 'replaced PSU') + t.is(captured.user, 'op@test') +}) + +function mkRep () { + const headers = {} + let body + let status = 200 + return { + header: (k, v) => { headers[k] = v }, + status: (s) => { status = s; return { send: (b) => { body = b } } }, + send: (b) => { body = b; return this }, + get _headers () { return headers }, + get _body () { return body }, + get _status () { return status } + } +} + +test('handlers: exportWorkOrder pdf returns 501 (deferred to phase 2)', async (t) => { + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async () => []) + const rep = mkRep() + await handlers.exportWorkOrder(ctx, { params: { id: 'IVI-2-0001' }, query: { format: 'pdf' } }, rep) + t.is(rep._status, 501) + t.ok(/^ERR_EXPORT_FORMAT_NOT_IMPLEMENTED:(pdf|docx)$/.test(rep._body.message)) +}) + +test('handlers: exportWorkOrder docx returns 501 (deferred to phase 2)', async (t) => { + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async () => []) + const rep = mkRep() + await handlers.exportWorkOrder(ctx, { params: { id: 'IVI-2-0001' }, query: { format: 'docx' } }, rep) + t.is(rep._status, 501) + t.ok(/^ERR_EXPORT_FORMAT_NOT_IMPLEMENTED:(pdf|docx)$/.test(rep._body.message)) +}) + +test('handlers: exportWorkOrder 404s when WO not found by id or code', async (t) => { + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async () => []) + await t.exception( + () => handlers.exportWorkOrder(ctx, { params: { id: 'nope' }, query: { format: 'csv' } }, mkRep()), + /ERR_WORK_ORDER_NOT_FOUND/ + ) +}) + +test('handlers: exportWorkOrder csv sets text/csv content-type and attachment filename', async (t) => { + const wo = { id: 'wo-1', code: 'IVI-2-0001', info: { status: 'open', type: 2, partsMoves: [] } } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async () => [wo]) + const rep = mkRep() + await handlers.exportWorkOrder(ctx, { params: { id: 'IVI-2-0001' }, query: { format: 'csv' } }, rep) + t.is(rep._headers['content-type'], 'text/csv; charset=utf-8') + t.ok(rep._headers['content-disposition'].includes('IVI-2-0001.csv')) + t.ok(typeof rep._body === 'string' && rep._body.startsWith('code,status,type')) +}) + +test('handlers: getWorkOrderAudit calls getHistoricalLogs filtered by id', async (t) => { + let received + const ctx = createMockCtxWithOrks( + [{ rpcPublicKey: 'k' }], + async (_k, method, params) => { + received = { method, params } + return [{ ts: 1, changes: { status: { oldValue: 'open', newValue: 'closed' } } }] + } + ) + const out = await handlers.getWorkOrderAudit(ctx, { + params: { id: 'wo-1' }, + query: { limit: 50 } + }) + t.is(received.method, 'getHistoricalLogs') + t.is(received.params.logType, 'info') + t.is(received.params.query['thing.id'], 'wo-1') + t.is(out.length, 1) +}) diff --git a/tests/unit/lib/utils.test.js b/tests/unit/lib/utils.test.js index 9c185e0..6e741f4 100644 --- a/tests/unit/lib/utils.test.js +++ b/tests/unit/lib/utils.test.js @@ -8,7 +8,11 @@ const { isValidEmail, getRpcTimeout, getAuthTokenFromHeaders, - parseJsonQueryParam + parseJsonQueryParam, + escapeRegex, + csvEscape, + stableJsonString, + listThingsWithCount } = require('../../../workers/lib/utils') const randomIPv4 = () => { @@ -437,3 +441,81 @@ test('parseJsonQueryParam - with custom error code', (t) => { t.pass() }) + +test('escapeRegex - escapes mingo regex metacharacters', (t) => { + t.is(escapeRegex('a.b+c*'), 'a\\.b\\+c\\*') + t.is(escapeRegex('AB:CD:EF'), 'AB:CD:EF', 'preserves non-metachars like ":"') + t.is(escapeRegex('(x|y)?'), '\\(x\\|y\\)\\?') +}) + +test('csvEscape - blanks null/undefined, quotes only when needed, doubles inner quotes', (t) => { + t.is(csvEscape(null), '') + t.is(csvEscape(undefined), '') + t.is(csvEscape('plain'), 'plain', 'no wrapping when no metacharacters') + t.is(csvEscape('a,b'), '"a,b"', 'comma forces quoting') + t.is(csvEscape('line1\nline2'), '"line1\nline2"', 'newline forces quoting') + t.is(csvEscape('say "hi"'), '"say ""hi"""', 'inner quotes doubled') + t.is(csvEscape(42), '42', 'non-strings JSON-stringified') + t.is(csvEscape({ a: 1 }), '"{""a"":1}"', 'objects stringified then escaped') +}) + +test('stableJsonString - sorts top-level keys so semantically-equal queries share cache entries', (t) => { + t.is(stableJsonString('{"b":2,"a":1}'), stableJsonString('{"a":1,"b":2}')) + t.is(stableJsonString('{"a":1,"b":2}'), '{"a":1,"b":2}') +}) + +test('stableJsonString - passes through non-strings, primitives, and malformed JSON', (t) => { + t.is(stableJsonString(undefined), undefined) + t.is(stableJsonString(42), 42) + t.is(stableJsonString('not json'), 'not json') + t.is(stableJsonString('"just a string"'), '"just a string"', 'wrapped primitive is not reordered') + t.is(stableJsonString('null'), 'null') +}) + +test('listThingsWithCount - issues listThings + getThingsCount in parallel and returns envelope', async (t) => { + const calls = [] + const ctx = { + dataProxy: { + requestData: async (method, params) => { + calls.push({ method, params }) + if (method === 'listThings') return [[{ id: 'a' }, { id: 'b' }]] + if (method === 'getThingsCount') return [5] + } + } + } + const out = await listThingsWithCount(ctx, { type: 'x' }, { offset: 2, limit: 2 }) + t.is(calls.length, 2) + t.alike(calls.map(c => c.method).sort(), ['getThingsCount', 'listThings']) + t.alike(out.data.map(d => d.id), ['a', 'b']) + t.is(out.totalCount, 5) + t.is(out.offset, 2) + t.is(out.limit, 2) + t.is(out.hasMore, true, 'offset(2) + page(2) < total(5)') +}) + +test('listThingsWithCount - hasMore=false when full result returned', async (t) => { + const ctx = { + dataProxy: { + requestData: async (method) => method === 'listThings' ? [[{ id: 'a' }]] : [1] + } + } + const out = await listThingsWithCount(ctx, {}, { offset: 0, limit: 10 }) + t.is(out.hasMore, false) +}) + +test('listThingsWithCount - caps data at limit when multiple orks each return up to limit items', async (t) => { + const ctx = { + dataProxy: { + requestData: async (method) => method === 'listThings' + ? [ + [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + [{ id: 'd' }, { id: 'e' }, { id: 'f' }] + ] + : [3, 3] + } + } + const out = await listThingsWithCount(ctx, {}, { offset: 0, limit: 4 }) + t.is(out.data.length, 4, 'capped to limit even though 6 unique items came back across 2 orks') + t.is(out.totalCount, 6) + t.is(out.hasMore, true) +}) diff --git a/tests/unit/lib/work.order.export.test.js b/tests/unit/lib/work.order.export.test.js new file mode 100644 index 0000000..b403cbd --- /dev/null +++ b/tests/unit/lib/work.order.export.test.js @@ -0,0 +1,62 @@ +'use strict' + +const test = require('brittle') +const { renderWorkOrderCsv } = require('../../../workers/lib/server/lib/work.order.export') + +const WO = { + id: 'wo-1', + code: 'IVI-2-0001', + info: { + type: 2, + status: 'open', + deviceType: 'psu', + deviceModel: 'PSU-WM-CB6_V5', + deviceIdentifier: 'SN-1', + issue: 'PSU fan dead', + assignedTo: 'tech@test', + createdBy: 'op@test', + createdAt: 1700000000000, + finalResult: null, + warranty: { vendor: 'microbt', fields: { rmaNumber: 'RMA-9', faultCode: 'E03' } }, + partsMoves: [ + { ts: 1700000001000, user: 'op@test', role: 'diagnosis', partId: 'p1', partCode: 'PS-1', fromLocation: 'Lab', toLocation: 'Field', fromStatus: 'active', toStatus: 'active' }, + { ts: 1700000002000, user: 'op@test', role: 'replacement', partId: 'p1', partCode: 'PS-1', fromLocation: 'Field', toLocation: 'Lab', fromStatus: 'active', toStatus: 'in_repair' } + ] + } +} + +test('work.order.export: CSV header is derived from the work order json property names', (t) => { + const header = renderWorkOrderCsv(WO).split('\r\n')[0].split(',') + const woKeys = ['code', ...Object.keys(WO.info).filter(k => k !== 'partsMoves')] + const moveKeys = Object.keys(WO.info.partsMoves[0]) + t.alike(header, [...woKeys, ...moveKeys], 'header is code + info fields + movement fields, not a hardcoded list') +}) + +test('work.order.export: a new info field appears as a column without code changes', (t) => { + const wo = { ...WO, info: { ...WO.info, slaBreached: true } } + const header = renderWorkOrderCsv(wo).split('\r\n')[0].split(',') + t.ok(header.includes('slaBreached'), 'newly added json field is picked up dynamically') +}) + +test('work.order.export: CSV emits one row per parts-movement entry', (t) => { + const csv = renderWorkOrderCsv(WO) + const lines = csv.trim().split('\r\n') + t.is(lines.length, 1 + WO.info.partsMoves.length, 'header + one row per move') + t.ok(lines[1].includes('IVI-2-0001'), 'wo code repeated on each row') + t.ok(lines[1].includes('diagnosis')) + t.ok(lines[2].includes('replacement')) +}) + +test('work.order.export: CSV with no movements still emits a single data row', (t) => { + const empty = { ...WO, info: { ...WO.info, partsMoves: [] } } + const csv = renderWorkOrderCsv(empty) + const lines = csv.trim().split('\r\n') + t.is(lines.length, 2) + t.ok(lines[1].startsWith('IVI-2-0001')) +}) + +test('work.order.export: CSV escapes commas / quotes / newlines in field values', (t) => { + const wo = { ...WO, info: { ...WO.info, issue: 'fan, broken\nreplaced' } } + const csv = renderWorkOrderCsv(wo) + t.ok(csv.includes('"fan, broken\nreplaced"'), 'value with comma/newline wrapped in quotes') +}) diff --git a/tests/unit/lib/work.orders.test.js b/tests/unit/lib/work.orders.test.js new file mode 100644 index 0000000..decab61 --- /dev/null +++ b/tests/unit/lib/work.orders.test.js @@ -0,0 +1,94 @@ +'use strict' + +const test = require('brittle') +const { + getWorkOrderRackId, + submitWorkOrderAction +} = require('../../../workers/lib/server/lib/work.orders') + +const RACK_ID = 'inventory-work_order-rack-x' + +const woReq = (email = 'op@test') => ({ + _info: { authToken: 'tok', user: { metadata: { email } } } +}) + +function buildCtx ({ racks = [{ id: RACK_ID }], pushResult = { id: 'action-1', errors: [] }, captured } = {}) { + return { + authLib: { + getTokenPerms: async () => ({ permissions: ['inventory:rw', 'work_order:rw', 'actions:rw'] }) + }, + dataProxy: { + requestData: async (method, payload, errorHandler) => { + if (captured) { + captured.calls = captured.calls || [] + captured.calls.push(method) + } + if (method === 'listRacks') return [racks] + if (method === 'pushAction') { + if (captured) captured.payload = payload + const arr = [] + if (errorHandler) errorHandler(pushResult, arr) + return arr + } + return [] + } + } + } +} + +test('getWorkOrderRackId - resolves the WO rack id from the ork rack registry', async (t) => { + const captured = {} + const id = await getWorkOrderRackId(buildCtx({ captured })) + t.is(id, RACK_ID, 'returns the rack id carrying the work_order type') + t.alike(captured.calls, ['listRacks'], 'asks the ork via listRacks') +}) + +test('getWorkOrderRackId - caches the resolved id and skips a second RPC', async (t) => { + const captured = {} + const ctx = buildCtx({ captured }) + await getWorkOrderRackId(ctx) + const id = await getWorkOrderRackId(ctx) + t.is(id, RACK_ID) + t.is(ctx._workOrderRackId, RACK_ID, 'cached on ctx') + t.alike(captured.calls, ['listRacks'], 'only one listRacks round-trip for repeated calls') +}) + +test('getWorkOrderRackId - returns the pre-set ctx cache without any RPC', async (t) => { + const captured = {} + const ctx = buildCtx({ captured }) + ctx._workOrderRackId = 'preset-rack' + const id = await getWorkOrderRackId(ctx) + t.is(id, 'preset-rack') + t.is(captured.calls, undefined, 'no RPC issued when already cached') +}) + +test('getWorkOrderRackId - throws ERR_WORK_ORDER_RACK_NOT_FOUND when the ork has no WO rack', async (t) => { + await t.exception(() => getWorkOrderRackId(buildCtx({ racks: [] })), /ERR_WORK_ORDER_RACK_NOT_FOUND/) +}) + +test('submitWorkOrderAction - submits pushAction against the resolved WO rack', async (t) => { + const captured = {} + const out = await submitWorkOrderAction(buildCtx({ captured }), woReq(), 'registerThing', { info: { foo: 'bar' } }) + + t.alike(captured.calls, ['listRacks', 'pushAction'], 'resolves the rack then pushes') + t.is(captured.payload.action, 'registerThing') + t.is(captured.payload.query.rack, RACK_ID) + t.is(captured.payload.params[0].rackId, RACK_ID) + t.is(captured.payload.params[0].info.foo, 'bar') + t.is(captured.payload.voter, 'op@test') + t.alike(captured.payload.authPerms, ['inventory:rw', 'work_order:rw', 'actions:rw']) + t.alike(out, [{ id: 'action-1', errors: [] }]) +}) + +test('submitWorkOrderAction - maps an rpc error into the result array', async (t) => { + const out = await submitWorkOrderAction(buildCtx({ pushResult: { error: 'boom' } }), woReq(), 'updateThing', { id: 'wo-1' }) + t.is(out[0].id, null) + t.is(out[0].errors[0], 'boom') +}) + +test('submitWorkOrderAction - throws ERR_WORK_ORDER_RACK_NOT_FOUND when no WO rack exists', async (t) => { + await t.exception( + () => submitWorkOrderAction(buildCtx({ racks: [] }), woReq(), 'registerThing', {}), + /ERR_WORK_ORDER_RACK_NOT_FOUND/ + ) +}) diff --git a/tests/unit/routes/spare.parts.routes.test.js b/tests/unit/routes/spare.parts.routes.test.js new file mode 100644 index 0000000..1fabd2e --- /dev/null +++ b/tests/unit/routes/spare.parts.routes.test.js @@ -0,0 +1,63 @@ +'use strict' + +const test = require('brittle') +const { + testModuleStructure, + testHandlerFunctions, + testOnRequestFunctions +} = require('../helpers/routeTestHelpers') +const { ENDPOINTS, HTTP_METHODS } = require('../../../workers/lib/constants') + +const ROUTES_PATH = '../../../workers/lib/server/routes/spare.parts.routes' + +test('spare.parts.routes: module structure', (t) => { + const routes = testModuleStructure(t, ROUTES_PATH, 'spare.parts') + testHandlerFunctions(t, routes, 'spare.parts') + testOnRequestFunctions(t, routes, 'spare.parts') +}) + +test('spare.parts.routes: registers expected endpoints', (t) => { + const routes = require(ROUTES_PATH)({}) + const expected = [ + { method: HTTP_METHODS.POST, url: ENDPOINTS.SPARE_PARTS }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.SPARE_PARTS }, + { method: HTTP_METHODS.PUT, url: ENDPOINTS.SPARE_PART_BY_ID }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.SPARE_PART_REPAIR_HISTORY } + ] + for (const e of expected) { + const found = routes.find(r => r.method === e.method && r.url === e.url) + t.ok(found, `${e.method} ${e.url}`) + } +}) + +test('spare.parts.routes: list cache key includes every filter shortcut', (t) => { + const { createCachedAuthRoute } = require('../../../workers/lib/server/lib/routeHelpers') + let capturedKeyFn + const orig = createCachedAuthRoute + require.cache[require.resolve('../../../workers/lib/server/lib/routeHelpers')].exports.createCachedAuthRoute = + (ctx, keyParts, endpoint, handler, perms) => { + if (endpoint === ENDPOINTS.SPARE_PARTS) capturedKeyFn = keyParts + return orig(ctx, keyParts, endpoint, handler, perms) + } + delete require.cache[require.resolve(ROUTES_PATH)] + require(ROUTES_PATH)({}) + + const req = { + query: { + query: '{"info.foo":1}', + sort: '{"code":1}', + fields: '{}', + offset: 0, + limit: 10, + q: 'AB:CD', + location: 'Site Lab', + status: 'faulty' + } + } + const key = capturedKeyFn(req) + for (const expected of ['{"info.foo":1}', '{"code":1}', '{}', 0, 10, 'AB:CD', 'Site Lab', 'faulty']) { + t.ok(key.includes(expected), `cache key includes ${JSON.stringify(expected)}`) + } + + require.cache[require.resolve('../../../workers/lib/server/lib/routeHelpers')].exports.createCachedAuthRoute = orig +}) diff --git a/tests/unit/routes/work.order.files.routes.test.js b/tests/unit/routes/work.order.files.routes.test.js new file mode 100644 index 0000000..f1fe01e --- /dev/null +++ b/tests/unit/routes/work.order.files.routes.test.js @@ -0,0 +1,28 @@ +'use strict' + +const test = require('brittle') +const { + testModuleStructure, + testHandlerFunctions, + testOnRequestFunctions +} = require('../helpers/routeTestHelpers') +const { ENDPOINTS, HTTP_METHODS } = require('../../../workers/lib/constants') + +const ROUTES_PATH = '../../../workers/lib/server/routes/work.order.files.routes' + +test('work.order.files.routes: module structure', (t) => { + const routes = testModuleStructure(t, ROUTES_PATH, 'work.order.files') + testHandlerFunctions(t, routes, 'work.order.files') + testOnRequestFunctions(t, routes, 'work.order.files') +}) + +test('work.order.files.routes: registers POST/GET/DELETE on the right urls', (t) => { + const routes = require(ROUTES_PATH)({}) + for (const e of [ + { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_FILES }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_FILE_BY_ID }, + { method: HTTP_METHODS.DELETE, url: ENDPOINTS.WORK_ORDER_FILE_BY_ID } + ]) { + t.ok(routes.find(r => r.method === e.method && r.url === e.url), `${e.method} ${e.url}`) + } +}) diff --git a/tests/unit/routes/work.orders.routes.test.js b/tests/unit/routes/work.orders.routes.test.js new file mode 100644 index 0000000..e64acc0 --- /dev/null +++ b/tests/unit/routes/work.orders.routes.test.js @@ -0,0 +1,81 @@ +'use strict' + +const test = require('brittle') +const { + testModuleStructure, + testHandlerFunctions, + testOnRequestFunctions +} = require('../helpers/routeTestHelpers') +const { ENDPOINTS, HTTP_METHODS } = require('../../../workers/lib/constants') + +const ROUTES_PATH = '../../../workers/lib/server/routes/work.orders.routes' + +test('work.orders.routes: module structure', (t) => { + const routes = testModuleStructure(t, ROUTES_PATH, 'work.orders') + testHandlerFunctions(t, routes, 'work.orders') + testOnRequestFunctions(t, routes, 'work.orders') +}) + +test('work.orders.routes: registers every WO endpoint', (t) => { + const routes = require(ROUTES_PATH)({}) + const expected = [ + { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDERS }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDERS }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_BY_ID }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_AUDIT }, + { method: HTTP_METHODS.PATCH, url: ENDPOINTS.WORK_ORDER_BY_ID }, + { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_CLOSE }, + { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_CANCEL }, + { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_ASSIGN }, + { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_LOG }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_EXPORT } + ] + for (const e of expected) { + const found = routes.find(r => r.method === e.method && r.url === e.url) + t.ok(found, `route ${e.method} ${e.url} present`) + } +}) + +test('work.orders.routes: every route has onRequest auth guard', (t) => { + const routes = require(ROUTES_PATH)({}) + for (const r of routes) { + t.ok(typeof r.onRequest === 'function', `${r.method} ${r.url} has onRequest`) + } +}) + +test('work.orders.routes: list cache key includes every filter shortcut', (t) => { + const { createCachedAuthRoute } = require('../../../workers/lib/server/lib/routeHelpers') + let capturedKeyFn + const orig = createCachedAuthRoute + require.cache[require.resolve('../../../workers/lib/server/lib/routeHelpers')].exports.createCachedAuthRoute = + (ctx, keyParts, endpoint, handler, perms) => { + if (endpoint === ENDPOINTS.WORK_ORDERS) capturedKeyFn = keyParts + return orig(ctx, keyParts, endpoint, handler, perms) + } + delete require.cache[require.resolve(ROUTES_PATH)] + require(ROUTES_PATH)({}) + + const req = { + query: { + query: '{"a":1}', + sort: '{"code":1}', + fields: '{}', + offset: 0, + limit: 10, + q: 'IVI', + assignee: 'u', + creator: 'c', + partId: 'p', + status: 'open', + type: 2, + from: 1, + to: 2 + } + } + const key = capturedKeyFn(req) + for (const expected of ['{"a":1}', '{"code":1}', '{}', 0, 10, 'IVI', 'u', 'c', 'p', 'open', 2, 1, 2]) { + t.ok(key.includes(expected), `cache key includes ${JSON.stringify(expected)}`) + } + + require.cache[require.resolve('../../../workers/lib/server/lib/routeHelpers')].exports.createCachedAuthRoute = orig +}) diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index d2e2584..c79ca89 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -2,6 +2,8 @@ const async = require('async') const WebsocketPlugin = require('@fastify/websocket') +const MultipartPlugin = require('@fastify/multipart') +const { WORK_ORDER_FILE_MAX_BYTES_DEFAULT } = require('./lib/constants') const TetherWrkBase = require('@tetherto/tether-wrk-base/workers/base.wrk.tether') const AuthLib = require('./lib/auth') const debug = require('debug')('store:aggr') @@ -99,6 +101,9 @@ class WrkServerHttp extends TetherWrkBase { } httpd.addPlugin([WebsocketPlugin, {}]) + httpd.addPlugin([MultipartPlugin, { + limits: { fileSize: this.conf.workOrderFileMaxBytes || WORK_ORDER_FILE_MAX_BYTES_DEFAULT, files: 1 } + }]) libServer.routes(this).forEach(r => { httpd.addRoute(r) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 085d5cc..f9e7ecc 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -39,8 +39,24 @@ const AUTH_PERMISSIONS = { TICKETS: 'tickets', POWER_SPOT_FORECAST: 'power_spot_forecast', POOL_CONFIG: 'pool_config', - POOL_CONFIG_APPROVE: 'pool_config_approve' -} + POOL_CONFIG_APPROVE: 'pool_config_approve', + WORK_ORDER: 'work_order' +} + +const WORK_ORDER_THING_TYPE = 'inventory-work_order' + +const WORK_ORDER_TYPES = { REGISTER: 1, REGULAR: 2 } +const WORK_ORDER_TERMINAL_STATUSES = ['closed', 'cancelled'] +const WORK_ORDER_VALID_DEVICE_TYPES = ['miner', 'psu', 'hashboard', 'controller'] +const MINER_LOCATIONS = ['Site Warehouse', 'Site Lab', 'Miner Room', 'Vendor', 'Scrapped', 'Disposed'] +const SPARE_PART_INITIAL_LOCATION = 'Site Warehouse' +const FILE_TYPES = { WORK_ORDER: 'work_order' } +const WORK_ORDER_FILE_MAX_BYTES_DEFAULT = 10 * 1024 * 1024 +const WORK_ORDER_FILE_COUNT_CAP_DEFAULT = 20 +const WORK_ORDER_FILE_MIME_ALLOWLIST_DEFAULT = [ + 'image/png', 'image/jpeg', 'image/webp', 'image/gif', + 'application/pdf', 'text/plain', 'text/csv', 'application/json' +] const AUTH_LEVELS = { READ: 'r', @@ -168,9 +184,28 @@ const ENDPOINTS = { SITE_EFFICIENCY: '/auth/site/efficiency', // Explorer endpoints EXPLORER_RACKS: '/auth/explorer/racks', + // Energy endpoints ENERGY_FORECAST: '/auth/energy/forecast', - ENERGY_AVAILABLE: '/auth/energy/available' -} + ENERGY_AVAILABLE: '/auth/energy/available', + // Work Order endpoints + WORK_ORDERS: '/auth/work-orders', + WORK_ORDER_BY_ID: '/auth/work-orders/:id', + WORK_ORDER_AUDIT: '/auth/work-orders/:id/audit', + WORK_ORDER_LOG: '/auth/work-orders/:id/log', + WORK_ORDER_FILES: '/auth/work-orders/:id/files', + WORK_ORDER_FILE_BY_ID: '/auth/work-orders/:id/files/:fileId', + WORK_ORDER_ASSIGN: '/auth/work-orders/:id/assign', + WORK_ORDER_CLOSE: '/auth/work-orders/:id/close', + WORK_ORDER_CANCEL: '/auth/work-orders/:id/cancel', + // Spare Part endpoints + SPARE_PARTS: '/auth/spare-parts', + SPARE_PART_BY_ID: '/auth/spare-parts/:id', + SPARE_PART_REPAIR_HISTORY: '/auth/spare-parts/:id/repair-history', + // Work Order export + WORK_ORDER_EXPORT: '/auth/work-orders/:id/export' +} + +const WORK_ORDER_EXPORT_FORMATS = ['pdf', 'csv', 'docx'] const HTTP_METHODS = { GET: 'GET', @@ -197,7 +232,14 @@ const OPERATIONS = { ACTIONS_CANCEL: 'actions.cancel', // Things operations - THING_COMMENT_WRITE: 'thing.comment.write' + THING_COMMENT_WRITE: 'thing.comment.write', + + WORK_ORDER_CREATE: 'work_order.create', + WORK_ORDER_READ: 'work_order.read', + WORK_ORDER_UPDATE: 'work_order.update', + WORK_ORDER_CLOSE: 'work_order.close', + WORK_ORDER_CANCEL: 'work_order.cancel', + WORK_ORDER_ASSIGN: 'work_order.assign' } const DEFAULTS = { @@ -712,5 +754,16 @@ module.exports = { EXPLORER_RACK_DEFAULT_LIMIT, EXPLORER_RACK_MAX_LIMIT, LOG_FIELDS, - ELECTRICITY_EXT_DATA_KEYS + ELECTRICITY_EXT_DATA_KEYS, + WORK_ORDER_THING_TYPE, + WORK_ORDER_TYPES, + WORK_ORDER_TERMINAL_STATUSES, + WORK_ORDER_VALID_DEVICE_TYPES, + MINER_LOCATIONS, + SPARE_PART_INITIAL_LOCATION, + FILE_TYPES, + WORK_ORDER_FILE_MAX_BYTES_DEFAULT, + WORK_ORDER_FILE_COUNT_CAP_DEFAULT, + WORK_ORDER_FILE_MIME_ALLOWLIST_DEFAULT, + WORK_ORDER_EXPORT_FORMATS } diff --git a/workers/lib/server/handlers/spare.parts.handlers.js b/workers/lib/server/handlers/spare.parts.handlers.js new file mode 100644 index 0000000..8e07209 --- /dev/null +++ b/workers/lib/server/handlers/spare.parts.handlers.js @@ -0,0 +1,270 @@ +'use strict' + +const { randomUUID } = require('crypto') +const { parseJsonQueryParam, flattenRpcResults, escapeRegex, listThingsWithCount } = require('../../utils') +const { + WORK_ORDER_THING_TYPE, + WORK_ORDER_TYPES, + WORK_ORDER_TERMINAL_STATUSES, + WORK_ORDER_VALID_DEVICE_TYPES, + SPARE_PART_INITIAL_LOCATION +} = require('../../constants') +const { getWorkOrderRackId } = require('../lib/work.orders') + +function _pushErrors (results) { + return (results || []).flatMap(r => r?.errors || []) +} + +async function _loadWorkOrder (ctx, workOrderId) { + const results = await ctx.dataProxy.requestData('listThings', { + query: { id: workOrderId, type: WORK_ORDER_THING_TYPE } + }) + return flattenRpcResults(results)[0] || null +} + +async function _loadSparePart (ctx, partId) { + const results = await ctx.dataProxy.requestData('listThings', { + query: { id: partId } + }) + return flattenRpcResults(results).find(t => t?.type !== WORK_ORDER_THING_TYPE) || null +} + +async function updateSparePart (ctx, req) { + const { id } = req.params + const { rackId, workOrderId, info } = req.body + + const movesPart = info.location !== undefined || info.status !== undefined + + if (movesPart && !workOrderId) { + const err = new Error('ERR_PART_MOVE_REQUIRES_WO') + err.statusCode = 400 + throw err + } + + let workOrderCode + let part + if (movesPart) { + const [wo, p] = await Promise.all([ + _loadWorkOrder(ctx, workOrderId), + _loadSparePart(ctx, id) + ]) + if (!wo) { + const err = new Error('ERR_WORK_ORDER_NOT_FOUND') + err.statusCode = 400 + throw err + } + if (WORK_ORDER_TERMINAL_STATUSES.includes(wo.info?.status)) { + const err = new Error('ERR_WO_INVALID_STATUS_TRANSITION') + err.statusCode = 400 + throw err + } + if (!p) { + const err = new Error('ERR_SPARE_PART_NOT_FOUND') + err.statusCode = 404 + throw err + } + workOrderCode = wo.code + part = p + } + + const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) + const voter = req._info.user.metadata.email + + const partInfo = { ...info } + if (movesPart) { + partInfo.workOrderId = workOrderId + partInfo.workOrderCode = workOrderCode + } + + const partResults = await ctx.dataProxy.requestData('pushAction', { + action: 'updateThing', + query: { rack: rackId }, + params: [{ rackId, id, info: partInfo }], + voter, + authPerms: permissions || [] + }, (res, arr) => { + if (res?.error) arr.push({ id: null, errors: [res.error] }) + else arr.push(res) + }) + + if (!movesPart) return partResults + + const partPushErrors = _pushErrors(partResults) + if (partPushErrors.length) { + const err = new Error(`ERR_PART_UPDATE_PUSH_FAILED:${partPushErrors.join(',')}`) + err.statusCode = 502 + err.detail = { stage: 'part', partAction: null, workOrderAction: null } + throw err + } + + const moveEntry = { + partId: id, + partCode: part.code, + workOrderId, + workOrderCode, + fromLocation: part.info?.location ?? null, + toLocation: info.location ?? part.info?.location ?? null, + fromStatus: part.info?.status ?? null, + toStatus: info.status ?? part.info?.status ?? null, + role: 'original', + ts: Date.now(), + user: voter + } + + const woRackId = await getWorkOrderRackId(ctx) + const wo = await _loadWorkOrder(ctx, workOrderId) + const currentMoves = Array.isArray(wo?.info?.partsMoves) ? wo.info.partsMoves : [] + + const woResults = await ctx.dataProxy.requestData('pushAction', { + action: 'updateThing', + query: { rack: woRackId }, + params: [{ rackId: woRackId, id: workOrderId, info: { partsMoves: [...currentMoves, moveEntry] } }], + voter, + authPerms: permissions || [] + }, (res, arr) => { + if (res?.error) arr.push({ id: null, errors: [res.error] }) + else arr.push(res) + }) + + return { + part: partResults, + workOrder: woResults, + move: moveEntry, + partActionId: partResults.find(r => r?.id)?.id ?? null, + workOrderActionId: woResults.find(r => r?.id)?.id ?? null, + workOrderAppendErrors: _pushErrors(woResults), + expectedActionLatencyMs: ctx.conf?.expectedActionLatencyMs ?? 1000 + } +} + +async function registerSparePart (ctx, req) { + const { rackId, info } = req.body + const deviceType = info.deviceType + if (!WORK_ORDER_VALID_DEVICE_TYPES.includes(deviceType)) { + const err = new Error('ERR_INVALID_DEVICE_TYPE') + err.statusCode = 400 + throw err + } + if (!info.deviceModel || typeof info.deviceModel !== 'string') { + const err = new Error('ERR_DEVICE_MODEL_REQUIRED') + err.statusCode = 400 + throw err + } + if (!info.serialNum || typeof info.serialNum !== 'string') { + const err = new Error('ERR_SERIAL_NUM_REQUIRED') + err.statusCode = 400 + throw err + } + const workOrderRackId = await getWorkOrderRackId(ctx) + + const voter = req._info.user.metadata.email + const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) + const authPerms = permissions || [] + + const partId = randomUUID() + const woId = randomUUID() + const ts = Date.now() + + const partInfo = { + ...info, + location: info.location ?? SPARE_PART_INITIAL_LOCATION + } + const woInfo = { + type: WORK_ORDER_TYPES.REGISTER, + deviceType, + deviceModel: info.deviceModel, + deviceIdentifier: info.serialNum, + createdBy: voter, + createdAt: ts, + partsMoves: [{ + partId, + fromLocation: null, + toLocation: SPARE_PART_INITIAL_LOCATION, + role: 'register', + ts, + user: voter + }] + } + + const pushSingleAction = (rack, id, info) => ctx.dataProxy.requestData('pushAction', { + action: 'registerThing', + query: { rack }, + params: [{ rackId: rack, id, info }], + voter, + authPerms + }, (res, arr) => { + if (res?.error) arr.push({ id: null, errors: [res.error] }) + else arr.push(res) + }) + + const [partResults, woResults] = await Promise.all([ + pushSingleAction(rackId, partId, partInfo), + pushSingleAction(workOrderRackId, woId, woInfo) + ]) + + return { + partId, + workOrderId: woId, + partActionId: partResults.find(r => r?.id)?.id ?? null, + workOrderActionId: woResults.find(r => r?.id)?.id ?? null, + errors: [..._pushErrors(partResults), ..._pushErrors(woResults)], + expectedActionLatencyMs: ctx.conf?.expectedActionLatencyMs ?? 1000 + } +} + +function _buildSparePartQuery (qs) { + const query = qs.query + ? parseJsonQueryParam(qs.query, 'ERR_QUERY_INVALID_JSON') + : {} + if (!query.type) query.type = { $ne: WORK_ORDER_THING_TYPE } + if (qs.location) query['info.location'] = qs.location + if (qs.status) query['info.status'] = qs.status + if (qs.q) { + const escaped = escapeRegex(qs.q) + query.$or = [ + { code: { $regex: escaped, $options: 'i' } }, + { 'info.serialNum': { $regex: escaped, $options: 'i' } }, + { 'info.macAddress': { $regex: escaped, $options: 'i' } } + ] + } + return query +} + +async function listSpareParts (ctx, req) { + return listThingsWithCount(ctx, _buildSparePartQuery(req.query), { + offset: req.query.offset ?? 0, + limit: req.query.limit ?? 100, + sort: req.query.sort && parseJsonQueryParam(req.query.sort, 'ERR_SORT_INVALID_JSON'), + fields: req.query.fields && parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') + }) +} + +async function getRepairHistory (ctx, req) { + const offset = req.query.offset ?? 0 + const limit = req.query.limit ?? 100 + const partId = req.params.id + + const results = await ctx.dataProxy.requestData('listThings', { + query: { type: WORK_ORDER_THING_TYPE, 'info.partsMoves.partId': partId } + }) + const wos = flattenRpcResults(results) + + const rows = [] + for (const wo of wos) { + for (const move of (wo.info?.partsMoves || [])) { + if (move.partId !== partId) continue + rows.push({ ...move, workOrderId: wo.id, workOrderCode: wo.code }) + } + } + rows.sort((a, b) => b.ts - a.ts) + + return { + data: rows.slice(offset, offset + limit), + totalCount: rows.length, + offset, + limit, + hasMore: offset + limit < rows.length + } +} + +module.exports = { registerSparePart, listSpareParts, updateSparePart, getRepairHistory } diff --git a/workers/lib/server/handlers/work.order.files.handlers.js b/workers/lib/server/handlers/work.order.files.handlers.js new file mode 100644 index 0000000..b115b3f --- /dev/null +++ b/workers/lib/server/handlers/work.order.files.handlers.js @@ -0,0 +1,165 @@ +'use strict' + +const { flattenRpcResults } = require('../../utils') +const { + WORK_ORDER_THING_TYPE, + WORK_ORDER_TERMINAL_STATUSES, + FILE_TYPES, + WORK_ORDER_FILE_COUNT_CAP_DEFAULT, + WORK_ORDER_FILE_MAX_BYTES_DEFAULT, + WORK_ORDER_FILE_MIME_ALLOWLIST_DEFAULT +} = require('../../constants') +const { getWorkOrderRackId } = require('../lib/work.orders') + +async function _loadWorkOrder (ctx, id) { + const results = await ctx.dataProxy.requestData('listThings', { + query: { id, type: WORK_ORDER_THING_TYPE } + }) + return flattenRpcResults(results)[0] || null +} + +async function _pushWorkOrderUpdate (ctx, req, info) { + const rackId = await getWorkOrderRackId(ctx) + const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) + return ctx.dataProxy.requestData('pushAction', { + action: 'updateThing', + query: { rack: rackId }, + params: [{ rackId, id: req.params.id, info }], + voter: req._info.user.metadata.email, + authPerms: permissions || [] + }, (res, arr) => { + if (res?.error) arr.push({ id: null, errors: [res.error] }) + else arr.push(res) + }) +} + +async function uploadWorkOrderFile (ctx, req) { + const wo = await _loadWorkOrder(ctx, req.params.id) + if (!wo) { + const err = new Error('ERR_WORK_ORDER_NOT_FOUND') + err.statusCode = 404 + throw err + } + if (WORK_ORDER_TERMINAL_STATUSES.includes(wo.info?.status)) { + const err = new Error('ERR_WO_INVALID_STATUS_TRANSITION') + err.statusCode = 400 + throw err + } + + const cap = ctx.conf.workOrderFileCountCap || WORK_ORDER_FILE_COUNT_CAP_DEFAULT + if ((wo.info?.files?.length || 0) >= cap) { + const err = new Error('ERR_WO_FILE_COUNT_CAP_REACHED') + err.statusCode = 400 + throw err + } + + const part = await req.file() + if (!part) { + const err = new Error('ERR_FILE_REQUIRED') + err.statusCode = 400 + throw err + } + + const allowlist = new Set(ctx.conf.workOrderFileMimeAllowlist || WORK_ORDER_FILE_MIME_ALLOWLIST_DEFAULT) + if (!allowlist.has(part.mimetype)) { + const err = new Error('ERR_FILE_MIME_NOT_ALLOWED') + err.statusCode = 400 + throw err + } + + const buf = await part.toBuffer() + const max = ctx.conf.workOrderFileMaxBytes || WORK_ORDER_FILE_MAX_BYTES_DEFAULT + if (buf.length > max) { + const err = new Error('ERR_FILE_TOO_LARGE') + err.statusCode = 413 + throw err + } + + const voter = req._info.user.metadata.email + const rackId = await getWorkOrderRackId(ctx) + const storeResults = await ctx.dataProxy.requestData('storeFile', { + type: FILE_TYPES.WORK_ORDER, + rackId, + workOrderId: req.params.id, + name: part.filename, + mime: part.mimetype, + contentBase64: buf.toString('base64'), + user: voter + }) + const meta = storeResults.find(r => r && r.id) + if (!meta) { + const failed = storeResults.find(r => r && r.error) + throw new Error(failed?.error || 'ERR_WO_FILE_STORE_FAILED') + } + + const files = [...(wo.info?.files || []), meta] + await _pushWorkOrderUpdate(ctx, req, { files }) + + return meta +} + +async function downloadWorkOrderFile (ctx, req, rep) { + const wo = await _loadWorkOrder(ctx, req.params.id) + if (!wo) { + const err = new Error('ERR_WORK_ORDER_NOT_FOUND') + err.statusCode = 404 + throw err + } + const file = (wo.info?.files || []).find(f => f.id === req.params.fileId) + if (!file) { + const err = new Error('ERR_WO_FILE_NOT_FOUND') + err.statusCode = 404 + throw err + } + + const loaded = await ctx.dataProxy.requestData('loadFile', { + type: FILE_TYPES.WORK_ORDER, + rackId: await getWorkOrderRackId(ctx), + workOrderId: wo.id, + fileId: req.params.fileId + }) + const got = loaded.find(r => r && r.contentBase64) + if (!got) { + const err = new Error('ERR_WO_FILE_NOT_FOUND') + err.statusCode = 404 + throw err + } + + rep.header('content-type', file.mime) + rep.header('content-disposition', `attachment; filename="${file.name}"`) + rep.send(Buffer.from(got.contentBase64, 'base64')) +} + +async function deleteWorkOrderFile (ctx, req) { + const wo = await _loadWorkOrder(ctx, req.params.id) + if (!wo) { + const err = new Error('ERR_WORK_ORDER_NOT_FOUND') + err.statusCode = 404 + throw err + } + if (WORK_ORDER_TERMINAL_STATUSES.includes(wo.info?.status)) { + const err = new Error('ERR_WO_INVALID_STATUS_TRANSITION') + err.statusCode = 400 + throw err + } + const files = wo.info?.files || [] + const file = files.find(f => f.id === req.params.fileId) + if (!file) { + const err = new Error('ERR_WO_FILE_NOT_FOUND') + err.statusCode = 404 + throw err + } + + const removeResults = await ctx.dataProxy.requestData('removeFile', { + type: FILE_TYPES.WORK_ORDER, + rackId: await getWorkOrderRackId(ctx), + workOrderId: wo.id, + fileId: req.params.fileId + }) + await _pushWorkOrderUpdate(ctx, req, { files: files.filter(f => f.id !== req.params.fileId) }) + + const removed = removeResults.find(r => r && typeof r.cleared === 'boolean') + return { id: req.params.fileId, blobCleared: removed?.cleared ?? false } +} + +module.exports = { uploadWorkOrderFile, downloadWorkOrderFile, deleteWorkOrderFile } diff --git a/workers/lib/server/handlers/work.orders.handlers.js b/workers/lib/server/handlers/work.orders.handlers.js new file mode 100644 index 0000000..e66e93d --- /dev/null +++ b/workers/lib/server/handlers/work.orders.handlers.js @@ -0,0 +1,231 @@ +'use strict' + +const { parseJsonQueryParam, flattenRpcResults, escapeRegex, listThingsWithCount } = require('../../utils') +const { + WORK_ORDER_THING_TYPE, + WORK_ORDER_TYPES, + WORK_ORDER_TERMINAL_STATUSES, + WORK_ORDER_VALID_DEVICE_TYPES, + SPARE_PART_INITIAL_LOCATION +} = require('../../constants') +const { renderWorkOrderCsv } = require('../lib/work.order.export') +const { submitWorkOrderAction, getWorkOrderRackId } = require('../lib/work.orders') + +async function _resolvePartByIdentifier (ctx, identifier) { + const results = await ctx.dataProxy.requestData('listThings', { + query: { + $or: [ + { id: identifier }, + { code: identifier }, + { 'info.serialNum': identifier }, + { 'info.macAddress': identifier } + ] + } + }) + return flattenRpcResults(results).find(t => t?.type !== WORK_ORDER_THING_TYPE) || null +} + +async function createWorkOrder (ctx, req) { + const { type, deviceType, deviceIdentifier } = req.body + + if (!WORK_ORDER_VALID_DEVICE_TYPES.includes(deviceType)) { + const err = new Error('ERR_INVALID_DEVICE_TYPE') + err.statusCode = 400 + throw err + } + + const voter = req._info.user.metadata.email + const info = { ...req.body, createdBy: voter, createdAt: Date.now() } + + if (type === WORK_ORDER_TYPES.REGULAR) { + const part = await _resolvePartByIdentifier(ctx, deviceIdentifier) + if (!part) { + const err = new Error('ERR_PART_NOT_FOUND') + err.statusCode = 400 + throw err + } + info.partsMoves = [{ + partId: part.id, + partCode: part.code, + role: 'diagnosis', + ts: Date.now(), + user: voter + }] + } else if (type === WORK_ORDER_TYPES.REGISTER) { + const part = await _resolvePartByIdentifier(ctx, deviceIdentifier) + if (!part) { + const err = new Error('ERR_PART_NOT_FOUND') + err.statusCode = 400 + throw err + } + info.partsMoves = [{ + partId: part.id, + partCode: part.code, + fromLocation: null, + toLocation: SPARE_PART_INITIAL_LOCATION, + role: 'register', + ts: Date.now(), + user: voter + }] + } + + return submitWorkOrderAction(ctx, req, 'registerThing', { info }) +} + +async function updateWorkOrder (ctx, req) { + return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info: { ...req.body } }) +} + +async function closeWorkOrder (ctx, req) { + const info = { status: 'closed' } + if (req.body?.finalResult) info.finalResult = req.body.finalResult + return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info }) +} + +async function cancelWorkOrder (ctx, req) { + const info = { status: 'cancelled' } + if (req.body?.reason) info.cancelReason = req.body.reason + return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info }) +} + +async function assignWorkOrder (ctx, req) { + return submitWorkOrderAction(ctx, req, 'updateThing', { + id: req.params.id, + info: { assignedTo: req.body.assignedTo } + }) +} + +function _buildWorkOrderQuery (qs) { + const query = qs.query + ? parseJsonQueryParam(qs.query, 'ERR_QUERY_INVALID_JSON') + : {} + query.type = WORK_ORDER_THING_TYPE + if (qs.assignee) query['info.assignedTo'] = qs.assignee + if (qs.creator) query['info.createdBy'] = qs.creator + if (qs.partId) query['info.partsMoves.partCode'] = qs.partId + if (qs.status) query['info.status'] = qs.status + if (qs.type != null) query['info.type'] = qs.type + if (qs.from || qs.to) { + query['info.createdAt'] = {} + if (qs.from) query['info.createdAt'].$gte = qs.from + if (qs.to) query['info.createdAt'].$lte = qs.to + } + if (qs.q) { + const escaped = escapeRegex(qs.q) + query.$or = [ + { code: { $regex: escaped } }, + { 'info.issue': { $regex: escaped, $options: 'i' } } + ] + } + return query +} + +async function listWorkOrders (ctx, req) { + return listThingsWithCount(ctx, _buildWorkOrderQuery(req.query), { + offset: req.query.offset ?? 0, + limit: req.query.limit ?? 100, + sort: req.query.sort && parseJsonQueryParam(req.query.sort, 'ERR_SORT_INVALID_JSON'), + fields: req.query.fields && parseJsonQueryParam(req.query.fields, 'ERR_FIELDS_INVALID_JSON') + }) +} + +async function getWorkOrder (ctx, req) { + const params = { query: { id: req.params.id, type: WORK_ORDER_THING_TYPE } } + const results = await ctx.dataProxy.requestData('listThings', params) + const flat = flattenRpcResults(results) + if (!flat.length) { + const err = new Error('ERR_WORK_ORDER_NOT_FOUND') + err.statusCode = 404 + throw err + } + return flat[0] +} + +async function appendWorkLogEntry (ctx, req) { + const rackId = await getWorkOrderRackId(ctx) + + const wo = await ctx.dataProxy.requestData('listThings', { + query: { id: req.params.id, type: WORK_ORDER_THING_TYPE } + }) + const found = flattenRpcResults(wo)[0] + if (!found) { + const err = new Error('ERR_WORK_ORDER_NOT_FOUND') + err.statusCode = 404 + throw err + } + if (WORK_ORDER_TERMINAL_STATUSES.includes(found.info?.status)) { + const err = new Error('ERR_WO_INVALID_STATUS_TRANSITION') + err.statusCode = 400 + throw err + } + + return ctx.dataProxy.requestData('saveThingComment', { + rackId, + thingId: req.params.id, + comment: req.body.text, + user: req._info.user.metadata.email + }, (res, arr) => { + if (res?.error) arr.push({ error: res.error }) + else arr.push(res) + }) +} + +async function _loadWorkOrderByIdOrCode (ctx, idOrCode) { + const params = { + query: { + type: WORK_ORDER_THING_TYPE, + $or: [{ id: idOrCode }, { code: idOrCode }] + } + } + const results = await ctx.dataProxy.requestData('listThings', params) + return flattenRpcResults(results)[0] || null +} + +async function exportWorkOrder (ctx, req, rep) { + const { format } = req.query + if (format !== 'csv') { + return rep.status(501).send({ + statusCode: 501, + error: 'Not Implemented', + message: `ERR_EXPORT_FORMAT_NOT_IMPLEMENTED:${format}` + }) + } + + const wo = await _loadWorkOrderByIdOrCode(ctx, req.params.id) + if (!wo) { + const err = new Error('ERR_WORK_ORDER_NOT_FOUND') + err.statusCode = 404 + throw err + } + + const filename = wo.code || wo.id + rep.header('content-type', 'text/csv; charset=utf-8') + rep.header('content-disposition', `attachment; filename="${filename}.csv"`) + return rep.send(renderWorkOrderCsv(wo)) +} + +async function getWorkOrderAudit (ctx, req) { + const payload = { + logType: 'info', + limit: req.query.limit ?? 100, + offset: req.query.offset ?? 0, + start: req.query.start, + end: req.query.end, + query: { 'thing.id': req.params.id } + } + const results = await ctx.dataProxy.requestData('getHistoricalLogs', payload) + return flattenRpcResults(results) +} + +module.exports = { + createWorkOrder, + listWorkOrders, + getWorkOrder, + updateWorkOrder, + closeWorkOrder, + cancelWorkOrder, + assignWorkOrder, + appendWorkLogEntry, + getWorkOrderAudit, + exportWorkOrder +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 699c260..8d5011f 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -21,6 +21,9 @@ const coolingSystemRoutes = require('./routes/cooling.system.routes') const energySystemRoutes = require('./routes/energy.system.routes') const explorerRoutes = require('./routes/explorer.routes') const energyRoutes = require('./routes/energy.routes') +const workOrdersRoutes = require('./routes/work.orders.routes') +const sparePartsRoutes = require('./routes/spare.parts.routes') +const workOrderFilesRoutes = require('./routes/work.order.files.routes') /** * Collect all routes into a flat array for server injection. @@ -48,7 +51,10 @@ function routes (ctx) { ...coolingSystemRoutes(ctx), ...energySystemRoutes(ctx), ...explorerRoutes(ctx), - ...energyRoutes(ctx) + ...energyRoutes(ctx), + ...workOrdersRoutes(ctx), + ...sparePartsRoutes(ctx), + ...workOrderFilesRoutes(ctx) ] } diff --git a/workers/lib/server/lib/work.order.export.js b/workers/lib/server/lib/work.order.export.js new file mode 100644 index 0000000..df6b8ad --- /dev/null +++ b/workers/lib/server/lib/work.order.export.js @@ -0,0 +1,28 @@ +'use strict' + +const { csvEscape } = require('../../utils') + +function renderWorkOrderCsv (wo) { + const { partsMoves, ...woFields } = wo.info || {} + const base = { code: wo.code, ...woFields } + const moves = Array.isArray(partsMoves) ? partsMoves : [] + const rows = moves.length ? moves.map(move => ({ ...base, ...move })) : [base] + + const headers = [] + const seen = new Set() + for (const row of rows) { + for (const key of Object.keys(row)) { + if (seen.has(key)) continue + seen.add(key) + headers.push(key) + } + } + + const lines = [headers.map(csvEscape).join(',')] + for (const row of rows) { + lines.push(headers.map(h => csvEscape(row[h])).join(',')) + } + return lines.join('\r\n') + '\r\n' +} + +module.exports = { renderWorkOrderCsv } diff --git a/workers/lib/server/lib/work.orders.js b/workers/lib/server/lib/work.orders.js new file mode 100644 index 0000000..6a0127a --- /dev/null +++ b/workers/lib/server/lib/work.orders.js @@ -0,0 +1,33 @@ +'use strict' + +const { WORK_ORDER_THING_TYPE } = require('../../constants') +const { flattenRpcResults } = require('../../utils') + +async function getWorkOrderRackId (ctx) { + if (ctx._workOrderRackId) return ctx._workOrderRackId + const results = await ctx.dataProxy.requestData('listRacks', { + type: WORK_ORDER_THING_TYPE + }) + const rack = flattenRpcResults(results)[0] + if (!rack || !rack.id) throw new Error('ERR_WORK_ORDER_RACK_NOT_FOUND') + ctx._workOrderRackId = rack.id + return rack.id +} + +async function submitWorkOrderAction (ctx, req, action, paramObj) { + const rackId = await getWorkOrderRackId(ctx) + const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) + + return ctx.dataProxy.requestData('pushAction', { + action, + query: { rack: rackId }, + params: [{ rackId, ...paramObj }], + voter: req._info.user.metadata.email, + authPerms: permissions || [] + }, (res, arr) => { + if (res?.error) arr.push({ id: null, errors: [res.error] }) + else arr.push(res) + }) +} + +module.exports = { getWorkOrderRackId, submitWorkOrderAction } diff --git a/workers/lib/server/routes/spare.parts.routes.js b/workers/lib/server/routes/spare.parts.routes.js new file mode 100644 index 0000000..cc28933 --- /dev/null +++ b/workers/lib/server/routes/spare.parts.routes.js @@ -0,0 +1,57 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_PERMISSIONS +} = require('../../constants') +const schemas = require('../schemas/spare.parts.schemas') +const { registerSparePart, listSpareParts, updateSparePart, getRepairHistory } = require('../handlers/spare.parts.handlers') +const { createAuthRoute, createCachedAuthRoute } = require('../lib/routeHelpers') +const { stableJsonString } = require('../../utils') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.SPARE_PARTS, + schema: schemas.register, + ...createAuthRoute(ctx, registerSparePart, [AUTH_PERMISSIONS.INVENTORY]) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.SPARE_PARTS, + schema: schemas.list, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'spare-parts', + stableJsonString(req.query.query), + stableJsonString(req.query.sort), + stableJsonString(req.query.fields), + req.query.offset, req.query.limit, + req.query.q, req.query.location, req.query.status + ], + ENDPOINTS.SPARE_PARTS, + listSpareParts, + [AUTH_PERMISSIONS.INVENTORY] + ) + }, + { + method: HTTP_METHODS.PUT, + url: ENDPOINTS.SPARE_PART_BY_ID, + schema: schemas.update, + ...createAuthRoute(ctx, updateSparePart, [AUTH_PERMISSIONS.INVENTORY]) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.SPARE_PART_REPAIR_HISTORY, + schema: schemas.repairHistory, + ...createCachedAuthRoute( + ctx, + (req) => ['spare-parts/repair-history', req.params.id, req.query.offset, req.query.limit], + ENDPOINTS.SPARE_PART_REPAIR_HISTORY, + getRepairHistory, + [AUTH_PERMISSIONS.INVENTORY] + ) + } +] diff --git a/workers/lib/server/routes/work.order.files.routes.js b/workers/lib/server/routes/work.order.files.routes.js new file mode 100644 index 0000000..cbdedbe --- /dev/null +++ b/workers/lib/server/routes/work.order.files.routes.js @@ -0,0 +1,48 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_PERMISSIONS +} = require('../../constants') +const { + uploadWorkOrderFile, + downloadWorkOrderFile, + deleteWorkOrderFile +} = require('../handlers/work.order.files.handlers') +const { createAuthRoute } = require('../lib/routeHelpers') + +const idParams = { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', minLength: 1 } } +} +const idAndFileParams = { + type: 'object', + required: ['id', 'fileId'], + properties: { + id: { type: 'string', minLength: 1 }, + fileId: { type: 'string', minLength: 1 } + } +} + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.WORK_ORDER_FILES, + schema: { params: idParams }, + ...createAuthRoute(ctx, uploadWorkOrderFile, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.WORK_ORDER_FILE_BY_ID, + schema: { params: idAndFileParams }, + ...createAuthRoute(ctx, downloadWorkOrderFile, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.DELETE, + url: ENDPOINTS.WORK_ORDER_FILE_BY_ID, + schema: { params: idAndFileParams }, + ...createAuthRoute(ctx, deleteWorkOrderFile, [AUTH_PERMISSIONS.WORK_ORDER]) + } +] diff --git a/workers/lib/server/routes/work.orders.routes.js b/workers/lib/server/routes/work.orders.routes.js new file mode 100644 index 0000000..cf95210 --- /dev/null +++ b/workers/lib/server/routes/work.orders.routes.js @@ -0,0 +1,118 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_PERMISSIONS +} = require('../../constants') +const schemas = require('../schemas/work.orders.schemas') +const { + createWorkOrder, + listWorkOrders, + getWorkOrder, + updateWorkOrder, + closeWorkOrder, + cancelWorkOrder, + assignWorkOrder, + appendWorkLogEntry, + getWorkOrderAudit, + exportWorkOrder +} = require('../handlers/work.orders.handlers') +const { createAuthRoute, createCachedAuthRoute } = require('../lib/routeHelpers') +const { stableJsonString } = require('../../utils') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.WORK_ORDERS, + schema: schemas.create, + ...createAuthRoute(ctx, createWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.WORK_ORDERS, + schema: schemas.list, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'work-orders', + stableJsonString(req.query.query), + stableJsonString(req.query.sort), + stableJsonString(req.query.fields), + req.query.offset, + req.query.limit, + req.query.q, + req.query.assignee, + req.query.creator, + req.query.partId, + req.query.status, + req.query.type, + req.query.from, + req.query.to + ], + ENDPOINTS.WORK_ORDERS, + listWorkOrders, + [AUTH_PERMISSIONS.WORK_ORDER] + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.WORK_ORDER_BY_ID, + schema: schemas.byId, + ...createCachedAuthRoute( + ctx, + (req) => ['work-orders', req.params.id], + ENDPOINTS.WORK_ORDER_BY_ID, + getWorkOrder, + [AUTH_PERMISSIONS.WORK_ORDER] + ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.WORK_ORDER_AUDIT, + schema: schemas.audit, + ...createCachedAuthRoute( + ctx, + (req) => ['work-orders/audit', req.params.id, req.query.start, req.query.end, req.query.offset, req.query.limit], + ENDPOINTS.WORK_ORDER_AUDIT, + getWorkOrderAudit, + [AUTH_PERMISSIONS.WORK_ORDER] + ) + }, + { + method: HTTP_METHODS.PATCH, + url: ENDPOINTS.WORK_ORDER_BY_ID, + schema: schemas.update, + ...createAuthRoute(ctx, updateWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.WORK_ORDER_CLOSE, + schema: schemas.close, + ...createAuthRoute(ctx, closeWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.WORK_ORDER_CANCEL, + schema: schemas.cancel, + ...createAuthRoute(ctx, cancelWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.WORK_ORDER_ASSIGN, + schema: schemas.assign, + ...createAuthRoute(ctx, assignWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.WORK_ORDER_LOG, + schema: schemas.log, + ...createAuthRoute(ctx, appendWorkLogEntry, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.WORK_ORDER_EXPORT, + schema: schemas.export, + ...createAuthRoute(ctx, exportWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) + } +] diff --git a/workers/lib/server/schemas/spare.parts.schemas.js b/workers/lib/server/schemas/spare.parts.schemas.js new file mode 100644 index 0000000..69dea30 --- /dev/null +++ b/workers/lib/server/schemas/spare.parts.schemas.js @@ -0,0 +1,77 @@ +'use strict' + +const register = { + body: { + type: 'object', + required: ['rackId', 'info'], + additionalProperties: false, + properties: { + rackId: { type: 'string', minLength: 1 }, + info: { + type: 'object', + additionalProperties: true, + minProperties: 1, + required: ['deviceType'] + } + } + } +} + +const update = { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', minLength: 1 } } + }, + body: { + type: 'object', + required: ['rackId', 'info'], + additionalProperties: false, + properties: { + rackId: { type: 'string', minLength: 1 }, + workOrderId: { type: 'string', minLength: 1 }, + info: { + type: 'object', + additionalProperties: true, + minProperties: 1 + } + } + } +} + +const repairHistory = { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', minLength: 1 } } + }, + querystring: { + type: 'object', + additionalProperties: false, + properties: { + limit: { type: 'integer', minimum: 1, maximum: 200 }, + offset: { type: 'integer', minimum: 0 }, + overwriteCache: { type: 'boolean' } + } + } +} + +const list = { + querystring: { + type: 'object', + additionalProperties: false, + properties: { + query: { type: 'string' }, + sort: { type: 'string' }, + fields: { type: 'string' }, + offset: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 200 }, + q: { type: 'string', minLength: 1, maxLength: 200 }, + location: { type: 'string', minLength: 1, maxLength: 100 }, + status: { type: 'string', minLength: 1, maxLength: 100 }, + overwriteCache: { type: 'boolean' } + } + } +} + +module.exports = { register, list, update, repairHistory } diff --git a/workers/lib/server/schemas/work.orders.schemas.js b/workers/lib/server/schemas/work.orders.schemas.js new file mode 100644 index 0000000..55c300e --- /dev/null +++ b/workers/lib/server/schemas/work.orders.schemas.js @@ -0,0 +1,145 @@ +'use strict' + +const types = { type: 'integer', enum: [1, 2] } + +const warranty = { + type: ['object', 'null'], + properties: { + vendor: { type: ['string', 'null'] }, + fields: { type: 'object', additionalProperties: true } + } +} + +const create = { + body: { + type: 'object', + required: ['type', 'deviceType', 'deviceModel', 'deviceIdentifier'], + additionalProperties: false, + properties: { + type: types, + deviceType: { type: 'string', minLength: 1, maxLength: 100 }, + deviceModel: { type: 'string', minLength: 1, maxLength: 100 }, + deviceIdentifier: { type: 'string', minLength: 1, maxLength: 200 }, + issue: { type: 'string', minLength: 1, maxLength: 2000 }, + assignedTo: { type: ['string', 'null'], maxLength: 200 }, + warranty + }, + if: { properties: { type: { const: 2 } } }, + then: { required: ['issue'] } + } +} + +const list = { + querystring: { + type: 'object', + additionalProperties: false, + properties: { + query: { type: 'string' }, + sort: { type: 'string' }, + fields: { type: 'string' }, + offset: { type: 'integer', minimum: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 200 }, + q: { type: 'string', minLength: 1, maxLength: 200 }, + assignee: { type: 'string', minLength: 1, maxLength: 200 }, + creator: { type: 'string', minLength: 1, maxLength: 200 }, + partId: { type: 'string', minLength: 1, maxLength: 200 }, + status: { type: 'string', enum: ['open', 'in_progress', 'closed', 'cancelled'] }, + type: types, + from: { type: 'integer', minimum: 0 }, + to: { type: 'integer', minimum: 0 }, + overwriteCache: { type: 'boolean' } + } + } +} + +const byId = { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', minLength: 1 } } + } +} + +const update = { + params: byId.params, + body: { + type: 'object', + additionalProperties: false, + minProperties: 1, + properties: { + issue: { type: 'string', minLength: 1, maxLength: 2000 }, + deviceType: { type: 'string', minLength: 1, maxLength: 100 }, + deviceModel: { type: 'string', minLength: 1, maxLength: 100 }, + deviceIdentifier: { type: 'string', minLength: 1, maxLength: 200 }, + assignedTo: { type: ['string', 'null'], maxLength: 200 }, + finalResult: { type: ['string', 'null'], maxLength: 4000 }, + warranty + } + } +} + +const close = { + params: byId.params, + body: { + type: 'object', + additionalProperties: false, + properties: { finalResult: { type: 'string', minLength: 1, maxLength: 4000 } } + } +} + +const cancel = { + params: byId.params, + body: { + type: 'object', + additionalProperties: false, + properties: { reason: { type: 'string', minLength: 1, maxLength: 2000 } } + } +} + +const assign = { + params: byId.params, + body: { + type: 'object', + required: ['assignedTo'], + additionalProperties: false, + properties: { assignedTo: { type: ['string', 'null'], maxLength: 200 } } + } +} + +const log = { + params: byId.params, + body: { + type: 'object', + required: ['text'], + additionalProperties: false, + properties: { text: { type: 'string', minLength: 1, maxLength: 4000 } } + } +} + +const audit = { + params: byId.params, + querystring: { + type: 'object', + additionalProperties: false, + properties: { + limit: { type: 'integer', minimum: 1, maximum: 500 }, + offset: { type: 'integer', minimum: 0 }, + start: { type: 'integer' }, + end: { type: 'integer' } + } + } +} + +const exportRoute = { + params: byId.params, + querystring: { + type: 'object', + required: ['format'], + additionalProperties: false, + properties: { + format: { type: 'string', enum: ['pdf', 'csv', 'docx'] } + } + } +} + +module.exports = { create, list, byId, update, close, cancel, assign, audit, log, export: exportRoute } diff --git a/workers/lib/utils.js b/workers/lib/utils.js index 44c12d3..5908de6 100644 --- a/workers/lib/utils.js +++ b/workers/lib/utils.js @@ -123,6 +123,48 @@ function deduplicateAlerts (alerts) { return result } +function escapeRegex (s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function csvEscape (v) { + if (v === null || v === undefined) return '' + const s = typeof v === 'string' ? v : JSON.stringify(v) + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"` + return s +} + +function stableJsonString (raw) { + if (typeof raw !== 'string') return raw + try { + const parsed = JSON.parse(raw) + if (parsed === null || typeof parsed !== 'object') return raw + return JSON.stringify(parsed, Object.keys(parsed).sort()) + } catch { + return raw + } +} + +async function listThingsWithCount (ctx, query, { offset = 0, limit = 100, sort, fields } = {}) { + const params = { query, offset, limit } + if (sort !== undefined) params.sort = sort + if (fields !== undefined) params.fields = fields + + const [listResults, countResults] = await Promise.all([ + ctx.dataProxy.requestData('listThings', params), + ctx.dataProxy.requestData('getThingsCount', { query }) + ]) + + // Each ork applies offset/limit locally so the union can be up to N*limit + // items across N orks. Cap to the requested page so we never return more + // than the caller asked for. Pagination across multiple racks is still + // best-effort because each rack uses the same offset locally. + const flat = flattenRpcResults(listResults) + const data = flat.slice(0, limit) + const totalCount = countResults.reduce((acc, c) => acc + (Number(c) || 0), 0) + return { data, totalCount, offset, limit, hasMore: offset + limit < totalCount } +} + function matchesFilter (item, filter, allowedFields) { if (!filter) return true for (const key of allowedFields) { @@ -151,5 +193,9 @@ module.exports = { safeDiv, runParallel, deduplicateAlerts, - matchesFilter + matchesFilter, + escapeRegex, + csvEscape, + stableJsonString, + listThingsWithCount } From 1b7f7f563bc1723f5865b6f2f9c30295d66d6aa5 Mon Sep 17 00:00:00 2001 From: borik91 <9007515+boris91@users.noreply.github.com> Date: Mon, 25 May 2026 13:42:35 +0300 Subject: [PATCH 50/63] (fix) NPM audit, Dependencies invulnerability fixed (#84) * chore: root - package-lock - dependency tree updated. * fix: root - package-lock - dependency tree packages invulnerability fixed. --- package-lock.json | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 187d21b..ae992a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1034,7 +1034,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2019,7 +2018,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -3176,7 +3174,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3384,7 +3381,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3483,7 +3479,6 @@ "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -3563,7 +3558,6 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -3580,7 +3574,6 @@ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -7093,9 +7086,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { From 6d1f48d884264ae389768f6ff00a30ebf38f8098 Mon Sep 17 00:00:00 2001 From: tekwani Date: Mon, 1 Jun 2026 23:08:14 +0530 Subject: [PATCH 51/63] feat: list firmwares endpoint (#86) --- package-lock.json | 338 ++++++++++++------ tests/unit/handlers/miners.handlers.test.js | 59 ++- tests/unit/routes/miners.routes.test.js | 4 + workers/lib/constants.js | 1 + .../lib/server/handlers/miners.handlers.js | 7 +- workers/lib/server/routes/miners.routes.js | 12 +- 6 files changed, 302 insertions(+), 119 deletions(-) diff --git a/package-lock.json b/package-lock.json index ae992a4..bc80ad6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,12 +63,13 @@ } }, "node_modules/@bitfinex/bfx-facs-http": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-http.git#a87b066f90df3408237687e2cdeb130eb327cbdb", + "version": "1.1.0", + "resolved": "git+ssh://git@github.com/bitfinexcom/bfx-facs-http.git#a605a4d29f46ddc23f4e9bd70373d0ef8dd18fdb", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-base": "git+https://github.com/bitfinexcom/bfx-facs-base.git", "async": "^2.6.3", + "cacheable-lookup": "7.0.0", "lodash": "^4.17.21", "node-fetch": "2.6.7" }, @@ -223,9 +224,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -601,9 +602,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/wreck": { - "version": "18.1.1", - "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.1.tgz", - "integrity": "sha512-UwTeGBfAnB/1mkw4gD6IQGI/bgMu7iGmqgT8K+xxye3z4ZHhCZlmS2wuHBJmENhBJSKqvoYzJ71ds3Xfq4gofQ==", + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.2.tgz", + "integrity": "sha512-3dMnV2pfhQiyEqu8DL3VBmxkdLiRDiiUDuG79Dp+UK1gL9ZxAfDOUhB6k3D5MLqcgJJ1IARyGFhwoc1NITr/pg==", "license": "BSD-3-Clause", "dependencies": { "@hapi/boom": "^10.0.1", @@ -635,9 +636,9 @@ "license": "MIT" }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -921,22 +922,17 @@ } }, "node_modules/@tetherto/svc-facs-auth": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/tetherto/svc-facs-auth.git#b182b8b84db238f821be1d2856fd66e5683b0d9b", + "version": "1.1.1", + "resolved": "git+ssh://git@github.com/tetherto/svc-facs-auth.git#c25bba0eecea9c64805d8829c214f85518b9dd2e", "license": "Apache-2.0", "dependencies": { "@bitfinex/bfx-facs-base": "^1.1.0", "@bitfinexcom/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", - "async": "3.2.5", - "bcrypt": "5.1.1" + "async": "^3.2.5", + "bcrypt": "^5.1.1", + "jsonwebtoken": "^9.0.3" } }, - "node_modules/@tetherto/svc-facs-auth/node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "license": "MIT" - }, "node_modules/@tetherto/svc-facs-httpd": { "version": "1.0.0", "resolved": "git+ssh://git@github.com/tetherto/svc-facs-httpd.git#b11b1bac9143049ca998ff540d3135e9ed28e3b3", @@ -1369,9 +1365,9 @@ } }, "node_modules/autobase": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.28.0.tgz", - "integrity": "sha512-pyV0K5HuHtiDij43qw4RO3mdwqtiAgPB1YyzQcDMf8ypBU6UaA55mmtlUx5C50cHpwILls9RfR2dVEtIAD8tPg==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.28.1.tgz", + "integrity": "sha512-I8SHcJE/ru5Nun6NpqwRjenB8aK01b9z1qNeyx4XuG+BxeFUpXk0ZT2u3/nG+GZnObUTFMCKOo2fbAv7ipcaLw==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.1", @@ -1545,9 +1541,9 @@ } }, "node_modules/bare-crypto": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.6.tgz", - "integrity": "sha512-xaK6n2bZgpJD8fdRdRsWvBiwoiHpQXDMcA/g06jg1M0bQ95OhIj4/kRPLcgGfFEgr6ewmGOJPAGQGai54/wm+A==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.14.0.tgz", + "integrity": "sha512-k9QAFx67b0IVM0sII8SUbFvjC7oXQhEwkfVC3CcU6C7eaikkJUh2a3PbvQNc4zBx5QvE/zKw7WK2Jlkc7znVRQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1976,9 +1972,9 @@ } }, "node_modules/bare-tls": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.4.tgz", - "integrity": "sha512-0zmlDYkHjsU3h/I3Z69QZetBZibMUlcLI+OtHhQHeso/73si7/wN58EslxmG3SRx/b5Vx2kzqexlEBMDRvFveg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.5.tgz", + "integrity": "sha512-yoOtW3MyJF1mMwavLeuqOE7+qTKZ9cl1GRPxCUOXMUvYCfGltvXyRH48R4EKrRIfgUG6vil6n4Ea1i81fwmgZA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2252,6 +2248,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/builtins": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.1.0.tgz", @@ -2300,9 +2302,9 @@ "optional": true }, "node_modules/cacache/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "optional": true, "dependencies": { @@ -2371,6 +2373,15 @@ "node": ">=8" } }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2519,9 +2530,9 @@ } }, "node_modules/compact-encoding": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.0.1.tgz", - "integrity": "sha512-djstau5F2ipPDZhJLGeAfL1OpBxpJUgWjHt7HnWU+bXNYCgN8//NlWujaKmHBaqkAHe+lQ7xY5mjE+KkblAcng==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.1.0.tgz", + "integrity": "sha512-HcXBKXucAr+rqCAzS+SmOgqXXkvbrUqLrqc4FSBGQ6Ifh8SCVTDiIcSfML92ZGK1ARePJIyug0698gnmaAwlBw==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.3.0" @@ -2600,18 +2611,20 @@ } }, "node_modules/corestore": { - "version": "7.9.2", - "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.9.2.tgz", - "integrity": "sha512-dyIktVSVLu0skZ1UM4yODbNbZ46dBpTHypzwlwET1btVuS3YIf66Zz8Tx8h3Xa8nbLO2USGqrYtvPYzSRj2wCA==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.10.0.tgz", + "integrity": "sha512-7n93aUhFyWqdg3iVPboCkEc0B3eo0BXHTO1evF9grKMh09dFsHeTu0wpP9ffw2G1LfKA/B5ildevlRhP164Xmg==", "license": "MIT", "dependencies": { "b4a": "^1.6.7", - "hypercore": "^11.19.0", + "bare-events": "^2.8.3", + "hypercore": "^11.32.0", "hypercore-crypto": "^3.4.2", "hypercore-errors": "^1.4.0", "hypercore-id-encoding": "^1.3.0", "ready-resource": "^1.1.1", "sodium-universal": "^5.0.1", + "streamx": "^2.26.0", "which-runtime": "^1.2.1" } }, @@ -2891,6 +2904,15 @@ "stream-shift": "^1.0.2" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3080,9 +3102,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -3302,9 +3324,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3417,9 +3439,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3507,9 +3529,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3609,9 +3631,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3739,9 +3761,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -4535,9 +4557,9 @@ "license": "ISC" }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -4747,9 +4769,9 @@ } }, "node_modules/hypercore": { - "version": "11.30.1", - "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.30.1.tgz", - "integrity": "sha512-pu4zcs4DsKZWhRZRXNIC3RW+I8mYOG0VWDEWklVlt+INCpEbJoGwgqQSK8iIq1rAdOuT/omnELQNGnqtNI57OA==", + "version": "11.33.1", + "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.33.1.tgz", + "integrity": "sha512-fpAWVOM4CclWAPChmsh1nAfJy5pJlFOLUOiegRws2HYzUsj5bP/VwanZYv9ruhRXRLY1qJeddXLTEz4DJf1GQw==", "license": "MIT", "dependencies": { "@hyperswarm/secret-stream": "^6.0.0", @@ -4762,7 +4784,7 @@ "hypercore-crypto": "^3.2.1", "hypercore-errors": "^1.5.0", "hypercore-id-encoding": "^1.2.0", - "hypercore-storage": "^2.8.0", + "hypercore-storage": "^3.0.0", "is-options": "^1.0.1", "nanoassert": "^2.0.0", "protomux": "^3.5.0", @@ -4806,19 +4828,18 @@ } }, "node_modules/hypercore-storage": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.9.0.tgz", - "integrity": "sha512-MhufmceSHdst+wwysLg5ygSpsB75PiN8U5E1q1SYkAePVHiYbb4dEvKv4d9IyZRrJTBlqcWUEYf7oVosAixi9g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-3.0.2.tgz", + "integrity": "sha512-f2l6RMyCfYU3BBdTxxZSv5KqSkKWiUpb7clGGJv0kNRsR3iLi+XzkRwXLyoCtBZ7Vou4uEply+PZL1jA4LidUg==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.7", - "bare-fs": "^4.0.1", "bare-path": "^3.0.0", - "compact-encoding": "^3.0.0", + "compact-encoding": "^3.1.0", "device-file": "^2.1.2", "flat-tree": "^1.12.1", "hypercore-crypto": "^3.4.2", - "hyperschema": "^1.7.0", + "hyperschema": "^1.21.0", "index-encoder": "^3.3.2", "resolve-reject-promise": "^1.0.0", "rocksdb-native": "^3.11.0", @@ -5618,6 +5639,28 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5634,6 +5677,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kademlia-routing-table": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/kademlia-routing-table/-/kademlia-routing-table-1.0.6.tgz", @@ -5753,6 +5817,42 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5760,6 +5860,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5786,9 +5892,9 @@ } }, "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -6291,9 +6397,9 @@ "optional": true }, "node_modules/node-gyp/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "optional": true, "dependencies": { @@ -7333,14 +7439,14 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", @@ -7436,9 +7542,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7662,9 +7768,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8210,9 +8316,9 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", - "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz", + "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -8560,9 +8666,9 @@ "license": "MIT" }, "node_modules/thread-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.1.0.tgz", - "integrity": "sha512-Bw6h2iBDt16v6iHLChBIoVYU8CBo9GPsW8TG7h1hRVhqKhIkH6N8qkxNSmiOZTKsCLPbtWG4ViWLkU6KeKXpig==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", "license": "MIT", "dependencies": { "real-require": "^1.0.0" @@ -8609,12 +8715,12 @@ } }, "node_modules/toad-cache": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", - "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/toidentifier": { @@ -8741,18 +8847,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -9010,20 +9116,20 @@ } }, "node_modules/which-runtime": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.3.2.tgz", - "integrity": "sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.4.0.tgz", + "integrity": "sha512-0ugbP4CJW4e2D20jvEcC4973dCgIaHI4Rw1PT+26U9zEve7FyYdWAIwUnoeOYvoCfn+wXHoHTKb1KhkYlb60Pw==", "license": "Apache-2.0" }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -9080,9 +9186,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/tests/unit/handlers/miners.handlers.test.js b/tests/unit/handlers/miners.handlers.test.js index 04419fa..64915c7 100644 --- a/tests/unit/handlers/miners.handlers.test.js +++ b/tests/unit/handlers/miners.handlers.test.js @@ -5,7 +5,8 @@ const { listMiners, formatMiner, extractPoolWorkers, - buildOrkProjection + buildOrkProjection, + listFirmwares } = require('../../../workers/lib/server/handlers/miners.handlers') const { MINER_FIELD_MAP, @@ -527,3 +528,59 @@ test('listMiners - maps user fields to ork projection and filters response', asy t.is(miner.efficiency, undefined, 'excludes unrequested efficiency') t.pass() }) + +// --- listFirmwares --- + +test('listFirmwares - returns data from requestDataMap', async (t) => { + const firmwareData = [ + [ + { model: 'antminer-s19xp', version: '2024.01.15', url: 'http://example.com/fw1.bin' }, + { model: 'antminer-s19', version: '2023.11.01', url: 'http://example.com/fw2.bin' } + ] + ] + const ctx = { + dataProxy: { + requestDataMap: async (method, payload) => { + if (method === 'listFirmwares') return firmwareData + return [] + } + } + } + + const result = await listFirmwares(ctx, {}) + + t.alike(result, firmwareData) + t.pass() +}) + +test('listFirmwares - calls requestDataMap with listFirmwares method and empty payload', async (t) => { + const capturedCalls = [] + const ctx = { + dataProxy: { + requestDataMap: async (method, payload) => { + capturedCalls.push({ method, payload }) + return [[]] + } + } + } + + await listFirmwares(ctx, {}) + + t.is(capturedCalls.length, 1) + t.is(capturedCalls[0].method, 'listFirmwares') + t.alike(capturedCalls[0].payload, {}) + t.pass() +}) + +test('listFirmwares - returns empty array when no firmwares exist', async (t) => { + const ctx = { + dataProxy: { + requestDataMap: async () => [[]] + } + } + + const result = await listFirmwares(ctx, {}) + + t.alike(result, [[]]) + t.pass() +}) diff --git a/tests/unit/routes/miners.routes.test.js b/tests/unit/routes/miners.routes.test.js index 47f3e06..ad2028e 100644 --- a/tests/unit/routes/miners.routes.test.js +++ b/tests/unit/routes/miners.routes.test.js @@ -16,6 +16,7 @@ test('miners routes - route definitions', (t) => { const routeUrls = routes.map(route => route.url) t.ok(routeUrls.includes('/auth/miners'), 'should have miners route') + t.ok(routeUrls.includes('/auth/list-firmwares'), 'should have list-firmwares route') t.pass() }) @@ -26,6 +27,9 @@ test('miners routes - HTTP methods', (t) => { const minersRoute = routes.find(r => r.url === '/auth/miners') t.is(minersRoute.method, 'GET', 'miners route should be GET') + const firmwaresRoute = routes.find(r => r.url === '/auth/list-firmwares') + t.is(firmwaresRoute.method, 'GET', 'list-firmwares route should be GET') + t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index f9e7ecc..5ce8cf7 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -174,6 +174,7 @@ const ENDPOINTS = { ALERTS_HISTORY: '/auth/alerts/history', MINERS: '/auth/miners', + LIST_FIRMWARES: '/auth/list-firmwares', // Cooling System endpoints COOLING_SYSTEM: '/auth/dcs/cooling-system', // Energy System endpoints diff --git a/workers/lib/server/handlers/miners.handlers.js b/workers/lib/server/handlers/miners.handlers.js index d18a55b..0b42d70 100644 --- a/workers/lib/server/handlers/miners.handlers.js +++ b/workers/lib/server/handlers/miners.handlers.js @@ -172,9 +172,14 @@ async function listMiners (ctx, req) { } } +async function listFirmwares (ctx, req) { + return await ctx.dataProxy.requestDataMap('listFirmwares', {}) +} + module.exports = { listMiners, formatMiner, extractPoolWorkers, - buildOrkProjection + buildOrkProjection, + listFirmwares } diff --git a/workers/lib/server/routes/miners.routes.js b/workers/lib/server/routes/miners.routes.js index 38dca0e..577e04d 100644 --- a/workers/lib/server/routes/miners.routes.js +++ b/workers/lib/server/routes/miners.routes.js @@ -6,7 +6,7 @@ const { AUTH_CAPS, AUTH_LEVELS } = require('../../constants') -const { listMiners } = require('../handlers/miners.handlers') +const { listMiners, listFirmwares } = require('../handlers/miners.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') module.exports = (ctx) => [ @@ -41,5 +41,15 @@ module.exports = (ctx) => [ listMiners, [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.LIST_FIRMWARES, + ...createCachedAuthRoute( + ctx, + (req) => ['list-firmwares'], + ENDPOINTS.LIST_FIRMWARES, + listFirmwares + ) } ] From 50616a9cb99f33ebfe2417b8dc45f8c103f3c796 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:39:23 +0530 Subject: [PATCH 52/63] feat: miner log download via Hypercore P2P streaming (#85) * feat: add miner log download REST endpoints and unit tests * add LogDownloader for P2P miner log streaming Introduces a LogDownloader class that fetches miner log files directly from wrk-miner via Hypercore/Hyperswarm, bypassing the HRPC pipeline. Wired up in the http worker after facilities are ready. * trim verbose comments in log download handlers * Fix LOGS_PEER_NOT_FOUND --- .../unit/handlers/minerLogs.handlers.test.js | 335 +++++++++++++ tests/unit/lib/log-downloader.test.js | 455 ++++++++++++++++++ workers/http.node.wrk.js | 8 + workers/lib/constants.js | 6 + workers/lib/log-downloader.js | 106 ++++ .../lib/server/handlers/actions.handlers.js | 57 ++- .../lib/server/handlers/minerLogs.handlers.js | 146 ++++++ workers/lib/server/index.js | 2 + workers/lib/server/routes/actions.routes.js | 20 +- workers/lib/server/routes/minerLogs.routes.js | 59 +++ 10 files changed, 1191 insertions(+), 3 deletions(-) create mode 100644 tests/unit/handlers/minerLogs.handlers.test.js create mode 100644 tests/unit/lib/log-downloader.test.js create mode 100644 workers/lib/log-downloader.js create mode 100644 workers/lib/server/handlers/minerLogs.handlers.js create mode 100644 workers/lib/server/routes/minerLogs.routes.js diff --git a/tests/unit/handlers/minerLogs.handlers.test.js b/tests/unit/handlers/minerLogs.handlers.test.js new file mode 100644 index 0000000..8bea9a4 --- /dev/null +++ b/tests/unit/handlers/minerLogs.handlers.test.js @@ -0,0 +1,335 @@ +'use strict' + +const test = require('brittle') +const { + startMinerLogDownload, + getMinerLogDownloadStatus +} = require('../../../workers/lib/server/handlers/minerLogs.handlers') + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function makeMockReply () { + let _code = 200 + let _body = null + const reply = { + get statusCode () { return _code }, + get body () { return _body }, + code (statusCode) { + _code = statusCode + return reply + }, + send (body) { + _body = body + return body + } + } + return reply +} + +function makeMockReq (minerId = 'miner-001', jobId = null, token = 'test-token') { + const req = { + params: { minerId }, + _info: { + authToken: token, + user: { metadata: { email: 'ops@example.com' } } + } + } + if (jobId !== null) req.params.jobId = jobId + return req +} + +function makeMockCtx ({ write = true, permissions = ['admin'], requestDataResult = null } = {}) { + return { + authLib: { + getTokenPerms: async () => ({ write, permissions }) + }, + dataProxy: { + requestData: async (method, payload, callback) => { + if (requestDataResult === null) return [] + if (typeof callback === 'function') { + const arr = [] + const items = Array.isArray(requestDataResult) ? requestDataResult : [requestDataResult] + for (const item of items) callback(item, arr) + return arr + } + return Array.isArray(requestDataResult) ? requestDataResult : [requestDataResult] + } + } + } +} + +function makeActionResult (overrides = {}) { + return { + targets: { + 'rack-001': { + calls: [ + { + result: { + success: true, + data: { + coreKey: 'a'.repeat(64), + byteLength: 1024, + expiresAt: Date.now() + 3600000, + minerId: 'miner-001', + ...overrides.data + } + } + } + ] + } + }, + ...overrides + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// startMinerLogDownload +// ───────────────────────────────────────────────────────────────────────────── + +test('startMinerLogDownload - returns 202 with jobId on success', async (t) => { + const ctx = makeMockCtx({ requestDataResult: { id: '12345' } }) + const req = makeMockReq('miner-001') + const reply = makeMockReply() + + await startMinerLogDownload(ctx, req, reply) + + t.is(reply.statusCode, 202, 'should return 202 Accepted') + t.is(reply.body.jobId, '12345', 'should include jobId') + t.ok(reply.body.statusUrl.includes('/status'), 'should include statusUrl') + t.ok(reply.body.fileUrl.includes('/file'), 'should include fileUrl') + t.ok(reply.body.statusUrl.includes('miner-001'), 'statusUrl should include minerId') + t.ok(reply.body.fileUrl.includes('miner-001'), 'fileUrl should include minerId') + t.pass() +}) + +test('startMinerLogDownload - returns 403 when token has no write permission', async (t) => { + const ctx = makeMockCtx({ write: false }) + const req = makeMockReq('miner-001') + const reply = makeMockReply() + + await startMinerLogDownload(ctx, req, reply) + + t.is(reply.statusCode, 403, 'should return 403 Forbidden') + t.is(reply.body.error, 'ERR_WRITE_PERM_REQUIRED', 'should return ERR_WRITE_PERM_REQUIRED') + t.pass() +}) + +test('startMinerLogDownload - returns 400 when action submit fails', async (t) => { + const ctx = makeMockCtx({ requestDataResult: { id: null, errors: ['ERR_MINER_NOT_FOUND'] } }) + const req = makeMockReq('miner-001') + const reply = makeMockReply() + + await startMinerLogDownload(ctx, req, reply) + + t.is(reply.statusCode, 400, 'should return 400 when action has no valid id') + t.ok(reply.body.error, 'should include error message') + t.pass() +}) + +test('startMinerLogDownload - returns 400 with error from result when available', async (t) => { + const ctx = makeMockCtx({ requestDataResult: { id: null, errors: ['ERR_SPECIFIC_FAILURE'] } }) + const req = makeMockReq('miner-001') + const reply = makeMockReply() + + await startMinerLogDownload(ctx, req, reply) + + t.is(reply.statusCode, 400, 'should return 400') + t.is(reply.body.error, 'ERR_SPECIFIC_FAILURE', 'should propagate the action error message') + t.pass() +}) + +test('startMinerLogDownload - returns 500 on unexpected dataProxy error', async (t) => { + const ctx = { + authLib: { getTokenPerms: async () => ({ write: true, permissions: [] }) }, + dataProxy: { + requestData: async () => { throw new Error('connection refused') } + } + } + const req = makeMockReq('miner-001') + const reply = makeMockReply() + + await startMinerLogDownload(ctx, req, reply) + + t.is(reply.statusCode, 500, 'should return 500 on unexpected error') + t.ok(reply.body.error, 'should include error message') + t.pass() +}) + +test('startMinerLogDownload - uses minerId from route params', async (t) => { + let capturedPayload = null + const ctx = { + authLib: { getTokenPerms: async () => ({ write: true, permissions: ['admin'] }) }, + dataProxy: { + requestData: async (method, payload, callback) => { + capturedPayload = payload + const arr = [] + callback({ id: '99' }, arr) + return arr + } + } + } + const req = makeMockReq('specific-miner-id') + const reply = makeMockReply() + + await startMinerLogDownload(ctx, req, reply) + + t.is(capturedPayload.query.id, 'specific-miner-id', 'should use minerId from route params') + t.is(capturedPayload.action, 'downloadLogs', 'should submit downloadLogs action') + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getMinerLogDownloadStatus +// ───────────────────────────────────────────────────────────────────────────── + +test('getMinerLogDownloadStatus - returns pending when action not in done bucket', async (t) => { + const ctx = makeMockCtx({ requestDataResult: [] }) + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.statusCode, 200, 'should return 200') + t.is(reply.body.status, 'pending', 'should return pending status') + t.is(reply.body.jobId, '42', 'should echo jobId') + t.pass() +}) + +test('getMinerLogDownloadStatus - returns ready with metadata when log is available', async (t) => { + const expiresAt = Date.now() + 3600000 + const ctx = { + authLib: { getTokenPerms: async () => ({}) }, + dataProxy: { + requestData: async () => [makeActionResult({ data: { expiresAt, byteLength: 2048 } })] + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.statusCode, 200, 'should return 200') + t.is(reply.body.status, 'ready', 'should return ready status') + t.is(reply.body.jobId, '42', 'should echo jobId') + t.is(reply.body.byteLength, 2048, 'should include byteLength') + t.is(reply.body.expiresAt, expiresAt, 'should include expiresAt') + t.ok(reply.body.fileUrl.includes('/file'), 'should include fileUrl') + t.pass() +}) + +test('getMinerLogDownloadStatus - returns failed when no coreKey in targets', async (t) => { + const action = { + targets: { + 'rack-001': { + calls: [ + { result: { success: false, error_msg: 'ERR_MINER_UNREACHABLE' } } + ] + } + } + } + const ctx = { + authLib: { getTokenPerms: async () => ({}) }, + dataProxy: { requestData: async () => [action] } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.statusCode, 200, 'should return 200') + t.is(reply.body.status, 'failed', 'should return failed status') + t.is(reply.body.error, 'ERR_MINER_UNREACHABLE', 'should propagate error message from action result') + t.pass() +}) + +test('getMinerLogDownloadStatus - returns failed with generic error when no error_msg', async (t) => { + const action = { + targets: { + 'rack-001': { + calls: [{ result: { success: false } }] + } + } + } + const ctx = { + authLib: { getTokenPerms: async () => ({}) }, + dataProxy: { requestData: async () => [action] } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.body.status, 'failed', 'should return failed status') + t.is(reply.body.error, 'ERR_LOG_NOT_AVAILABLE', 'should use fallback error code') + t.pass() +}) + +test('getMinerLogDownloadStatus - returns expired when TTL has passed', async (t) => { + const ctx = { + authLib: { getTokenPerms: async () => ({}) }, + dataProxy: { + requestData: async () => [makeActionResult({ data: { expiresAt: Date.now() - 1000 } })] + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.statusCode, 200, 'should return 200') + t.is(reply.body.status, 'expired', 'should return expired status') + t.is(reply.body.error, 'ERR_LOG_EXPIRED', 'should return ERR_LOG_EXPIRED') + t.pass() +}) + +test('getMinerLogDownloadStatus - returns 500 on unexpected dataProxy error', async (t) => { + const ctx = { + authLib: { getTokenPerms: async () => ({}) }, + dataProxy: { + requestData: async () => { throw new Error('redis timeout') } + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.statusCode, 500, 'should return 500 on unexpected error') + t.ok(reply.body.error, 'should include error message') + t.pass() +}) + +test('getMinerLogDownloadStatus - finds successful result across multiple racks', async (t) => { + const expiresAt = Date.now() + 3600000 + const action = { + targets: { + 'rack-001': { + calls: [{ result: { success: false, error_msg: 'offline' } }] + }, + 'rack-002': { + calls: [ + { + result: { + success: true, + data: { coreKey: 'b'.repeat(64), byteLength: 512, expiresAt, minerId: 'miner-002' } + } + } + ] + } + } + } + const ctx = { + authLib: { getTokenPerms: async () => ({}) }, + dataProxy: { requestData: async () => [action] } + } + const req = makeMockReq('miner-002', '77') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.body.status, 'ready', 'should return ready when at least one rack has a result') + t.is(reply.body.byteLength, 512, 'should return correct byteLength from second rack') + t.pass() +}) diff --git a/tests/unit/lib/log-downloader.test.js b/tests/unit/lib/log-downloader.test.js new file mode 100644 index 0000000..b77d41b --- /dev/null +++ b/tests/unit/lib/log-downloader.test.js @@ -0,0 +1,455 @@ +'use strict' + +const test = require('brittle') +const { Readable } = require('node:stream') +const { randomBytes } = require('node:crypto') +const LogDownloader = require('../../../workers/lib/log-downloader') + +// ───────────────────────────────────────────────────────────────────────────── +// Fake builders +// ───────────────────────────────────────────────────────────────────────────── + +function makeDiscovery () { + let destroyed = false + return { + destroy: async () => { destroyed = true }, + get _destroyed () { return destroyed } + } +} + +function makeReadStream () { + return new Readable({ read () {} }) +} + +function makeCore (opts = {}) { + const key = opts.key || randomBytes(32) + const discoveryKey = opts.discoveryKey || randomBytes(32) + const rs = opts.readStream || makeReadStream() + let _length = opts.length !== undefined ? opts.length : 1 + let cleared = false + let closed = false + + return { + key, + discoveryKey, + get length () { return _length }, + set length (v) { _length = v }, + ready: async () => {}, + findingPeers: () => () => {}, + update: opts.update || (async () => ({ changed: true })), + createByteStream: () => rs, + replicate: () => {}, + clear: async () => { cleared = true }, + close: async () => { closed = true }, + _rs: rs, + get _cleared () { return cleared }, + get _closed () { return closed } + } +} + +function makeSwarm () { + const _listeners = {} + const joined = [] + + return { + joined, + on (event, fn) { + _listeners[event] = _listeners[event] || [] + _listeners[event].push(fn) + }, + join (discoveryKey, opts) { + joined.push({ discoveryKey, opts }) + return makeDiscovery() + }, + flush: async () => {}, + emit (event, ...args) { + for (const fn of (_listeners[event] || [])) fn(...args) + }, + listenerCount (event) { + return (_listeners[event] || []).length + } + } +} + +function makeNetFac (existingSwarm = null) { + let _swarm = existingSwarm + return { + get swarm () { return _swarm }, + startSwarm: async () => { _swarm = makeSwarm() } + } +} + +function makeStoreFac (coreFactory = null) { + return { + getCore (opts) { + return coreFactory ? coreFactory(opts) : makeCore({ key: opts.key }) + } + } +} + +const TEST_KEY = Buffer.alloc(32).fill(0x01) +const TEST_KEY_HEX = TEST_KEY.toString('hex') + +// ───────────────────────────────────────────────────────────────────────────── +// Constructor +// ───────────────────────────────────────────────────────────────────────────── + +test('LogDownloader - constructor stores facilities', (t) => { + const netFac = makeNetFac() + const storeFac = makeStoreFac() + + const dl = new LogDownloader({ netFac, storeFac }) + + t.ok(dl._netFac === netFac, 'should store netFac reference') + t.ok(dl._storeFac === storeFac, 'should store storeFac reference') + t.pass() +}) + +test('LogDownloader - constructor defaults peerTimeoutMs to 30s', (t) => { + const dl = new LogDownloader({ netFac: makeNetFac(), storeFac: makeStoreFac() }) + t.is(dl._peerTimeoutMs, 30000, 'should default to 30000ms') + t.pass() +}) + +test('LogDownloader - constructor accepts custom peerTimeoutMs', (t) => { + const dl = new LogDownloader({ netFac: makeNetFac(), storeFac: makeStoreFac(), peerTimeoutMs: 5000 }) + t.is(dl._peerTimeoutMs, 5000, 'should use provided peerTimeoutMs') + t.pass() +}) + +test('LogDownloader - constructor initialises empty downloads map', (t) => { + const dl = new LogDownloader({ netFac: makeNetFac(), storeFac: makeStoreFac() }) + t.ok(dl._downloads instanceof Map, 'should be a Map') + t.is(dl._downloads.size, 0, 'should start empty') + t.is(dl._swarmReady, false, 'swarmReady should start false') + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// _ensureSwarm +// ───────────────────────────────────────────────────────────────────────────── + +test('LogDownloader - _ensureSwarm calls startSwarm when swarm is null', async (t) => { + let startCalls = 0 + const swarm = makeSwarm() + const netFac = { + get swarm () { return startCalls > 0 ? swarm : null }, + startSwarm: async () => { startCalls++ } + } + + const dl = new LogDownloader({ netFac, storeFac: makeStoreFac() }) + await dl._ensureSwarm() + + t.is(startCalls, 1, 'should call startSwarm exactly once') + t.ok(dl._swarmReady, 'should set swarmReady to true') + t.pass() +}) + +test('LogDownloader - _ensureSwarm skips startSwarm when swarm already exists', async (t) => { + let startCalls = 0 + const swarm = makeSwarm() + const netFac = { + get swarm () { return swarm }, + startSwarm: async () => { startCalls++ } + } + + const dl = new LogDownloader({ netFac, storeFac: makeStoreFac() }) + await dl._ensureSwarm() + + t.is(startCalls, 0, 'should not call startSwarm when swarm already exists') + t.pass() +}) + +test('LogDownloader - _ensureSwarm registers connection handler exactly once', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const dl = new LogDownloader({ netFac, storeFac: makeStoreFac() }) + await dl._ensureSwarm() + await dl._ensureSwarm() + await dl._ensureSwarm() + + t.is(swarm.listenerCount('connection'), 1, 'should register connection listener only once') + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// stream — happy path +// ───────────────────────────────────────────────────────────────────────────── + +test('LogDownloader - stream returns a Readable', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const dl = new LogDownloader({ netFac, storeFac: makeStoreFac() }) + const rs = await dl.stream(TEST_KEY_HEX, 100) + + t.ok(rs instanceof Readable, 'should return a Readable stream') + t.pass() +}) + +test('LogDownloader - stream joins swarm as client only', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const dl = new LogDownloader({ netFac, storeFac: makeStoreFac() }) + await dl.stream(TEST_KEY_HEX, 100) + + t.is(swarm.joined.length, 1, 'should join exactly one DHT topic') + t.alike(swarm.joined[0].opts, { server: false, client: true }, 'should join as client only') + t.pass() +}) + +test('LogDownloader - stream registers download entry while active', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const dl = new LogDownloader({ netFac, storeFac: makeStoreFac() }) + await dl.stream(TEST_KEY_HEX, 100) + + t.is(dl._downloads.size, 1, 'should track the active download') + t.ok(dl._downloads.has(TEST_KEY_HEX), 'should key the entry by coreKeyHex') + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// stream — error cases +// ───────────────────────────────────────────────────────────────────────────── + +test('LogDownloader - stream throws ERR_LOG_PEER_TIMEOUT when update hangs', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const storeFac = makeStoreFac(() => { + const c = makeCore({ + length: 0, + update: () => new Promise(() => {}) // never resolves + }) + c.length = 0 + return c + }) + + const dl = new LogDownloader({ netFac, storeFac, peerTimeoutMs: 50 }) + + try { + await dl.stream(TEST_KEY_HEX, 100) + t.fail('should have thrown ERR_LOG_PEER_TIMEOUT') + } catch (err) { + t.is(err.message, 'ERR_LOG_PEER_TIMEOUT', 'should throw ERR_LOG_PEER_TIMEOUT') + } + + t.pass() +}) + +test('LogDownloader - stream throws ERR_LOG_PEER_NOT_FOUND when update has no change', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const storeFac = makeStoreFac(() => { + const c = makeCore({ update: async () => ({ changed: false }) }) + c.length = 0 + return c + }) + + const dl = new LogDownloader({ netFac, storeFac }) + + try { + await dl.stream(TEST_KEY_HEX, 100) + t.fail('should have thrown ERR_LOG_PEER_NOT_FOUND') + } catch (err) { + t.is(err.message, 'ERR_LOG_PEER_NOT_FOUND', 'should throw ERR_LOG_PEER_NOT_FOUND') + } + + t.pass() +}) + +test('LogDownloader - stream removes download from map on peer-not-found', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const storeFac = makeStoreFac(() => { + const c = makeCore({ update: async () => ({ changed: false }) }) + c.length = 0 + return c + }) + + const dl = new LogDownloader({ netFac, storeFac }) + + try { await dl.stream(TEST_KEY_HEX, 100) } catch {} + + t.is(dl._downloads.size, 0, 'should remove download from map after failure') + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// stream — auto-cleanup on stream events +// ───────────────────────────────────────────────────────────────────────────── + +test('LogDownloader - stream cleans up after stream end', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const rs = makeReadStream() + let coreClosed = false + + const storeFac = makeStoreFac(() => { + const c = makeCore() + c.createByteStream = () => rs + c.close = async () => { coreClosed = true } + return c + }) + + const dl = new LogDownloader({ netFac, storeFac }) + await dl.stream(TEST_KEY_HEX, 100) + + t.is(dl._downloads.size, 1, 'precondition: download tracked before stream ends') + + rs.emit('end') + await new Promise(r => setImmediate(r)) + + t.is(dl._downloads.size, 0, 'should remove download from map after stream end') + t.ok(coreClosed, 'should close core after stream end') + t.pass() +}) + +test('LogDownloader - stream cleans up after stream error', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const rs = makeReadStream() + rs.on('error', () => {}) // suppress unhandled error + let coreClosed = false + + const storeFac = makeStoreFac(() => { + const c = makeCore() + c.createByteStream = () => rs + c.close = async () => { coreClosed = true } + return c + }) + + const dl = new LogDownloader({ netFac, storeFac }) + await dl.stream(TEST_KEY_HEX, 100) + + rs.emit('error', new Error('connection dropped')) + await new Promise(r => setImmediate(r)) + + t.is(dl._downloads.size, 0, 'should remove download from map after stream error') + t.ok(coreClosed, 'should close core after stream error') + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// connection handler — topic-based routing +// ───────────────────────────────────────────────────────────────────────────── + +test('LogDownloader - connection handler replicates core with matching topic', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const replicateCalls = [] + const storeFac = makeStoreFac(() => { + const c = makeCore() + c.replicate = (socket) => replicateCalls.push(socket) + return c + }) + + const dl = new LogDownloader({ netFac, storeFac }) + await dl.stream(TEST_KEY_HEX, 100) + + const entry = dl._downloads.get(TEST_KEY_HEX) + const discoveryKeyBuf = Buffer.from(entry.discoveryKeyHex, 'hex') + + const fakeSocket = {} + swarm.emit('connection', fakeSocket, { topics: [discoveryKeyBuf] }) + + t.is(replicateCalls.length, 1, 'should replicate when topic matches discoveryKey') + t.ok(replicateCalls[0] === fakeSocket, 'should pass socket to replicate') + t.pass() +}) + +test('LogDownloader - connection handler skips core with non-matching topic', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const replicateCalls = [] + const storeFac = makeStoreFac(() => { + const c = makeCore() + c.replicate = (socket) => replicateCalls.push(socket) + return c + }) + + const dl = new LogDownloader({ netFac, storeFac }) + await dl.stream(TEST_KEY_HEX, 100) + + // Emit connection with a completely different topic + swarm.emit('connection', {}, { topics: [Buffer.alloc(32).fill(0xff)] }) + + t.is(replicateCalls.length, 0, 'should not replicate when topic does not match') + t.pass() +}) + +test('LogDownloader - connection handler handles empty topics list', async (t) => { + const swarm = makeSwarm() + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const replicateCalls = [] + const storeFac = makeStoreFac(() => { + const c = makeCore() + c.replicate = (socket) => replicateCalls.push(socket) + return c + }) + + const dl = new LogDownloader({ netFac, storeFac }) + await dl.stream(TEST_KEY_HEX, 100) + + swarm.emit('connection', {}, { topics: [] }) + + t.is(replicateCalls.length, 0, 'should not replicate when topics list is empty') + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// _cleanup +// ───────────────────────────────────────────────────────────────────────────── + +test('LogDownloader - _cleanup destroys discovery and closes core', async (t) => { + let discoveryDestroyed = false + let coreClosed = false + let coreCleared = false + + const discovery = { + destroy: async () => { discoveryDestroyed = true } + } + const core = makeCore() + core.length = 1 + core.close = async () => { coreClosed = true } + core.clear = async () => { coreCleared = true } + + const dl = new LogDownloader({ netFac: makeNetFac(makeSwarm()), storeFac: makeStoreFac() }) + dl._downloads.set(TEST_KEY_HEX, { core, discoveryKeyHex: TEST_KEY_HEX }) + + await dl._cleanup(TEST_KEY_HEX, core, discovery) + + t.ok(discoveryDestroyed, 'should destroy the DHT discovery') + t.ok(coreClosed, 'should close the core session') + t.ok(coreCleared, 'should clear downloaded blocks from storage') + t.is(dl._downloads.size, 0, 'should remove entry from downloads map') + t.pass() +}) + +test('LogDownloader - _cleanup skips clear when core has no blocks', async (t) => { + let coreCleared = false + + const discovery = { destroy: async () => {} } + const core = makeCore() + core.length = 0 + core.clear = async () => { coreCleared = true } + + const dl = new LogDownloader({ netFac: makeNetFac(makeSwarm()), storeFac: makeStoreFac() }) + dl._downloads.set(TEST_KEY_HEX, { core, discoveryKeyHex: TEST_KEY_HEX }) + + await dl._cleanup(TEST_KEY_HEX, core, discovery) + + t.not(coreCleared, true, 'should not call clear when length is 0') + t.pass() +}) diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index c79ca89..33029b8 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -14,6 +14,7 @@ const { AlertsService } = require('./lib/alerts') const { auditLogger } = require('./lib/server/lib/auditLogger') const { createDataProxy } = require('./lib/data.proxy') const { AUTH_CACHE_TTL } = require('./lib/constants') +const LogDownloader = require('./lib/log-downloader') class WrkServerHttp extends TetherWrkBase { constructor (conf, ctx) { @@ -32,6 +33,7 @@ class WrkServerHttp extends TetherWrkBase { this.isRpcMode = ctx.isRpcMode !== false if (ctx.ork) this.ork = ctx.ork this.dataProxy = createDataProxy(this) + this.logDownloader = null // initialised in _start() after facilities are ready this.init() this.start() @@ -89,6 +91,12 @@ class WrkServerHttp extends TetherWrkBase { async.series([ next => { super._start(next) }, async () => { + this.logDownloader = new LogDownloader({ + netFac: this.net_r0, + storeFac: this.store_s0, + peerTimeoutMs: this.conf?.logDownloader?.peerTimeoutMs + }) + await this.net_r0.startRpcServer() const httpd = this.httpd_h0 diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 5ce8cf7..1b35d83 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -112,6 +112,12 @@ const ENDPOINTS = { ACTIONS_VOTING_BATCH: '/auth/actions/voting/batch', ACTIONS_VOTE: '/auth/actions/voting/:id/vote', ACTIONS_CANCEL: '/auth/actions/voting/cancel', + DOWNLOAD_LOGS: '/auth/download-logs/:id', + + // Miner log download flow (start → poll status → stream file) + MINER_DOWNLOAD_LOGS_START: '/auth/miners/:minerId/download-logs', + MINER_DOWNLOAD_LOGS_STATUS: '/auth/miners/:minerId/download-logs/:jobId/status', + MINER_DOWNLOAD_LOGS_FILE: '/auth/miners/:minerId/download-logs/:jobId/file', // Logs endpoints TAIL_LOG: '/auth/tail-log', diff --git a/workers/lib/log-downloader.js b/workers/lib/log-downloader.js new file mode 100644 index 0000000..f61efc9 --- /dev/null +++ b/workers/lib/log-downloader.js @@ -0,0 +1,106 @@ +'use strict' + +const DEFAULT_PEER_TIMEOUT_MS = 60000 + +/** + * Downloads miner log files from wrk-miner via Hypercore/Hyperswarm P2P. + * + * Uses the hp-svc-facs-net facility (net_r0) for Hyperswarm and the + * hp-svc-facs-store facility (store_s0) for Hypercore/Corestore storage. + * No direct hypercore or hyperswarm require needed. + * + * The wrk-miner exposes a Hypercore identified by coreKey. + * This class: + * 1. Creates a read-only clone of that core via the Corestore facility + * 2. Joins Hyperswarm (via net_r0) on the core's discoveryKey to find the wrk-miner peer + * 3. Returns a Readable byte stream suitable for piping directly to an HTTP response + * 4. Clears downloaded blocks and closes the Corestore session after the stream ends + * + * One shared connection handler on net_r0.swarm replicates all active downloads on + * every connection (net_r0.swarm is not shared with the RPC layer, so all connections + * on this swarm are log-transfer peers). + */ +class LogDownloader { + constructor ({ netFac, storeFac, peerTimeoutMs } = {}) { + this._netFac = netFac + this._storeFac = storeFac + this._peerTimeoutMs = peerTimeoutMs || DEFAULT_PEER_TIMEOUT_MS + // coreKeyHex -> { core, discoveryKeyHex } + this._downloads = new Map() + this._swarmReady = false + } + + async _ensureSwarm () { + if (!this._netFac.swarm) { + await this._netFac.startSwarm() + } + + if (this._swarmReady) return + this._swarmReady = true + + this._netFac.swarm.on('connection', (socket) => { + for (const [, entry] of this._downloads) { + entry.core.replicate(socket) + } + }) + } + + /** + * Open a streaming read of a remote Hypercore and return it as a Node.js Readable. + * The stream fetches blocks lazily from the wrk-miner peer — no full buffering. + * + * @param {string} coreKeyHex Hex-encoded public key of the Hypercore (from action result) + * @param {number} byteLength Expected total byte length (for stream bounds + HTTP Content-Length) + * @returns {Promise} + */ + async stream (coreKeyHex, byteLength) { + await this._ensureSwarm() + + const core = this._storeFac.getCore({ key: Buffer.from(coreKeyHex, 'hex') }) + await core.ready() + + const discoveryKeyHex = core.discoveryKey.toString('hex') + this._downloads.set(coreKeyHex, { core, discoveryKeyHex }) + + const discovery = this._netFac.swarm.join(core.discoveryKey, { server: false, client: true }) + const peersDone = core.findingPeers() + const peersDoneTimer = setTimeout(peersDone, this._peerTimeoutMs) + + try { + await Promise.race([ + core.update(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('ERR_LOG_PEER_TIMEOUT')), this._peerTimeoutMs) + ) + ]) + } finally { + clearTimeout(peersDoneTimer) + peersDone() + } + + if (!core.length) { + await this._cleanup(coreKeyHex, core, discovery) + throw new Error('ERR_LOG_PEER_NOT_FOUND') + } + + // Lazy byte stream — blocks are fetched from the peer as the HTTP client reads + const rs = core.createByteStream({ byteOffset: 0, byteLength }) + + const cleanup = () => this._cleanup(coreKeyHex, core, discovery).catch(() => {}) + rs.once('end', cleanup) + rs.once('error', cleanup) + + return rs + } + + async _cleanup (coreKeyHex, core, discovery) { + this._downloads.delete(coreKeyHex) + try { await discovery.destroy() } catch {} + try { + if (core.length > 0) await core.clear(0, core.length) + await core.close() + } catch {} + } +} + +module.exports = LogDownloader diff --git a/workers/lib/server/handlers/actions.handlers.js b/workers/lib/server/handlers/actions.handlers.js index c1ddb20..a3bbd20 100644 --- a/workers/lib/server/handlers/actions.handlers.js +++ b/workers/lib/server/handlers/actions.handlers.js @@ -142,6 +142,60 @@ async function cancelActionsBatch (ctx, req) { }) } +// Action result contains only metadata (coreKey, byteLength, expiresAt) — actual bytes +// come directly from wrk-miner over Hypercore/Hyperswarm, bypassing the HRPC pipeline. +async function downloadLogFile (ctx, req, reply) { + const { id } = req.params + + const results = await ctx.dataProxy.requestData('getAction', { id, type: 'done' }) + const action = Array.isArray(results) ? results.find(r => r && !r.error) : results + + if (!action || !action.targets) { + return reply.code(404).send({ error: 'ERR_ACTION_NOT_FOUND' }) + } + + let meta = null + for (const rack of Object.values(action.targets)) { + for (const call of (rack.calls || [])) { + if (call.result?.success && call.result?.data?.coreKey) { + meta = call.result.data + break + } + } + if (meta) break + } + + if (!meta) { + return reply.code(404).send({ error: 'ERR_LOG_NOT_AVAILABLE' }) + } + + if (meta.expiresAt && Date.now() > meta.expiresAt) { + return reply.code(410).send({ error: 'ERR_LOG_EXPIRED' }) + } + + let stream + try { + stream = await ctx.logDownloader.stream(meta.coreKey, meta.byteLength) + } catch (err) { + const code = err.message === 'ERR_LOG_PEER_TIMEOUT' || err.message === 'ERR_LOG_PEER_NOT_FOUND' + ? 503 + : 500 + return reply.code(code).send({ error: err.message }) + } + + // Set headers only after stream is ready — if set before the try-catch and stream() + // throws, the error response would carry application/octet-stream content-type and + // Fastify would refuse to serialize the JSON error object. + const filename = `miner-log-${meta.minerId || 'unknown'}-${id}.log` + reply.header('Content-Type', 'application/octet-stream') + reply.header('Content-Disposition', `attachment; filename="${filename}"`) + reply.header('Content-Length', meta.byteLength) + reply.header('Cache-Control', 'no-store') + + // Fastify pipes a Readable stream directly to the HTTP response — no buffering + return reply.send(stream) +} + module.exports = { queryActionsBatch, queryActions, @@ -149,5 +203,6 @@ module.exports = { pushAction, voteAction, cancelActionsBatch, - pushActionsBatch + pushActionsBatch, + downloadLogFile } diff --git a/workers/lib/server/handlers/minerLogs.handlers.js b/workers/lib/server/handlers/minerLogs.handlers.js new file mode 100644 index 0000000..b7275e8 --- /dev/null +++ b/workers/lib/server/handlers/minerLogs.handlers.js @@ -0,0 +1,146 @@ +'use strict' + +const { downloadLogFile } = require('./actions.handlers') + +/** + * Miner log download — three-step REST flow for large async file transfers: + * + * 1. POST /auth/miners/:minerId/download-logs + * Submits a downloadLogs action for the miner. + * Returns 202 Accepted immediately with { jobId }. + * + * 2. GET /auth/miners/:minerId/download-logs/:jobId/status + * Polls whether the action has completed. + * Returns { status: 'pending' | 'ready' | 'failed' | 'expired', ... }. + * + * 3. GET /auth/miners/:minerId/download-logs/:jobId/file + * Streams the binary log file once status is 'ready'. + * Uses the Hypercore P2P pipeline — no buffering. + */ + +async function startMinerLogDownload (ctx, req, reply) { + const { minerId } = req.params + + const { write, permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) + if (!write) { + return reply.code(403).send({ error: 'ERR_WRITE_PERM_REQUIRED' }) + } + + const payload = { + query: { id: minerId }, + action: 'downloadLogs', + params: [], + voter: req._info.user.metadata.email, + authPerms: permissions + } + + let results + try { + results = await ctx.dataProxy.requestData('pushAction', payload, (res, arr) => { + if (res.error) { + arr.push({ id: null, errors: [res.error] }) + } else { + arr.push(res) + } + }) + } catch (err) { + return reply.code(500).send({ error: err.message }) + } + + const result = Array.isArray(results) + ? results.find(r => r && r.id !== null && r.id !== undefined) + : results + + if (!result || result.id == null) { + const errMsg = (Array.isArray(results) ? results[0]?.errors?.[0] : null) || 'ERR_ACTION_SUBMIT_FAILED' + return reply.code(400).send({ error: errMsg }) + } + + const jobId = String(result.id) + + return reply.code(202).send({ + jobId, + statusUrl: `/auth/miners/${encodeURIComponent(minerId)}/download-logs/${jobId}/status`, + fileUrl: `/auth/miners/${encodeURIComponent(minerId)}/download-logs/${jobId}/file` + }) +} + +/** + * GET /auth/miners/:minerId/download-logs/:jobId/status + * + * Polls the action result to determine whether the log is ready to download. + * + * Status values: + * pending — action not yet completed (still in voting/executing pipeline) + * ready — Hypercore is serving the log; use fileUrl to download + * failed — action completed but the miner returned an error + * expired — log was ready but the Hypercore TTL has passed + */ +async function getMinerLogDownloadStatus (ctx, req, reply) { + const { minerId, jobId } = req.params + + let action = null + try { + const results = await ctx.dataProxy.requestData('getAction', { id: jobId, type: 'done' }) + action = Array.isArray(results) ? results.find(r => r && !r.error) : results + } catch (err) { + return reply.code(500).send({ error: err.message }) + } + + // Action not yet in the 'done' bucket — still executing through the pipeline + if (!action || !action.targets) { + return reply.code(200).send({ status: 'pending', jobId }) + } + + let meta = null + let firstError = null + + for (const rack of Object.values(action.targets)) { + for (const call of (rack.calls || [])) { + if (call.result?.success && call.result?.data?.coreKey) { + meta = call.result.data + break + } + if (!firstError && call.result?.error_msg) { + firstError = call.result.error_msg + } + } + if (meta) break + } + + if (!meta) { + return reply.code(200).send({ + status: 'failed', + jobId, + error: firstError || 'ERR_LOG_NOT_AVAILABLE' + }) + } + + if (meta.expiresAt && Date.now() > meta.expiresAt) { + return reply.code(200).send({ + status: 'expired', + jobId, + error: 'ERR_LOG_EXPIRED' + }) + } + + return reply.code(200).send({ + status: 'ready', + jobId, + minerId: meta.minerId || minerId, + byteLength: meta.byteLength, + expiresAt: meta.expiresAt, + fileUrl: `/auth/miners/${encodeURIComponent(minerId)}/download-logs/${jobId}/file` + }) +} + +function getMinerLogFile (ctx, req, reply) { + // downloadLogFile expects req.params.id — bridge from our :jobId param + return downloadLogFile(ctx, { ...req, params: { ...req.params, id: req.params.jobId } }, reply) +} + +module.exports = { + startMinerLogDownload, + getMinerLogDownloadStatus, + getMinerLogFile +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 8d5011f..5ced8d0 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -16,6 +16,7 @@ const devicesRoutes = require('./routes/devices.routes') const metricsRoutes = require('./routes/metrics.routes') const alertsRoutes = require('./routes/alerts.routes') const minersRoutes = require('./routes/miners.routes') +const minerLogsRoutes = require('./routes/minerLogs.routes') const groupsRoutes = require('./routes/groups.routes') const coolingSystemRoutes = require('./routes/cooling.system.routes') const energySystemRoutes = require('./routes/energy.system.routes') @@ -47,6 +48,7 @@ function routes (ctx) { ...metricsRoutes(ctx), ...alertsRoutes(ctx), ...minersRoutes(ctx), + ...minerLogsRoutes(ctx), ...groupsRoutes(ctx), ...coolingSystemRoutes(ctx), ...energySystemRoutes(ctx), diff --git a/workers/lib/server/routes/actions.routes.js b/workers/lib/server/routes/actions.routes.js index dda05e6..c9ca539 100644 --- a/workers/lib/server/routes/actions.routes.js +++ b/workers/lib/server/routes/actions.routes.js @@ -11,9 +11,10 @@ const { pushAction, voteAction, cancelActionsBatch, - pushActionsBatch + pushActionsBatch, + downloadLogFile } = require('../handlers/actions.handlers') -const { createAuthRoute, createCachedAuthRoute } = require('../lib/routeHelpers') +const { createAuthRoute, createCachedAuthRoute, createAuthOnRequest } = require('../lib/routeHelpers') module.exports = (ctx) => { return [ @@ -154,6 +155,21 @@ module.exports = (ctx) => { } }, ...createAuthRoute(ctx, cancelActionsBatch) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.DOWNLOAD_LOGS, + schema: { + params: { + type: 'object', + properties: { + id: { type: 'string' } + }, + required: ['id'] + } + }, + onRequest: createAuthOnRequest(ctx), + handler: (req, reply) => downloadLogFile(ctx, req, reply) } ] } diff --git a/workers/lib/server/routes/minerLogs.routes.js b/workers/lib/server/routes/minerLogs.routes.js new file mode 100644 index 0000000..2836995 --- /dev/null +++ b/workers/lib/server/routes/minerLogs.routes.js @@ -0,0 +1,59 @@ +'use strict' + +const { ENDPOINTS, HTTP_METHODS } = require('../../constants') +const { + startMinerLogDownload, + getMinerLogDownloadStatus, + getMinerLogFile +} = require('../handlers/minerLogs.handlers') +const { createAuthOnRequest } = require('../lib/routeHelpers') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.MINER_DOWNLOAD_LOGS_START, + schema: { + params: { + type: 'object', + properties: { + minerId: { type: 'string' } + }, + required: ['minerId'] + } + }, + onRequest: createAuthOnRequest(ctx), + handler: (req, reply) => startMinerLogDownload(ctx, req, reply) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.MINER_DOWNLOAD_LOGS_STATUS, + schema: { + params: { + type: 'object', + properties: { + minerId: { type: 'string' }, + jobId: { type: 'string' } + }, + required: ['minerId', 'jobId'] + } + }, + onRequest: createAuthOnRequest(ctx), + handler: (req, reply) => getMinerLogDownloadStatus(ctx, req, reply) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.MINER_DOWNLOAD_LOGS_FILE, + schema: { + params: { + type: 'object', + properties: { + minerId: { type: 'string' }, + jobId: { type: 'string' } + }, + required: ['minerId', 'jobId'] + } + }, + onRequest: createAuthOnRequest(ctx), + handler: (req, reply) => getMinerLogFile(ctx, req, reply) + } +] From f31bc91421ee96bd12b161aadba4b86ca9688ad6 Mon Sep 17 00:00:00 2001 From: tekwani Date: Thu, 4 Jun 2026 12:19:20 +0530 Subject: [PATCH 53/63] feat: forecast history endpoint (#87) --- .../unit/handlers/minerLogs.handlers.test.js | 3 ++- tests/unit/lib/log-downloader.test.js | 25 +++++++++---------- workers/http.node.wrk.js | 4 +-- workers/lib/constants.js | 8 ++++-- workers/lib/log-downloader.js | 2 +- .../lib/server/handlers/energy.handlers.js | 17 +++++++++++-- workers/lib/server/routes/energy.routes.js | 22 +++++++++++++++- 7 files changed, 59 insertions(+), 22 deletions(-) diff --git a/tests/unit/handlers/minerLogs.handlers.test.js b/tests/unit/handlers/minerLogs.handlers.test.js index 8bea9a4..487c8dc 100644 --- a/tests/unit/handlers/minerLogs.handlers.test.js +++ b/tests/unit/handlers/minerLogs.handlers.test.js @@ -165,7 +165,8 @@ test('startMinerLogDownload - uses minerId from route params', async (t) => { requestData: async (method, payload, callback) => { capturedPayload = payload const arr = [] - callback({ id: '99' }, arr) + const res = { id: '99' } + callback(res, arr) return arr } } diff --git a/tests/unit/lib/log-downloader.test.js b/tests/unit/lib/log-downloader.test.js index b77d41b..6d19b85 100644 --- a/tests/unit/lib/log-downloader.test.js +++ b/tests/unit/lib/log-downloader.test.js @@ -105,9 +105,9 @@ test('LogDownloader - constructor stores facilities', (t) => { t.pass() }) -test('LogDownloader - constructor defaults peerTimeoutMs to 30s', (t) => { +test('LogDownloader - constructor defaults peerTimeoutMs to 60s', (t) => { const dl = new LogDownloader({ netFac: makeNetFac(), storeFac: makeStoreFac() }) - t.is(dl._peerTimeoutMs, 30000, 'should default to 30000ms') + t.is(dl._peerTimeoutMs, 60000, 'should default to 60000ms') t.pass() }) @@ -305,7 +305,7 @@ test('LogDownloader - stream cleans up after stream end', async (t) => { t.is(dl._downloads.size, 1, 'precondition: download tracked before stream ends') rs.emit('end') - await new Promise(r => setImmediate(r)) + await new Promise(resolve => setImmediate(resolve)) t.is(dl._downloads.size, 0, 'should remove download from map after stream end') t.ok(coreClosed, 'should close core after stream end') @@ -331,7 +331,7 @@ test('LogDownloader - stream cleans up after stream error', async (t) => { await dl.stream(TEST_KEY_HEX, 100) rs.emit('error', new Error('connection dropped')) - await new Promise(r => setImmediate(r)) + await new Promise(resolve => setImmediate(resolve)) t.is(dl._downloads.size, 0, 'should remove download from map after stream error') t.ok(coreClosed, 'should close core after stream error') @@ -339,10 +339,10 @@ test('LogDownloader - stream cleans up after stream error', async (t) => { }) // ───────────────────────────────────────────────────────────────────────────── -// connection handler — topic-based routing +// connection handler — replicate all active downloads // ───────────────────────────────────────────────────────────────────────────── -test('LogDownloader - connection handler replicates core with matching topic', async (t) => { +test('LogDownloader - connection handler replicates core on swarm connection', async (t) => { const swarm = makeSwarm() const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } @@ -362,12 +362,12 @@ test('LogDownloader - connection handler replicates core with matching topic', a const fakeSocket = {} swarm.emit('connection', fakeSocket, { topics: [discoveryKeyBuf] }) - t.is(replicateCalls.length, 1, 'should replicate when topic matches discoveryKey') + t.is(replicateCalls.length, 1, 'should replicate active download on connection') t.ok(replicateCalls[0] === fakeSocket, 'should pass socket to replicate') t.pass() }) -test('LogDownloader - connection handler skips core with non-matching topic', async (t) => { +test('LogDownloader - connection handler replicates without inspecting peer topics', async (t) => { const swarm = makeSwarm() const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } @@ -381,14 +381,13 @@ test('LogDownloader - connection handler skips core with non-matching topic', as const dl = new LogDownloader({ netFac, storeFac }) await dl.stream(TEST_KEY_HEX, 100) - // Emit connection with a completely different topic swarm.emit('connection', {}, { topics: [Buffer.alloc(32).fill(0xff)] }) - t.is(replicateCalls.length, 0, 'should not replicate when topic does not match') + t.is(replicateCalls.length, 1, 'should replicate on every swarm connection') t.pass() }) -test('LogDownloader - connection handler handles empty topics list', async (t) => { +test('LogDownloader - connection handler replicates when connection has no topics', async (t) => { const swarm = makeSwarm() const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } @@ -402,9 +401,9 @@ test('LogDownloader - connection handler handles empty topics list', async (t) = const dl = new LogDownloader({ netFac, storeFac }) await dl.stream(TEST_KEY_HEX, 100) - swarm.emit('connection', {}, { topics: [] }) + swarm.emit('connection', {}) - t.is(replicateCalls.length, 0, 'should not replicate when topics list is empty') + t.is(replicateCalls.length, 1, 'should replicate without peer topic metadata') t.pass() }) diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index 33029b8..97d2307 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -3,7 +3,7 @@ const async = require('async') const WebsocketPlugin = require('@fastify/websocket') const MultipartPlugin = require('@fastify/multipart') -const { WORK_ORDER_FILE_MAX_BYTES_DEFAULT } = require('./lib/constants') +const { WORK_ORDER_FILE_MAX_BYTES_DEFAULT, MICROSOFT_AUTH_SCOPE } = require('./lib/constants') const TetherWrkBase = require('@tetherto/tether-wrk-base/workers/base.wrk.tether') const AuthLib = require('./lib/auth') const debug = require('debug')('store:aggr') @@ -105,7 +105,7 @@ class WrkServerHttp extends TetherWrkBase { if (!this.noAuth) { httpd.addPlugin(httpdAuth.injection()) - httpd.addPlugin(httpdAuthMicrosoft.injection()) + httpd.addPlugin(httpdAuthMicrosoft.injection({ scope: MICROSOFT_AUTH_SCOPE })) } httpd.addPlugin([WebsocketPlugin, {}]) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 1b35d83..6eef72a 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -193,6 +193,7 @@ const ENDPOINTS = { EXPLORER_RACKS: '/auth/explorer/racks', // Energy endpoints ENERGY_FORECAST: '/auth/energy/forecast', + ENERGY_FORECAST_HISTORY: '/auth/energy/forecast/history', ENERGY_AVAILABLE: '/auth/energy/available', // Work Order endpoints WORK_ORDERS: '/auth/work-orders', @@ -577,7 +578,8 @@ const MINERPOOL_EXT_DATA_KEYS = { const ELECTRICITY_EXT_DATA_KEYS = { FORECAST: 'forecast', - AVAIL_ENERGY_MWH: 'availableEnergyMWh' + FORECAST_HISTORY: 'forecast-history', + AVAIL_ENERGY: 'availableEnergy' } const NON_METRIC_KEYS = [ @@ -697,6 +699,7 @@ const EXPLORER_RACK_AGGR_FIELDS = { const EXPLORER_RACK_DEFAULT_LIMIT = 20 const EXPLORER_RACK_MAX_LIMIT = 100 +const MICROSOFT_AUTH_SCOPE = ['openid', 'profile', 'email', 'User.Read'] module.exports = { SUPER_ADMIN_ROLE, @@ -772,5 +775,6 @@ module.exports = { WORK_ORDER_FILE_MAX_BYTES_DEFAULT, WORK_ORDER_FILE_COUNT_CAP_DEFAULT, WORK_ORDER_FILE_MIME_ALLOWLIST_DEFAULT, - WORK_ORDER_EXPORT_FORMATS + WORK_ORDER_EXPORT_FORMATS, + MICROSOFT_AUTH_SCOPE } diff --git a/workers/lib/log-downloader.js b/workers/lib/log-downloader.js index f61efc9..1a449e9 100644 --- a/workers/lib/log-downloader.js +++ b/workers/lib/log-downloader.js @@ -69,7 +69,7 @@ class LogDownloader { try { await Promise.race([ core.update(), - new Promise((_, reject) => + new Promise((resolve, reject) => setTimeout(() => reject(new Error('ERR_LOG_PEER_TIMEOUT')), this._peerTimeoutMs) ) ]) diff --git a/workers/lib/server/handlers/energy.handlers.js b/workers/lib/server/handlers/energy.handlers.js index 34d28f8..3c07466 100644 --- a/workers/lib/server/handlers/energy.handlers.js +++ b/workers/lib/server/handlers/energy.handlers.js @@ -11,17 +11,30 @@ const getEnergyForecast = async (ctx, req) => { }) } +const getEnergyForecastHistory = async (ctx, req) => { + const { start, end } = req.query + return await ctx.dataProxy.requestDataMap( + RPC_METHODS.GET_WRK_EXT_DATA, + { + type: WORKER_TYPES.ELECTRICITY, + query: { key: ELECTRICITY_EXT_DATA_KEYS.FORECAST_HISTORY }, + start, + end + }) +} + const setAvailableEnergy = async (ctx, req) => { return await ctx.dataProxy.requestDataMap( RPC_METHODS.SET_WRK_EXT_DATA, { type: WORKER_TYPES.ELECTRICITY, - key: ELECTRICITY_EXT_DATA_KEYS.AVAIL_ENERGY_MWH, + key: ELECTRICITY_EXT_DATA_KEYS.AVAIL_ENERGY, value: req.body.data }) } module.exports = { getEnergyForecast, - setAvailableEnergy + setAvailableEnergy, + getEnergyForecastHistory } diff --git a/workers/lib/server/routes/energy.routes.js b/workers/lib/server/routes/energy.routes.js index bab4d29..10116c0 100644 --- a/workers/lib/server/routes/energy.routes.js +++ b/workers/lib/server/routes/energy.routes.js @@ -1,7 +1,7 @@ 'use strict' const { ENDPOINTS, HTTP_METHODS, AUTH_CAPS } = require('../../constants') -const { getEnergyForecast, setAvailableEnergy } = require('../handlers/energy.handlers') +const { getEnergyForecast, setAvailableEnergy, getEnergyForecastHistory } = require('../handlers/energy.handlers') const { createCachedAuthRoute, createAuthRoute } = require('../lib/routeHelpers') const schemas = require('../schemas/energy.schemas') @@ -16,6 +16,26 @@ module.exports = (ctx) => [ getEnergyForecast ) }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.ENERGY_FORECAST_HISTORY, + schema: { + querystring: { + type: 'object', + properties: { + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 } + }, + required: ['start', 'end'] + } + }, + ...createCachedAuthRoute( + ctx, + (req) => ['energy-forecast-history'], + ENDPOINTS.ENERGY_FORECAST_HISTORY, + getEnergyForecastHistory + ) + }, { method: HTTP_METHODS.POST, url: ENDPOINTS.ENERGY_AVAILABLE, From 05aafdbc964dcd6cbff29818a0b9e230d7bb0c1d Mon Sep 17 00:00:00 2001 From: tekwani Date: Thu, 4 Jun 2026 17:27:29 +0530 Subject: [PATCH 54/63] feat: forecast settings (#89) * feat: forecast settings * get forecast settings endpoint --- workers/lib/constants.js | 4 ++- .../lib/server/handlers/energy.handlers.js | 23 +++++++++++++++- workers/lib/server/routes/energy.routes.js | 22 ++++++++++++++- workers/lib/server/schemas/energy.schemas.js | 27 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 6eef72a..9e40d3d 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -194,6 +194,7 @@ const ENDPOINTS = { // Energy endpoints ENERGY_FORECAST: '/auth/energy/forecast', ENERGY_FORECAST_HISTORY: '/auth/energy/forecast/history', + ENERGY_FORECAST_SETTINGS: '/auth/energy/forecast/settings', ENERGY_AVAILABLE: '/auth/energy/available', // Work Order endpoints WORK_ORDERS: '/auth/work-orders', @@ -578,7 +579,8 @@ const MINERPOOL_EXT_DATA_KEYS = { const ELECTRICITY_EXT_DATA_KEYS = { FORECAST: 'forecast', - FORECAST_HISTORY: 'forecast-history', + FORECAST_SETTINGS: 'forecastSettings', + FORECAST_HISTORY: 'forecastHistory', AVAIL_ENERGY: 'availableEnergy' } diff --git a/workers/lib/server/handlers/energy.handlers.js b/workers/lib/server/handlers/energy.handlers.js index 3c07466..7d1e832 100644 --- a/workers/lib/server/handlers/energy.handlers.js +++ b/workers/lib/server/handlers/energy.handlers.js @@ -33,8 +33,29 @@ const setAvailableEnergy = async (ctx, req) => { }) } +const getForecastSettings = async (ctx, req) => { + return await ctx.dataProxy.requestDataMap( + RPC_METHODS.GET_WRK_EXT_DATA, + { + type: WORKER_TYPES.ELECTRICITY, + query: { key: ELECTRICITY_EXT_DATA_KEYS.FORECAST_SETTINGS } + }) +} + +const setForecastSettings = async (ctx, req) => { + return await ctx.dataProxy.requestDataMap( + RPC_METHODS.SET_WRK_EXT_DATA, + { + type: WORKER_TYPES.ELECTRICITY, + key: ELECTRICITY_EXT_DATA_KEYS.FORECAST_SETTINGS, + value: req.body + }) +} + module.exports = { getEnergyForecast, setAvailableEnergy, - getEnergyForecastHistory + getEnergyForecastHistory, + setForecastSettings, + getForecastSettings } diff --git a/workers/lib/server/routes/energy.routes.js b/workers/lib/server/routes/energy.routes.js index 10116c0..a0877b2 100644 --- a/workers/lib/server/routes/energy.routes.js +++ b/workers/lib/server/routes/energy.routes.js @@ -1,7 +1,7 @@ 'use strict' const { ENDPOINTS, HTTP_METHODS, AUTH_CAPS } = require('../../constants') -const { getEnergyForecast, setAvailableEnergy, getEnergyForecastHistory } = require('../handlers/energy.handlers') +const { getEnergyForecast, setAvailableEnergy, getEnergyForecastHistory, setForecastSettings, getForecastSettings } = require('../handlers/energy.handlers') const { createCachedAuthRoute, createAuthRoute } = require('../lib/routeHelpers') const schemas = require('../schemas/energy.schemas') @@ -45,5 +45,25 @@ module.exports = (ctx) => [ schema: { body: schemas.body.availableEnergy } + }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.ENERGY_FORECAST_SETTINGS, + ...createAuthRoute(ctx, async (ctx, req) => { + return await setForecastSettings(ctx, req) + }, [`${AUTH_CAPS.m}:w`]), + schema: { + body: schemas.body.forecastSettings + } + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.ENERGY_FORECAST_SETTINGS, + ...createCachedAuthRoute( + ctx, + (req) => ['forecast-settings'], + ENDPOINTS.ENERGY_FORECAST_SETTINGS, + getForecastSettings + ) } ] diff --git a/workers/lib/server/schemas/energy.schemas.js b/workers/lib/server/schemas/energy.schemas.js index afb4a5a..aea0832 100644 --- a/workers/lib/server/schemas/energy.schemas.js +++ b/workers/lib/server/schemas/energy.schemas.js @@ -10,6 +10,33 @@ const schemas = { } }, required: ['data'] + }, + forecastSettings: { + type: 'object', + properties: { + miningRevenueTaxFees: { + type: 'object' + }, + sellingEnergyTaxFees: { + type: 'object' + }, + buyingEnergyTaxFees: { + type: 'object' + }, + lcoe: { + type: 'object' + }, + siteEfficiency: { + type: 'object' + } + }, + required: [ + 'miningRevenueTaxFees', + 'sellingEnergyTaxFees', + 'buyingEnergyTaxFees', + 'lcoe', + 'siteEfficiency' + ] } } } From 8c6088349a5b8db90989c380ce92fd923fac571f Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 4 Jun 2026 16:38:52 +0300 Subject: [PATCH 55/63] fix: add info params + fix WO auth (#88) * feat: add info params (notes, remarks, site, location) to work order schema Accept optional info object with notes, remarks, site, and location on POST /auth/work-orders and PATCH /auth/work-orders/:id. The handler merges these into the thing info alongside the top-level fields. * fix: auto-qualify bare permission names in tokenHasPerms tokenHasPerms received bare resource names (e.g. 'work_order') from route perms, but _permsMatch expects 'resource:level' format. Splitting a bare name by ':' left `required` as undefined, crashing on spread. --- .../handlers/work.orders.handlers.test.js | 26 +++++++++++++++++ tests/unit/lib/auth.test.js | 28 +++++++++++++++++++ workers/lib/auth.js | 6 +++- .../server/handlers/work.orders.handlers.js | 6 ++-- .../lib/server/schemas/work.orders.schemas.js | 24 ++++++++++++++-- 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/tests/unit/handlers/work.orders.handlers.test.js b/tests/unit/handlers/work.orders.handlers.test.js index bfeca37..ba6ae76 100644 --- a/tests/unit/handlers/work.orders.handlers.test.js +++ b/tests/unit/handlers/work.orders.handlers.test.js @@ -48,6 +48,32 @@ test('handlers: createWorkOrder Type 2 resolves part and forwards body as info', t.is(flow.lastPush.params[0].info.partsMoves[0].role, 'diagnosis') }) +test('handlers: createWorkOrder merges info.notes, info.remarks, info.site, info.location into thing info', async (t) => { + const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'SN-1' } }] }) + await handlers.createWorkOrder(flow.ctx, { + ...userMeta(), + body: { + type: 1, + deviceType: 'psu', + deviceModel: 'PSU-WM-CB6_V5', + deviceIdentifier: 'SN-1', + info: { + notes: 'batch registration', + remarks: 'test remark', + site: 'Ivinhema', + location: 'Site Warehouse' + } + } + }) + const info = flow.lastPush.params[0].info + t.is(info.notes, 'batch registration') + t.is(info.remarks, 'test remark') + t.is(info.site, 'Ivinhema') + t.is(info.location, 'Site Warehouse') + t.is(info.deviceType, 'psu', 'top-level fields still present') + t.ok(!info.info, 'no nested info.info') +}) + test('handlers: createWorkOrder rejects unknown deviceType with ERR_INVALID_DEVICE_TYPE', async (t) => { const flow = buildSubmitFlow() await t.exception( diff --git a/tests/unit/lib/auth.test.js b/tests/unit/lib/auth.test.js index 8ed56c4..c77b573 100644 --- a/tests/unit/lib/auth.test.js +++ b/tests/unit/lib/auth.test.js @@ -348,6 +348,34 @@ test('AuthLib - tokenHasPerms with matchAll=false', async (t) => { t.pass() }) +test('AuthLib - tokenHasPerms auto-qualifies bare perm names', async (t) => { + const mockAuth = { + getTokenPerms: function () { + return { superadmin: false, perms: ['work_order:rw', 'actions:rw'] } + }, + conf: { + superAdminPerms: [] + } + } + const authLib = new AuthLib({ + httpc: {}, + httpd: {}, + userService: {}, + auth: mockAuth + }) + + const resultWrite = await authLib.tokenHasPerms('token', true, ['work_order']) + t.is(resultWrite, true, 'bare perm with write=true should resolve to work_order:rw') + + const resultRead = await authLib.tokenHasPerms('token', false, ['work_order']) + t.is(resultRead, true, 'bare perm with write=false should resolve to work_order:r') + + const resultMissing = await authLib.tokenHasPerms('token', true, ['inventory']) + t.is(resultMissing, false, 'bare perm not in user perms should return false') + + t.pass() +}) + test('AuthLib - cleanupTokens', async (t) => { let cleanupTokensCalled = false const mockAuth = { diff --git a/workers/lib/auth.js b/workers/lib/auth.js index 626efc8..aa14975 100644 --- a/workers/lib/auth.js +++ b/workers/lib/auth.js @@ -88,7 +88,11 @@ class AuthLib { return false } - const resolved = requestedPerms.map(perm => this._permsMatch(perms.permissions, perm)) + const level = write ? 'rw' : 'r' + const resolved = requestedPerms.map(perm => { + const qualified = perm.includes(':') ? perm : `${perm}:${level}` + return this._permsMatch(perms.permissions, qualified) + }) return matchAll ? resolved.every(res => res) diff --git a/workers/lib/server/handlers/work.orders.handlers.js b/workers/lib/server/handlers/work.orders.handlers.js index e66e93d..81b7480 100644 --- a/workers/lib/server/handlers/work.orders.handlers.js +++ b/workers/lib/server/handlers/work.orders.handlers.js @@ -35,7 +35,8 @@ async function createWorkOrder (ctx, req) { } const voter = req._info.user.metadata.email - const info = { ...req.body, createdBy: voter, createdAt: Date.now() } + const { info: extraInfo, ...body } = req.body + const info = { ...body, ...extraInfo, createdBy: voter, createdAt: Date.now() } if (type === WORK_ORDER_TYPES.REGULAR) { const part = await _resolvePartByIdentifier(ctx, deviceIdentifier) @@ -73,7 +74,8 @@ async function createWorkOrder (ctx, req) { } async function updateWorkOrder (ctx, req) { - return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info: { ...req.body } }) + const { info: extraInfo, ...body } = req.body + return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info: { ...body, ...extraInfo } }) } async function closeWorkOrder (ctx, req) { diff --git a/workers/lib/server/schemas/work.orders.schemas.js b/workers/lib/server/schemas/work.orders.schemas.js index 55c300e..f0858e8 100644 --- a/workers/lib/server/schemas/work.orders.schemas.js +++ b/workers/lib/server/schemas/work.orders.schemas.js @@ -22,7 +22,17 @@ const create = { deviceIdentifier: { type: 'string', minLength: 1, maxLength: 200 }, issue: { type: 'string', minLength: 1, maxLength: 2000 }, assignedTo: { type: ['string', 'null'], maxLength: 200 }, - warranty + warranty, + info: { + type: 'object', + additionalProperties: false, + properties: { + notes: { type: 'string', maxLength: 4000 }, + remarks: { type: 'string', maxLength: 4000 }, + site: { type: 'string', maxLength: 200 }, + location: { type: 'string', maxLength: 200 } + } + } }, if: { properties: { type: { const: 2 } } }, then: { required: ['issue'] } @@ -73,7 +83,17 @@ const update = { deviceIdentifier: { type: 'string', minLength: 1, maxLength: 200 }, assignedTo: { type: ['string', 'null'], maxLength: 200 }, finalResult: { type: ['string', 'null'], maxLength: 4000 }, - warranty + warranty, + info: { + type: 'object', + additionalProperties: false, + properties: { + notes: { type: 'string', maxLength: 4000 }, + remarks: { type: 'string', maxLength: 4000 }, + site: { type: 'string', maxLength: 200 }, + location: { type: 'string', maxLength: 200 } + } + } } } } From 2d81fddf73cce369edec97a296d92e867c5154d4 Mon Sep 17 00:00:00 2001 From: tekwani Date: Tue, 9 Jun 2026 13:50:29 +0530 Subject: [PATCH 56/63] Chore/main sync (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ci pr trigger change main (#39) * chore: package json updates for publishing (#41) * chore: package json updates for publishing * update deps * ci: package publishing (#40) * ci: adding release pr process and version bump (#42) * Release: v1.0.0 (#44) * ci: release pr local jobs (#46) * ci: persistents credentials to allow tag creation (#48) * Release: v1.0.0 (#50) Co-authored-by: github-actions[bot] * chore: facs version update (#77) * refactor: logs cleanup (#78) * ci: CI Checks — dependency review, audits, and workflow improvements (#81) - Introduce security dependency review on pull requests - Improve audit reporting (dependency review, npm/pnpm audit, audit-ci on core) - Improve overall CI checks: single install + node_modules cache, lockfile gate, parallel jobs, summary Fleet: core. Suggested PR title matches commit subject. * reset ci updates --------- Co-authored-by: andretetherio Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- package-lock.json | 214 ++++++++++++++----------- package.json | 4 +- tests/integration/api.security.test.js | 2 +- tests/integration/api.test.js | 2 +- tests/integration/ws.test.js | 2 +- workers/lib/auth.js | 2 +- 6 files changed, 122 insertions(+), 104 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc80ad6..7e8bf48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,10 +30,10 @@ "miningos-app-node": "worker.js" }, "devDependencies": { + "@tetherto/tether-svc-test-helper": "git+https://github.com/tetherto/tether-svc-test-helper.git#v1.0.0", "brittle": "3.18.0", "http-server": "14.1.1", - "standard": "17.1.0", - "tether-svc-test-helper": "git+https://github.com/tetherto/tether-svc-test-helper.git" + "standard": "17.1.0" }, "engines": { "node": ">=20" @@ -968,6 +968,19 @@ "pino-abstract-transport": "^2.0.0" } }, + "node_modules/@tetherto/tether-svc-test-helper": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/tetherto/tether-svc-test-helper.git#534728262da110ce1005962e1dcbded34ef77de9", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bitfinex/bfx-svc-boot-js": "^1.2.0", + "async": "^3.2.6" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@tetherto/tether-wrk-base": { "version": "1.0.0", "resolved": "git+ssh://git@github.com/tetherto/tether-wrk-base.git#f49b78d29c60a2e84e6a22aa2e0eb5b64b036b76", @@ -1541,9 +1554,9 @@ } }, "node_modules/bare-crypto": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.14.0.tgz", - "integrity": "sha512-k9QAFx67b0IVM0sII8SUbFvjC7oXQhEwkfVC3CcU6C7eaikkJUh2a3PbvQNc4zBx5QvE/zKw7WK2Jlkc7znVRQ==", + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.15.3.tgz", + "integrity": "sha512-macV9lbyJTsLPRXJkBtz8ivTGEo3LCyJInLT9IB/PWJ7pRXwvHs/FP4bx/fWw+HZkiepIYCAV2cuU5CR92XWCw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1605,9 +1618,9 @@ } }, "node_modules/bare-events": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", - "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -1629,9 +1642,9 @@ } }, "node_modules/bare-fs": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", - "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -1660,16 +1673,16 @@ "license": "Apache-2.0" }, "node_modules/bare-http-parser": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.1.4.tgz", - "integrity": "sha512-DL+7fTEUWzAEj/Baw9e/BwNAidARbxuUf5bonQ/Wt3VPUdJNyf562ydaono9ZkQBAUw0NydzYEI97rSs/93ruA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.1.5.tgz", + "integrity": "sha512-sPaDetLbWRmth04JmuTCvw/hoNdWJvYUJN8n1tYdGW3HM0mMnCvK2f0oIxE6HK7iOq+WlsRzEMg1LT/b0wGbLQ==", "dev": true, "license": "Apache-2.0" }, "node_modules/bare-http1": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.5.6.tgz", - "integrity": "sha512-31OAwMkSU+z1VuUOCk65hx3aWQgzCfH/zQ6LGxbJtmiy2Czsw0+uvOBM9YkqaL6zUSTSYG2pLbL0v/TjME3Buw==", + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.5.7.tgz", + "integrity": "sha512-PRuzs9ywt4vUvrC3mnHhQyaQfLYzdFy8XKOH0oKeKmYfyYYUqXBkdbJE2csESLBxJEbKDBjxPGLU+Qa0l1ds4A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1767,9 +1780,9 @@ } }, "node_modules/bare-module-lexer": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/bare-module-lexer/-/bare-module-lexer-1.4.7.tgz", - "integrity": "sha512-0klU4eMsjh/wcxi8FdHmNom2j2F4kmkXOhyJFL9qTaSFp2lE3m6BtbKgMHY8R5miqC9r8/IfA8wzXnC5Os14WA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bare-module-lexer/-/bare-module-lexer-1.5.0.tgz", + "integrity": "sha512-FXjqDdVrR9zizyHcZvJtkUNe9CanFo9eOmo33O8CGEL45xYMiZDUvAqoQhsHb/RK2QKsryAyKiNv0W5kiALzmw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1802,9 +1815,9 @@ } }, "node_modules/bare-net": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.1.tgz", - "integrity": "sha512-MypSqDKpDU2Xt7FIfazn5yGvRnV09gFcIPHGWstW0gxuzA4tucTcwJSZeos97C4F89vtU5oGwXDN/HrGN6Y4Jw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.2.tgz", + "integrity": "sha512-I+yz+pqbYsBkxDsnu5vkKvy7RSNY9CcAvu2jZT6PsmdXJQG1i3dmD5V7xc3334OVp2absgtUEYLmmuNFlphBzg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1824,18 +1837,18 @@ } }, "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", "license": "Apache-2.0", "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-pipe": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.1.5.tgz", - "integrity": "sha512-6OfxaG8JSkRh3Gc4hzHRsxNt+yu2PpN7lrv1V+T78GdknWQkVGwiEvu4m+1nbfk8cMVQ0TGxRvQ90XA4rhnTuw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.2.1.tgz", + "integrity": "sha512-il5ssf8MGPHtuaHnqjAiJHUzNQ3dahjkIMAo1k+7+zxdqscZZf8gLyptjFGQHVNHb24z8zwmAXpMBeNcFjwMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1957,9 +1970,9 @@ } }, "node_modules/bare-tcp": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.13.tgz", - "integrity": "sha512-4KQPgqYugvK6QxcSnVGbl87XslBebxmXlv7Glf4M9iwwoSCDKtYmC1t6zsMctTNhzKXbWCId7mB4R9qLWj3JMw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.5.0.tgz", + "integrity": "sha512-lwUy3jSVoloVBbCCyPFmmqT1KaeBk/XEkpLMHU+BCap8WNXc48iQfiWEQYgJkCRYuP6vnkZ0XHCLY222TJ29Wg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1969,12 +1982,20 @@ }, "engines": { "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-pipe": "*" + }, + "peerDependenciesMeta": { + "bare-pipe": { + "optional": true + } } }, "node_modules/bare-tls": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.5.tgz", - "integrity": "sha512-yoOtW3MyJF1mMwavLeuqOE7+qTKZ9cl1GRPxCUOXMUvYCfGltvXyRH48R4EKrRIfgUG6vil6n4Ea1i81fwmgZA==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.7.tgz", + "integrity": "sha512-AMw8tJlb3LhzAmhgXRcjDrTlNxR3gXXyj6G8eU9iwvCFtiUBD8MxAW7bwunA1gXDukgo40A970jX0APc2jMU7A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2010,9 +2031,9 @@ } }, "node_modules/bare-url": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", - "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -2530,9 +2551,9 @@ } }, "node_modules/compact-encoding": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.1.0.tgz", - "integrity": "sha512-HcXBKXucAr+rqCAzS+SmOgqXXkvbrUqLrqc4FSBGQ6Ifh8SCVTDiIcSfML92ZGK1ARePJIyug0698gnmaAwlBw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.2.0.tgz", + "integrity": "sha512-cCe/FM8f/WuXkEVpsYEoSX3ht2tec7NSNOC8rZBv/sbdFzO3h2Z/JUmZwJQBhghKJNimJSkWM40YURhgU640jw==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.3.0" @@ -2611,9 +2632,9 @@ } }, "node_modules/corestore": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.10.0.tgz", - "integrity": "sha512-7n93aUhFyWqdg3iVPboCkEc0B3eo0BXHTO1evF9grKMh09dFsHeTu0wpP9ffw2G1LfKA/B5ildevlRhP164Xmg==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.10.1.tgz", + "integrity": "sha512-QdcNtpFSYtVHA4UcUSSf2m9gNMT9QqSs9fn+pZx6xdUivNono18JhtpEP/TYpP9heKB7yd+qLsLQP5bo65sg6A==", "license": "MIT", "dependencies": { "b4a": "^1.6.7", @@ -4828,9 +4849,9 @@ } }, "node_modules/hypercore-storage": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-3.0.2.tgz", - "integrity": "sha512-f2l6RMyCfYU3BBdTxxZSv5KqSkKWiUpb7clGGJv0kNRsR3iLi+XzkRwXLyoCtBZ7Vou4uEply+PZL1jA4LidUg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-3.1.1.tgz", + "integrity": "sha512-UEOtZDT2ftGSrGEfwUTzOprhwICxNHfSYgl6BxgdJZDpV18hllbUQIBdjkH+IfRNBg5c85ehSY8VZJ6bIPwPEQ==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.7", @@ -5568,10 +5589,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7585,9 +7616,9 @@ } }, "node_modules/rocksdb-native": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.15.1.tgz", - "integrity": "sha512-T9inGQj9pLNxnWeW2hRGGT9+FGGGFqHUSmTzrEClFQRCo4Jg7YWfIwLasWOZy65VPo9BrwCz5ZHFf7n54Kakzw==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.15.2.tgz", + "integrity": "sha512-8fXcL6yVfHd14NySHCNLcMKkbTO81aFog5aMrLU92vBMHpycSgqGVIevqeILGQ/jt0FAZy9lMqWO82f4eZVh/A==", "license": "Apache-2.0", "dependencies": { "compact-encoding": "^3.0.0", @@ -7768,9 +7799,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7879,15 +7910,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -8316,9 +8347,9 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz", - "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==", + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.27.0.tgz", + "integrity": "sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -8409,19 +8440,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8431,16 +8463,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -8535,9 +8567,9 @@ } }, "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -8635,20 +8667,6 @@ "bare-path": "^3.0.0" } }, - "node_modules/tether-svc-test-helper": { - "name": "@tetherto/tether-svc-test-helper", - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/tetherto/tether-svc-test-helper.git#534728262da110ce1005962e1dcbded34ef77de9", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@bitfinex/bfx-svc-boot-js": "^1.2.0", - "async": "^3.2.6" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -9122,9 +9140,9 @@ "license": "Apache-2.0" }, "node_modules/which-typed-array": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", - "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ecabc64..e534f1b 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,11 @@ "start": "node worker.js --wtype wrk-node-http --env production --port 3000" }, "dependencies": { + "@bitfinex/bfx-svc-boot-js": "1.2.0", "@bitfinex/bfx-facs-db-sqlite": "git+https://github.com/bitfinexcom/bfx-facs-db-sqlite.git", "@bitfinex/bfx-facs-http": "git+https://github.com/bitfinexcom/bfx-facs-http.git", "@bitfinex/bfx-facs-interval": "git+https://github.com/bitfinexcom/bfx-facs-interval.git", "@bitfinex/bfx-facs-lru": "git+https://github.com/bitfinexcom/bfx-facs-lru.git", - "@bitfinex/bfx-svc-boot-js": "1.2.0", "@bitfinex/lib-js-util-base": "git+https://github.com/bitfinexcom/lib-js-util-base.git", "@fastify/multipart": "^10.0.0", "@fastify/websocket": "11.2.0", @@ -63,7 +63,7 @@ "brittle": "3.18.0", "http-server": "14.1.1", "standard": "17.1.0", - "tether-svc-test-helper": "git+https://github.com/tetherto/tether-svc-test-helper.git" + "@tetherto/tether-svc-test-helper": "git+https://github.com/tetherto/tether-svc-test-helper.git#v1.0.0" }, "overrides": { "@tootallnate/once": "3.0.1", diff --git a/tests/integration/api.security.test.js b/tests/integration/api.security.test.js index 98e2ae7..7f3bf0a 100644 --- a/tests/integration/api.security.test.js +++ b/tests/integration/api.security.test.js @@ -2,7 +2,7 @@ const test = require('brittle') const fs = require('fs') -const { createWorker } = require('tether-svc-test-helper').worker +const { createWorker } = require('@tetherto/tether-svc-test-helper').worker const { setTimeout: sleep } = require('timers/promises') const HttpFacility = require('@bitfinex/bfx-facs-http') const { ENDPOINTS } = require('../../workers/lib/constants') diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js index e4e9603..bb1ee6e 100644 --- a/tests/integration/api.test.js +++ b/tests/integration/api.test.js @@ -2,7 +2,7 @@ const test = require('brittle') const fs = require('fs') -const { createWorker } = require('tether-svc-test-helper').worker +const { createWorker } = require('@tetherto/tether-svc-test-helper').worker const { setTimeout: sleep } = require('timers/promises') const HttpFacility = require('@bitfinex/bfx-facs-http') const { ENDPOINTS } = require('../../workers/lib/constants') diff --git a/tests/integration/ws.test.js b/tests/integration/ws.test.js index b2dcfd1..76de0d3 100644 --- a/tests/integration/ws.test.js +++ b/tests/integration/ws.test.js @@ -3,7 +3,7 @@ const test = require('brittle') const fs = require('fs') const WebSocket = require('ws') -const { createWorker } = require('tether-svc-test-helper').worker +const { createWorker } = require('@tetherto/tether-svc-test-helper').worker const { setTimeout: sleep } = require('timers/promises') const { ENDPOINTS } = require('../../workers/lib/constants') diff --git a/workers/lib/auth.js b/workers/lib/auth.js index aa14975..bb65a56 100644 --- a/workers/lib/auth.js +++ b/workers/lib/auth.js @@ -38,7 +38,7 @@ class AuthLib { try { await this._userService.createUser({ email: oldUser.email, role }) } catch (error) { - console.error(`Failed to migrate user: ${oldUser.email}`, error) + console.error('ERR_MIGRATE_USER', error) } })) } catch (error) { From 2f53d4eade588da5740834a921a1c5a5989033a7 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:57:52 +0530 Subject: [PATCH 57/63] fix: harden miner download-logs API and P2P log streaming (#94) - LogDownloader: replicate cores onto already-open Hyperswarm connections (reused peer sockets get no 'connection' event), fixing 503 ERR_LOG_PEER_NOT_FOUND on every download after the first - accept empty JSON bodies on POST (body-less download-logs start request) - status endpoint: include a human-readable message with worker error codes --- .gitignore | 5 + .../unit/handlers/minerLogs.handlers.test.js | 98 ++++++++++++++++++- tests/unit/lib/log-downloader.test.js | 38 +++++++ workers/http.node.wrk.js | 15 +++ workers/lib/log-downloader.js | 18 +++- .../lib/server/handlers/minerLogs.handlers.js | 21 +++- 6 files changed, 191 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index d8552d4..8fb2eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,10 @@ tests/integration/db/* tests/integration/config/* tests/integration/status/* tests/integration/store/* +tests/e2e/db/* +tests/e2e/config/* +tests/e2e/status/* +tests/e2e/store/* +tests/e2e/tmp/* coverage .scannerwork diff --git a/tests/unit/handlers/minerLogs.handlers.test.js b/tests/unit/handlers/minerLogs.handlers.test.js index 487c8dc..552171c 100644 --- a/tests/unit/handlers/minerLogs.handlers.test.js +++ b/tests/unit/handlers/minerLogs.handlers.test.js @@ -3,7 +3,8 @@ const test = require('brittle') const { startMinerLogDownload, - getMinerLogDownloadStatus + getMinerLogDownloadStatus, + getMinerLogFile } = require('../../../workers/lib/server/handlers/minerLogs.handlers') // ───────────────────────────────────────────────────────────────────────────── @@ -302,6 +303,101 @@ test('getMinerLogDownloadStatus - returns 500 on unexpected dataProxy error', as t.pass() }) +test('getMinerLogDownloadStatus - failed status carries the worker error code and a readable message', async (t) => { + const workerErrors = [ + 'ERR_DOWNLOAD_LOGS_PARSE_FAILED', + 'ERR_DOWNLOAD_LOGS_FAILED: Code 45', + 'ERR_DOWNLOAD_LOGS_EMPTY', + 'ERR_DOWNLOAD_LOGS_TIMEOUT', + 'ERR_DOWNLOAD_LOGS_INCOMPLETE: connection closed after 10/100 bytes', + 'ERR_DOWNLOAD_LOGS_CONNECT_FAILED: connect ECONNREFUSED' + ] + + for (const errMsg of workerErrors) { + const action = { + targets: { + 'rack-001': { calls: [{ result: { success: false, error_msg: errMsg } }] } + } + } + const ctx = { + authLib: { getTokenPerms: async () => ({}) }, + dataProxy: { requestData: async () => [action] } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.body.status, 'failed', `status should be failed for ${errMsg}`) + t.is(reply.body.error, errMsg, 'error should carry the raw worker error code') + t.ok(typeof reply.body.message === 'string' && reply.body.message.length > 0, + `should include a human-readable message for ${errMsg}`) + t.not(reply.body.message, errMsg, 'message should not just repeat the raw code') + } + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getMinerLogFile +// ───────────────────────────────────────────────────────────────────────────── + +test('getMinerLogFile - bridges :jobId to downloadLogFile and returns 404 for unknown action', async (t) => { + let capturedParams = null + const ctx = { + dataProxy: { + requestData: async (method, params) => { + capturedParams = { method, params } + return [] + } + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogFile(ctx, req, reply) + + t.is(capturedParams.method, 'getAction', 'should fetch the action') + t.is(capturedParams.params.id, '42', 'should pass the jobId as the action id') + t.is(reply.statusCode, 404, 'should return 404 for unknown action') + t.is(reply.body.error, 'ERR_ACTION_NOT_FOUND', 'should return ERR_ACTION_NOT_FOUND') + t.pass() +}) + +test('getMinerLogFile - returns 410 when log TTL has expired', async (t) => { + const ctx = { + dataProxy: { + requestData: async () => [makeActionResult({ data: { expiresAt: Date.now() - 1000 } })] + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogFile(ctx, req, reply) + + t.is(reply.statusCode, 410, 'should return 410 Gone') + t.is(reply.body.error, 'ERR_LOG_EXPIRED', 'should return ERR_LOG_EXPIRED') + t.pass() +}) + +test('getMinerLogFile - returns 503 when the log peer is unreachable', async (t) => { + const ctx = { + dataProxy: { + requestData: async () => [makeActionResult()] + }, + logDownloader: { + stream: async () => { throw new Error('ERR_LOG_PEER_TIMEOUT') } + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogFile(ctx, req, reply) + + t.is(reply.statusCode, 503, 'should return 503 when peer unreachable') + t.is(reply.body.error, 'ERR_LOG_PEER_TIMEOUT', 'should propagate the peer error code') + t.pass() +}) + test('getMinerLogDownloadStatus - finds successful result across multiple racks', async (t) => { const expiresAt = Date.now() + 3600000 const action = { diff --git a/tests/unit/lib/log-downloader.test.js b/tests/unit/lib/log-downloader.test.js index 6d19b85..daf5c2e 100644 --- a/tests/unit/lib/log-downloader.test.js +++ b/tests/unit/lib/log-downloader.test.js @@ -200,6 +200,44 @@ test('LogDownloader - stream joins swarm as client only', async (t) => { t.pass() }) +test('LogDownloader - stream replicates the core onto existing swarm connections', async (t) => { + // Hyperswarm reuses peer connections across topics — no 'connection' event fires + const swarm = makeSwarm() + const existingSocket = { id: 'existing-peer-socket' } + swarm.connections = new Set([existingSocket]) + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const replicated = [] + const core = makeCore({ key: TEST_KEY }) + core.replicate = (socket) => replicated.push(socket) + + const dl = new LogDownloader({ netFac, storeFac: { getCore: () => core } }) + await dl.stream(TEST_KEY_HEX, 100) + + t.is(replicated.length, 1, 'should replicate the core onto the existing connection') + t.ok(replicated[0] === existingSocket, 'should replicate onto the existing socket') + t.pass() +}) + +test('LogDownloader - core is not replicated twice on the same socket', async (t) => { + const swarm = makeSwarm() + const existingSocket = { id: 'peer-socket' } + swarm.connections = new Set([existingSocket]) + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const replicated = [] + const core = makeCore({ key: TEST_KEY }) + core.replicate = (socket) => replicated.push(socket) + + const dl = new LogDownloader({ netFac, storeFac: { getCore: () => core } }) + await dl.stream(TEST_KEY_HEX, 100) + + swarm.emit('connection', existingSocket) + + t.is(replicated.length, 1, 'should not replicate the same core twice on one socket') + t.pass() +}) + test('LogDownloader - stream registers download entry while active', async (t) => { const swarm = makeSwarm() const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index 97d2307..6f9d5b4 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -3,6 +3,7 @@ const async = require('async') const WebsocketPlugin = require('@fastify/websocket') const MultipartPlugin = require('@fastify/multipart') +const fastifyPlugin = require('fastify-plugin') const { WORK_ORDER_FILE_MAX_BYTES_DEFAULT, MICROSOFT_AUTH_SCOPE } = require('./lib/constants') const TetherWrkBase = require('@tetherto/tether-wrk-base/workers/base.wrk.tether') const AuthLib = require('./lib/auth') @@ -113,6 +114,20 @@ class WrkServerHttp extends TetherWrkBase { limits: { fileSize: this.conf.workOrderFileMaxBytes || WORK_ORDER_FILE_MAX_BYTES_DEFAULT, files: 1 } }]) + // Tolerate empty JSON bodies (Fastify rejects them by default); + // routes that require a body still fail schema validation + httpd.addPlugin([fastifyPlugin(async (instance) => { + instance.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => { + if (body === '' || body === undefined) return done(null, undefined) + try { + done(null, JSON.parse(body)) + } catch (err) { + err.statusCode = 400 + done(err) + } + }) + }), {}]) + libServer.routes(this).forEach(r => { httpd.addRoute(r) }) diff --git a/workers/lib/log-downloader.js b/workers/lib/log-downloader.js index 1a449e9..bc06ba5 100644 --- a/workers/lib/log-downloader.js +++ b/workers/lib/log-downloader.js @@ -40,11 +40,19 @@ class LogDownloader { this._netFac.swarm.on('connection', (socket) => { for (const [, entry] of this._downloads) { - entry.core.replicate(socket) + this._replicateCore(entry, socket) } }) } + // Replicates a core onto a socket exactly once. Hyperswarm reuses peer + // connections across topics, so new cores must also reach open sockets. + _replicateCore (entry, socket) { + if (entry.seenSockets.has(socket)) return + entry.seenSockets.add(socket) + entry.core.replicate(socket) + } + /** * Open a streaming read of a remote Hypercore and return it as a Node.js Readable. * The stream fetches blocks lazily from the wrk-miner peer — no full buffering. @@ -60,7 +68,13 @@ class LogDownloader { await core.ready() const discoveryKeyHex = core.discoveryKey.toString('hex') - this._downloads.set(coreKeyHex, { core, discoveryKeyHex }) + const entry = { core, discoveryKeyHex, seenSockets: new WeakSet() } + this._downloads.set(coreKeyHex, entry) + + // No 'connection' event fires for reused peer sockets — replicate explicitly + for (const socket of this._netFac.swarm.connections || []) { + this._replicateCore(entry, socket) + } const discovery = this._netFac.swarm.join(core.discoveryKey, { server: false, client: true }) const peersDone = core.findingPeers() diff --git a/workers/lib/server/handlers/minerLogs.handlers.js b/workers/lib/server/handlers/minerLogs.handlers.js index b7275e8..4a9491b 100644 --- a/workers/lib/server/handlers/minerLogs.handlers.js +++ b/workers/lib/server/handlers/minerLogs.handlers.js @@ -2,6 +2,23 @@ const { downloadLogFile } = require('./actions.handlers') +// Worker error code prefixes -> human-readable messages (raw code stays in `error`) +const WORKER_ERROR_MESSAGES = [ + ['ERR_DOWNLOAD_LOGS_PARSE_FAILED', 'The miner sent a malformed response header; the download was aborted'], + ['ERR_DOWNLOAD_LOGS_FAILED', 'The miner rejected the download-logs command'], + ['ERR_DOWNLOAD_LOGS_EMPTY', 'The miner reports an empty log archive — there is nothing to download'], + ['ERR_DOWNLOAD_LOGS_TIMEOUT', 'The miner did not deliver the log within the time limit'], + ['ERR_DOWNLOAD_LOGS_INCOMPLETE', 'The connection to the miner dropped before the full log was transferred'], + ['ERR_DOWNLOAD_LOGS_CONNECT_FAILED', 'Could not connect to the miner to download logs'], + ['ERR_LOG_CORE_MANAGER_NOT_READY', 'The rack worker is not ready to serve log transfers yet'], + ['ERR_LOG_NOT_AVAILABLE', 'The action completed but produced no downloadable log'] +] + +function describeWorkerError (errorCode) { + const match = WORKER_ERROR_MESSAGES.find(([prefix]) => errorCode.startsWith(prefix)) + return match ? match[1] : 'The download-logs action failed on the rack worker' +} + /** * Miner log download — three-step REST flow for large async file transfers: * @@ -109,10 +126,12 @@ async function getMinerLogDownloadStatus (ctx, req, reply) { } if (!meta) { + const error = firstError || 'ERR_LOG_NOT_AVAILABLE' return reply.code(200).send({ status: 'failed', jobId, - error: firstError || 'ERR_LOG_NOT_AVAILABLE' + error, + message: describeWorkerError(error) }) } From af9c55cbeb54dadaba028dab497950726c2f8cf1 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:58:32 +0530 Subject: [PATCH 58/63] feat: align site/status/live with header UI logic (#93) - source consumption by featureConfig (DCS site_main meter, transformer meters, or site power meter) and expose power alert/error - fix minerpool stats parsing and convert pool hashrate to MH/s - sum alerts across miner, powermeter and container entries - limit tail-log query to a 10 minute freshness window - drop derived miners.sleep, add unit metadata to response fields - extract aggregation helpers to site.utils.js --- tests/integration/api.test.js | 9 +- tests/unit/handlers/site.handlers.test.js | 317 +++++++++++++++++-- workers/lib/constants.js | 16 + workers/lib/server/handlers/site.handlers.js | 295 ++++------------- workers/lib/server/handlers/site.utils.js | 246 ++++++++++++++ 5 files changed, 635 insertions(+), 248 deletions(-) create mode 100644 workers/lib/server/handlers/site.utils.js diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js index bb1ee6e..a95eebd 100644 --- a/tests/integration/api.test.js +++ b/tests/integration/api.test.js @@ -901,20 +901,25 @@ test('Api', { timeout: 90000 }, async (main) => { t.ok(data.hashrate.value !== undefined, 'hashrate should have value') t.ok(data.hashrate.nominal !== undefined, 'hashrate should have nominal') t.ok(data.hashrate.utilization !== undefined, 'hashrate should have utilization') + t.is(data.hashrate.unit, 'MH/s', 'hashrate unit should be MH/s') // Verify power structure t.ok(data.power.value !== undefined, 'power should have value') t.ok(data.power.nominal !== undefined, 'power should have nominal') t.ok(data.power.utilization !== undefined, 'power should have utilization') + t.is(data.power.unit, 'W', 'power unit should be W') + t.ok(data.power.alert !== undefined, 'power should have alert') + t.ok(typeof data.power.error === 'boolean', 'power should have boolean error') // Verify efficiency structure t.ok(data.efficiency.value !== undefined, 'efficiency should have value') + t.is(data.efficiency.unit, 'W/TH/s', 'efficiency unit should be W/TH/s') // Verify miners structure t.ok(data.miners.online !== undefined, 'miners should have online') t.ok(data.miners.offline !== undefined, 'miners should have offline') t.ok(data.miners.error !== undefined, 'miners should have error') - t.ok(data.miners.sleep !== undefined, 'miners should have sleep') + t.is(data.miners.sleep, undefined, 'miners should not have a derived sleep field') t.ok(data.miners.total !== undefined, 'miners should have total') t.ok(data.miners.containerCapacity !== undefined, 'miners should have containerCapacity') @@ -926,6 +931,8 @@ test('Api', { timeout: 90000 }, async (main) => { // Verify pools structure t.ok(data.pools.totalHashrate !== undefined, 'pools should have totalHashrate') + t.ok(data.pools.totalHashrate.value !== undefined, 'pools totalHashrate should have value') + t.is(data.pools.totalHashrate.unit, 'MH/s', 'pools totalHashrate unit should be MH/s') t.ok(data.pools.activeWorkers !== undefined, 'pools should have activeWorkers') t.ok(data.pools.totalWorkers !== undefined, 'pools should have totalWorkers') diff --git a/tests/unit/handlers/site.handlers.test.js b/tests/unit/handlers/site.handlers.test.js index e91b9bd..eeb7b74 100644 --- a/tests/unit/handlers/site.handlers.test.js +++ b/tests/unit/handlers/site.handlers.test.js @@ -4,16 +4,18 @@ const test = require('brittle') const { getSiteLiveStatus, getSiteOverviewGroupsStats, getSiteEfficiency } = require('../../../workers/lib/server/handlers/site.handlers') const { withDataProxy } = require('../helpers/mockHelpers') -function createMockCtx (tailLogMultiResponse, extDataResponse, globalConfigResponse) { +function createMockCtx (tailLogMultiResponse, extDataResponse, globalConfigResponse, listThingsResponse = [], featureConfig = {}) { return withDataProxy({ conf: { - orks: [{ rpcPublicKey: 'key1' }] + orks: [{ rpcPublicKey: 'key1' }], + featureConfig }, net_r0: { jRequest: async (key, method) => { if (method === 'tailLogMulti') return tailLogMultiResponse if (method === 'getWrkExtData') return extDataResponse if (method === 'getGlobalConfig') return globalConfigResponse + if (method === 'listThings') return listThingsResponse return {} } } @@ -25,13 +27,14 @@ test('getSiteLiveStatus - returns composed response with correct structure', asy // Key 0: miner stats [{ hashrate_mhs_1m_sum_aggr: 601432498437, nominal_hashrate_mhs_sum_aggr: 741423000000, online_or_minor_error_miners_amount_aggr: 1850, not_mining_miners_amount_aggr: 23, offline_or_sleeping_miners_amount_aggr: 45, hashrate_mhs_1m_cnt_aggr: 1930, alerts_aggr: { critical: 8, high: 12, medium: 39 } }], // Key 1: powermeter stats - [{ site_power_w: 16701560 }], + [{}], // Key 2: container stats [{ container_nominal_miner_capacity_sum_aggr: 2000 }] ] + // Real minerpool ext-data shape: entries with a stats ARRAY (one item per pool), hashrate in H/s const extDataResponse = [ - { stats: { hashrate: 279670375560265, active_workers_count: 1823, worker_count: 1930 } } + { ts: '1769686500000', stats: [{ poolType: 'f2pool', hashrate: 279670375560265, active_workers_count: 1823, worker_count: 1930 }] } ] const globalConfigResponse = { @@ -39,7 +42,12 @@ test('getSiteLiveStatus - returns composed response with correct structure', asy nominalPowerAvailability_MW: 22.5 } - const ctx = createMockCtx(tailLogMultiResponse, extDataResponse, globalConfigResponse) + // Site power meter thing snapshot (consumption source, like the header UI) + const listThingsResponse = [ + { id: 'pm-site', tags: ['t-powermeter'], last: { snap: { stats: { power_w: 16701560 } }, alerts: [] } } + ] + + const ctx = createMockCtx(tailLogMultiResponse, extDataResponse, globalConfigResponse, listThingsResponse) const req = { query: {} } const result = await getSiteLiveStatus(ctx, req) @@ -48,13 +56,19 @@ test('getSiteLiveStatus - returns composed response with correct structure', asy t.is(result.hashrate.value, 601432498437, 'hashrate value should match') t.is(result.hashrate.nominal, 741423000000, 'hashrate nominal should match') t.ok(result.hashrate.utilization > 0, 'hashrate utilization should be > 0') + t.is(result.hashrate.unit, 'MH/s', 'hashrate unit should be MH/s') t.ok(result.power, 'should have power') - t.is(result.power.value, 16701560, 'power value should match') + t.is(result.power.value, 16701560, 'power value should come from site meter snapshot') t.is(result.power.nominal, 22500000, 'power nominal should be MW * 1000000') + t.is(result.power.unit, 'W', 'power unit should be W') + t.is(result.power.alert, '', 'power alert should be empty without device alerts') + t.is(result.power.error, false, 'power error should be false without device alerts') t.ok(result.efficiency, 'should have efficiency') - t.ok(result.efficiency.value > 0, 'efficiency value should be > 0') + // 16701560 W / 601432.498437 TH/s, unrounded like the header UI + t.is(result.efficiency.value, 16701560 / (601432498437 / 1000000), 'efficiency should be consumption over THs, unrounded') + t.is(result.efficiency.unit, 'W/TH/s', 'efficiency unit should be W/TH/s') t.ok(result.miners, 'should have miners') t.is(result.miners.online, 1850, 'miners online should match') @@ -70,7 +84,8 @@ test('getSiteLiveStatus - returns composed response with correct structure', asy t.is(result.alerts.total, 59, 'total alerts should be sum') t.ok(result.pools, 'should have pools') - t.is(result.pools.totalHashrate, 279670375560265, 'pool hashrate should match') + t.is(result.pools.totalHashrate.value, 279670375560265 / 1000000, 'pool hashrate should be converted H/s to MH/s') + t.is(result.pools.totalHashrate.unit, 'MH/s', 'pool hashrate unit should be MH/s') t.is(result.pools.activeWorkers, 1823, 'active workers should match') t.is(result.pools.totalWorkers, 1930, 'total workers should match') @@ -89,18 +104,22 @@ test('getSiteLiveStatus - handles empty ork responses', async (t) => { t.is(result.efficiency.value, 0, 'efficiency should be 0') t.is(result.miners.total, 0, 'miners total should be 0') t.is(result.alerts.total, 0, 'alerts total should be 0') - t.is(result.pools.totalHashrate, 0, 'pool hashrate should be 0') + t.is(result.pools.totalHashrate.value, 0, 'pool hashrate should be 0') t.pass() }) test('getSiteLiveStatus - computes utilization correctly', async (t) => { const tailLogMultiResponse = [ [{ hashrate_mhs_1m_sum_aggr: 500, nominal_hashrate_mhs_sum_aggr: 1000, online_or_minor_error_miners_amount_aggr: 0, not_mining_miners_amount_aggr: 0, offline_or_sleeping_miners_amount_aggr: 0, hashrate_mhs_1m_cnt_aggr: 0, alerts_aggr: {} }], - [{ site_power_w: 750 }], + [{}], [{ container_nominal_miner_capacity_sum_aggr: 0 }] ] - const ctx = createMockCtx(tailLogMultiResponse, [], { nominalPowerAvailability_MW: 0.001 }) + const listThingsResponse = [ + { id: 'pm-site', tags: ['t-powermeter'], last: { snap: { stats: { power_w: 750 } } } } + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], { nominalPowerAvailability_MW: 0.001 }, listThingsResponse) const req = { query: {} } const result = await getSiteLiveStatus(ctx, req) @@ -113,7 +132,7 @@ test('getSiteLiveStatus - computes utilization correctly', async (t) => { test('getSiteLiveStatus - handles zero nominal values gracefully', async (t) => { const tailLogMultiResponse = [ [{ hashrate_mhs_1m_sum_aggr: 100 }], - [{ site_power_w: 200 }], + [{}], [{}] ] @@ -127,16 +146,22 @@ test('getSiteLiveStatus - handles zero nominal values gracefully', async (t) => t.pass() }) -test('getSiteLiveStatus - aggregates multiple pool accounts', async (t) => { +test('getSiteLiveStatus - aggregates multiple pool accounts from stats arrays', async (t) => { const tailLogMultiResponse = [ [{ hashrate_mhs_1m_sum_aggr: 0 }], [{}], [{}] ] + // One entry with two pools plus one extra entry, all stats arrays (H/s) const extDataResponse = [ - { stats: { hashrate: 100, active_workers_count: 10, worker_count: 15 } }, - { stats: { hashrate: 200, active_workers_count: 20, worker_count: 25 } } + { + stats: [ + { poolType: 'f2pool', hashrate: 100000000, active_workers_count: 10, worker_count: 15 }, + { poolType: 'ocean', hashrate: 150000000, active_workers_count: 15, worker_count: 20 } + ] + }, + { stats: [{ poolType: 'luxor', hashrate: 50000000, active_workers_count: 5, worker_count: 5 }] } ] const ctx = createMockCtx(tailLogMultiResponse, extDataResponse, {}) @@ -144,13 +169,13 @@ test('getSiteLiveStatus - aggregates multiple pool accounts', async (t) => { const result = await getSiteLiveStatus(ctx, req) - t.is(result.pools.totalHashrate, 300, 'should sum pool hashrates') + t.is(result.pools.totalHashrate.value, 300, 'should sum pool hashrates and convert H/s to MH/s') t.is(result.pools.activeWorkers, 30, 'should sum active workers') t.is(result.pools.totalWorkers, 40, 'should sum total workers') t.pass() }) -test('getSiteLiveStatus - computes sleep miners from remainder', async (t) => { +test('getSiteLiveStatus - does not expose a derived sleep field', async (t) => { const tailLogMultiResponse = [ [{ hashrate_mhs_1m_sum_aggr: 0, @@ -170,12 +195,266 @@ test('getSiteLiveStatus - computes sleep miners from remainder', async (t) => { t.is(result.miners.online, 80, 'online should match') t.is(result.miners.error, 5, 'error should match') - t.is(result.miners.offline, 10, 'offline should match') - t.is(result.miners.sleep, 5, 'sleep should be total - online - error - offline') + t.is(result.miners.offline, 10, 'offline should match (includes sleeping)') + t.is(result.miners.sleep, undefined, 'sleep should not be present (UI has no sleep concept)') t.is(result.miners.total, 100, 'total should match') t.pass() }) +test('getSiteLiveStatus - sums alerts across miner, powermeter and container entries', async (t) => { + const tailLogMultiResponse = [ + [{ alerts_aggr: { critical: 1, high: 2, medium: 3 } }], + [{ alerts_aggr: { critical: 4, high: 5, medium: 6 } }], + [{ alerts_aggr: { critical: 7, high: 8, medium: 9 } }] + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.alerts.critical, 12, 'critical should sum across all three entry types') + t.is(result.alerts.high, 15, 'high should sum across all three entry types') + t.is(result.alerts.medium, 18, 'medium should sum across all three entry types') + t.is(result.alerts.total, 45, 'total should be the overall sum') + t.pass() +}) + +test('getSiteLiveStatus - queries tail-log with a 10 minute freshness window', async (t) => { + let tailLogParams = null + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }], featureConfig: {} }, + net_r0: { + jRequest: async (key, method, params) => { + if (method === 'tailLogMulti') { + tailLogParams = params + return [] + } + return [] + } + } + }) + + const before = Date.now() + await getSiteLiveStatus(ctx, { query: {} }) + const after = Date.now() + + t.ok(tailLogParams, 'should call tailLogMulti') + t.ok(typeof tailLogParams.start === 'number', 'should pass start to tailLogMulti') + t.ok(tailLogParams.start >= before - 10 * 60 * 1000, 'start should be no earlier than now - 10min') + t.ok(tailLogParams.start <= after - 10 * 60 * 1000, 'start should be now - 10min') + t.pass() +}) + +test('getSiteLiveStatus - falls back to site container thing when no power meter is tagged', async (t) => { + const tailLogMultiResponse = [[{}], [{}], [{}]] + + const listThingsResponse = [ + { id: 'other', tags: ['t-sensor-temp'], last: { snap: { stats: { power_w: 999 } } } }, + { id: 'container-site', tags: ['t-container'], last: { snap: { stats: { power_w: 1234 } } } } + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}, listThingsResponse) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.power.value, 1234, 'should fall back to the first t-container thing') + t.pass() +}) + +test('getSiteLiveStatus - exposes site meter alerts as power alert/error', async (t) => { + const tailLogMultiResponse = [[{}], [{}], [{}]] + + const listThingsResponse = [ + { + id: 'pm-site', + tags: ['t-powermeter'], + last: { + snap: { stats: { power_w: 500 } }, + alerts: [ + { severity: 'high', createdAt: 1769686500000, name: 'power-failure', description: 'Phase loss', message: 'L2 down' } + ] + } + } + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}, listThingsResponse) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.power.value, 500, 'power value should come from the site meter') + t.ok(result.power.alert.includes('(high)'), 'alert string should include severity') + t.ok(result.power.alert.includes('power-failure'), 'alert string should include alert name') + t.is(result.power.error, true, 'power error should be true when the meter has alerts') + t.pass() +}) + +test('getSiteLiveStatus - totalSystemConsumptionHeader feature returns zero consumption', async (t) => { + const tailLogMultiResponse = [ + [{ hashrate_mhs_1m_sum_aggr: 1000000 }], + [{}], + [{}] + ] + + // listThings would return a site meter, but the feature branch must ignore it + const listThingsResponse = [ + { id: 'pm-site', tags: ['t-powermeter'], last: { snap: { stats: { power_w: 16701560 } } } } + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}, listThingsResponse, { totalSystemConsumptionHeader: true }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.power.value, 0, 'power should be 0 for the system consumption header feature') + t.is(result.power.alert, '', 'no alert for the system consumption branch') + t.is(result.efficiency.value, 0, 'efficiency should be 0 when consumption is 0') + t.pass() +}) + +test('getSiteLiveStatus - totalTransformerConsumptionHeader sums transformer power meters', async (t) => { + const tailLogMultiResponse = [ + [{ hashrate_mhs_1m_sum_aggr: 2000000 }], + [{}], + [{}] + ] + + const listThingsResponse = [ + { id: 'pm-tr1', type: 'powermeter-x', info: { pos: 'tr1' }, last: { snap: { stats: { power_w: 1000 } } } }, + { id: 'pm-tr2', type: 'powermeter-x', info: { pos: 'tr2' }, last: { snap: { stats: { power_w: 500 } } } }, + // Not a transformer power meter (pos does not match ^tr\d+$) - must be skipped + { id: 'pm-other', type: 'powermeter-x', info: { pos: 'site' }, last: { snap: { stats: { power_w: 9999 } } } }, + // Not a power meter type - must be skipped + { id: 'sensor-tr3', type: 'sensor-temp-x', info: { pos: 'tr3' }, last: { snap: { stats: { power_w: 7777 } } } } + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}, listThingsResponse, { totalTransformerConsumptionHeader: true }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.power.value, 1500, 'power should be the sum of transformer power meters only') + // 1500 W / 2 TH/s = 750 W/TH/s + t.is(result.efficiency.value, 750, 'efficiency should use the transformer consumption') + t.pass() +}) + +test('getSiteLiveStatus - central DCS reads consumption from the site main meter', async (t) => { + const tailLogMultiResponse = [ + [{ hashrate_mhs_1m_sum_aggr: 2000000 }], + [{}], + [{}] + ] + + // DCS thing with site_main meter reporting kW, like the energy layout view + const listThingsResponse = [ + { + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-1', role: 'rack', power: { value: 4000, unit: 'kW' } }, + { equipment: 'PM-SITE', role: 'site_main', power: { value: 10.5, unit: 'kW' } } + ] + } + } + } + } + } + } + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}, listThingsResponse, { centralDCSSetup: { enabled: true, tag: 't-dcs' } }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.power.value, 10500, 'power should be the site_main meter reading converted kW to W') + t.is(result.power.alert, '', 'no device alert in the DCS branch') + // 10500 W / 2 TH/s = 5250 W/TH/s + t.is(result.efficiency.value, 5250, 'efficiency should use the DCS site meter consumption') + t.pass() +}) + +test('getSiteLiveStatus - central DCS takes precedence over other consumption branches', async (t) => { + const tailLogMultiResponse = [[{}], [{}], [{}]] + + const listThingsResponse = [ + { + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', power: { value: 2, unit: 'kW' } } + ] + } + } + } + } + } + }, + // Transformer meter that the transformer branch would otherwise sum + { id: 'pm-tr1', type: 'powermeter-x', info: { pos: 'tr1' }, last: { snap: { stats: { power_w: 9999 } } } } + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}, listThingsResponse, { + centralDCSSetup: { enabled: true, tag: 't-dcs' }, + totalTransformerConsumptionHeader: true + }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.power.value, 2000, 'DCS site meter should win over the transformer branch') + t.pass() +}) + +test('getSiteLiveStatus - central DCS without site_main meter yields zero consumption', async (t) => { + const tailLogMultiResponse = [[{}], [{}], [{}]] + + const listThingsResponse = [ + { + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-1', role: 'rack', power: { value: 4000, unit: 'kW' } } + ] + } + } + } + } + } + } + ] + + const ctx = createMockCtx(tailLogMultiResponse, [], {}, listThingsResponse, { centralDCSSetup: { enabled: true, tag: 't-dcs' } }) + const req = { query: {} } + + const result = await getSiteLiveStatus(ctx, req) + + t.is(result.power.value, 0, 'power should be 0 when no site_main meter exists') + t.is(result.power.error, false, 'no error flag without a site meter alert source') + t.pass() +}) + test('getSiteLiveStatus - uses nominal_hashrate from taillog over globalConfig', async (t) => { const tailLogMultiResponse = [ [{ hashrate_mhs_1m_sum_aggr: 500, nominal_hashrate_mhs_sum_aggr: 1000 }], diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 9e40d3d..b4d0fb4 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -510,6 +510,20 @@ const SITE_OVERVIEW_AGGR_FIELDS = { hashrate_mhs_5m_active_container_group_cnt: 1 } +const SITE_STATUS_LIVE_AGGR_FIELDS = { + hashrate_mhs_1m_sum_aggr: 1, + nominal_hashrate_mhs_sum_aggr: 1, + alerts_aggr: 1, + online_or_minor_error_miners_amount_aggr: 1, + not_mining_miners_amount_aggr: 1, + offline_or_sleeping_miners_amount_aggr: 1, + hashrate_mhs_1m_cnt_aggr: 1, + container_nominal_miner_capacity_sum_aggr: 1 +} + +// Ignore tail-log entries older than this (header UI uses start = now - 10min) +const SITE_STATUS_LIVE_WINDOW_MS = 10 * 60 * 1000 + // DCS power meter field projections for site overview const DCS_POWER_METER_FIELDS = { 'last.snap.stats.dcs_specific.equipment.power_meters': 1, @@ -760,6 +774,8 @@ module.exports = { COOLING_SYSTEM_PROJECTIONS, ENERGY_SYSTEM_PROJECTIONS, SITE_OVERVIEW_AGGR_FIELDS, + SITE_STATUS_LIVE_AGGR_FIELDS, + SITE_STATUS_LIVE_WINDOW_MS, DCS_POWER_METER_FIELDS, DCS_EFFICIENCY_FIELDS, EXPLORER_RACK_AGGR_FIELDS, diff --git a/workers/lib/server/handlers/site.handlers.js b/workers/lib/server/handlers/site.handlers.js index 20ea677..b8d98f4 100644 --- a/workers/lib/server/handlers/site.handlers.js +++ b/workers/lib/server/handlers/site.handlers.js @@ -14,258 +14,95 @@ const { WORKER_TYPES, WORKER_TAGS, SITE_OVERVIEW_AGGR_FIELDS, + SITE_STATUS_LIVE_AGGR_FIELDS, + SITE_STATUS_LIVE_WINDOW_MS, DCS_POWER_METER_FIELDS, DCS_EFFICIENCY_FIELDS } = require('../../constants') const { isCentralDCSEnabled, getDCSTag, - extractDcsThing + extractDcsThing, + fetchDcsThing } = require('../../dcs.utils') +const { + sumTransformerPowerW, + extractSiteMeterThing, + formatDeviceAlerts, + composeSiteStatus +} = require('./site.utils') + +// DCS site main meter (role: site_main), same source as the energy layout view; reported in kW +async function getDCSSiteConsumption (ctx) { + const dcsThing = await fetchDcsThing(ctx, { + id: 1, + code: 1, + type: 1, + tags: 1, + ...DCS_POWER_METER_FIELDS + }) -/** - * Aggregates miner stats from tailLogMulti results across all orks. - * Key index 0 = miner data (stat-rtd, type: miner) - * - * @param {Array} tailLogResults - Array of ork responses from tailLogMulti - * @returns {Object} Aggregated miner stats - */ -function aggregateMinerStats (tailLogResults) { - const stats = { - hashrate: 0, - nominalHashrate: 0, - online: 0, - error: 0, - offline: 0, - total: 0, - alerts: { critical: 0, high: 0, medium: 0 } - } - - for (const orkResult of tailLogResults) { - const entry = extractKeyEntry(orkResult, 0) - if (!entry) continue - - stats.hashrate += entry.hashrate_mhs_1m_sum_aggr || 0 - stats.nominalHashrate += entry.nominal_hashrate_mhs_sum_aggr || 0 - stats.online += entry.online_or_minor_error_miners_amount_aggr || 0 - stats.error += entry.not_mining_miners_amount_aggr || 0 - stats.offline += entry.offline_or_sleeping_miners_amount_aggr || 0 - stats.total += entry.hashrate_mhs_1m_cnt_aggr || 0 - - const alerts = entry.alerts_aggr - if (alerts && typeof alerts === 'object') { - stats.alerts.critical += alerts.critical || 0 - stats.alerts.high += alerts.high || 0 - stats.alerts.medium += alerts.medium || 0 - } - } - - return stats -} - -/** - * Extracts site power from powermeter tail-log results across all orks. - * Key index 1 = powermeter data (stat-rtd, type: powermeter) - * - * @param {Array} tailLogResults - Array of ork responses from tailLogMulti - * @returns {number} Total site power in Watts - */ -function aggregatePowerStats (tailLogResults) { - let sitePower = 0 - - for (const orkResult of tailLogResults) { - const entry = extractKeyEntry(orkResult, 1) - if (!entry) continue - sitePower += entry.site_power_w || 0 - } + const powerMeters = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.power_meters || [] + const siteMeter = powerMeters.find(pm => pm.role === 'site_main') + const siteMeterKw = siteMeter?.power?.value || 0 - return sitePower + return { powerW: siteMeterKw * 1000, alert: '' } } -/** - * Extracts container capacity from container tail-log results across all orks. - * Key index 2 = container data (stat-rtd, type: container) - * - * @param {Array} tailLogResults - Array of ork responses from tailLogMulti - * @returns {number} Total container nominal miner capacity - */ -function aggregateContainerCapacity (tailLogResults) { - let capacity = 0 +// Resolves consumption by featureConfig, mirroring the header UI: +// central DCS > totalSystemConsumptionHeader (0) > totalTransformerConsumptionHeader > site meter +async function getSiteConsumption (ctx) { + const featureConfig = ctx.conf.featureConfig || {} - for (const orkResult of tailLogResults) { - const entry = extractKeyEntry(orkResult, 2) - if (!entry) continue - capacity += entry.container_nominal_miner_capacity_sum_aggr || 0 + if (isCentralDCSEnabled(ctx)) { + return getDCSSiteConsumption(ctx) } - return capacity -} - -/** - * Aggregates pool stats from ext-data minerpool results across all orks. - * - * @param {Array} poolDataResults - Array of ork responses from getWrkExtData - * @returns {Object} Aggregated pool stats - */ -function aggregatePoolStats (poolDataResults) { - const stats = { - totalHashrate: 0, - activeWorkers: 0, - totalWorkers: 0 + if (featureConfig.totalSystemConsumptionHeader) { + return { powerW: 0, alert: '' } } - for (const orkResult of poolDataResults) { - if (!Array.isArray(orkResult)) continue - for (const pool of orkResult) { - if (!pool || !pool.stats) continue - stats.totalHashrate += pool.stats.hashrate || 0 - stats.activeWorkers += pool.stats.active_workers_count || 0 - stats.totalWorkers += pool.stats.worker_count || 0 - } - } - - return stats -} - -/** - * Extracts nominal values from global config results. - * Merges across orks (typically only 1 ork has global config). - * - * @param {Array} globalConfigResults - Array of ork responses from getGlobalConfig - * @returns {Object} Nominal configuration values - */ -function extractGlobalConfig (globalConfigResults) { - const config = { - nominalHashrate: 0, - nominalPowerAvailability_MW: 0 - } - - for (const orkResult of globalConfigResults) { - if (!orkResult || typeof orkResult !== 'object') continue - if (orkResult.nominalHashrate) { config.nominalHashrate = orkResult.nominalHashrate } - if (orkResult.nominalPowerAvailability_MW) { - config.nominalPowerAvailability_MW = - orkResult.nominalPowerAvailability_MW - } + if (featureConfig.totalTransformerConsumptionHeader) { + const results = await ctx.dataProxy.requestDataMap('listThings', { + query: { + $and: [ + { tags: { $in: [WORKER_TAGS.POWERMETER] } }, + { 'info.pos': { $regex: 'tr' } } + ] + }, + status: 1, + limit: 200, + sort: { 'info.pos': 1 }, + fields: { 'last.snap.stats.power_w': 1, info: 1, type: 1 } + }) + return { powerW: sumTransformerPowerW(results), alert: '' } } - return config -} - -/** - * Computes utilization percentage safely. - * - * @param {number} value - Current value - * @param {number} nominal - Nominal/max value - * @returns {number} Utilization percentage rounded to 1 decimal, or 0 if nominal is 0 - */ -function computeUtilization (value, nominal) { - if (!nominal || nominal === 0) return 0 - return Math.round((value / nominal) * 1000) / 10 -} - -/** - * Composes the site live status response from all data sources. - * - * @param {Array} tailLogResults - tailLogMulti RPC results - * @param {Array} poolDataResults - getWrkExtData (minerpool) RPC results - * @param {Array} globalConfigResults - getGlobalConfig RPC results - * @returns {Object} Composed site status response - */ -function composeSiteStatus ( - tailLogResults, - poolDataResults, - globalConfigResults -) { - const minerStats = aggregateMinerStats(tailLogResults) - const sitePower = aggregatePowerStats(tailLogResults) - const containerCapacity = aggregateContainerCapacity(tailLogResults) - const poolStats = aggregatePoolStats(poolDataResults) - const globalConfig = extractGlobalConfig(globalConfigResults) - - const nominalPowerW = globalConfig.nominalPowerAvailability_MW * 1000000 - const hashrateNominal = - minerStats.nominalHashrate || globalConfig.nominalHashrate || 0 - - const hashrateValue = minerStats.hashrate - const hashrateThs = hashrateValue / 1000000 - const efficiencyWPerTh = - hashrateThs > 0 ? Math.round((sitePower / hashrateThs) * 10) / 10 : 0 - - const sleep = Math.max( - 0, - minerStats.total - - minerStats.online - - minerStats.error - - minerStats.offline - ) - const alertTotal = - minerStats.alerts.critical + - minerStats.alerts.high + - minerStats.alerts.medium - + const results = await ctx.dataProxy.requestDataMap('listThings', { + query: { 'info.pos': { $eq: 'site' } }, + status: 1, + limit: 100, + fields: { id: 1, 'last.snap.stats.power_w': 1, 'last.alerts': 1, tags: 1 } + }) + const siteMeter = extractSiteMeterThing(results) return { - hashrate: { - value: hashrateValue, - nominal: hashrateNominal, - utilization: computeUtilization(hashrateValue, hashrateNominal) - }, - power: { - value: sitePower, - nominal: nominalPowerW, - utilization: computeUtilization(sitePower, nominalPowerW) - }, - efficiency: { - value: efficiencyWPerTh - }, - miners: { - online: minerStats.online, - offline: minerStats.offline, - error: minerStats.error, - sleep, - total: minerStats.total, - containerCapacity - }, - alerts: { - critical: minerStats.alerts.critical, - high: minerStats.alerts.high, - medium: minerStats.alerts.medium, - total: alertTotal - }, - pools: poolStats, - ts: Date.now() + powerW: siteMeter?.last?.snap?.stats?.power_w || 0, + alert: formatDeviceAlerts(siteMeter?.last?.alerts) } } -/** - * GET /auth/site/status/live - * - * Returns a composite site status snapshot by aggregating: - * - tailLogMulti (miner hashrate/counts/alerts, powermeter power, container capacity) - * - getWrkExtData (pool hashrate, worker counts) - * - getGlobalConfig (nominal hashrate, nominal power availability) - * - * Replaces 5 separate frontend API calls with a single server-side composition. - */ +// GET /auth/site/status/live — composite snapshot (tailLog + consumption + pools + globalConfig), +// replacing 5 separate frontend calls async function getSiteLiveStatus (ctx, req) { const tailLogPayload = { keys: [ - { key: 'stat-rtd', type: 'miner', tag: 't-miner' }, - { key: 'stat-rtd', type: 'powermeter', tag: 't-powermeter' }, - { key: 'stat-rtd', type: 'container', tag: 't-container' } + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER }, + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.POWERMETER, tag: WORKER_TAGS.POWERMETER }, + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.CONTAINER, tag: WORKER_TAGS.CONTAINER } ], limit: 1, - aggrFields: { - hashrate_mhs_1m_sum_aggr: 1, - nominal_hashrate_mhs_sum_aggr: 1, - alerts_aggr: 1, - online_or_minor_error_miners_amount_aggr: 1, - not_mining_miners_amount_aggr: 1, - offline_or_sleeping_miners_amount_aggr: 1, - hashrate_mhs_1m_cnt_aggr: 1, - site_power_w: 1, - container_nominal_miner_capacity_sum_aggr: 1 - } + start: Date.now() - SITE_STATUS_LIVE_WINDOW_MS, + aggrFields: SITE_STATUS_LIVE_AGGR_FIELDS } const poolPayload = { @@ -277,17 +114,19 @@ async function getSiteLiveStatus (ctx, req) { fields: { nominalHashrate: 1, nominalPowerAvailability_MW: 1 } } - const [tailLogResults, poolDataResults, globalConfigResults] = + const [tailLogResults, poolDataResults, globalConfigResults, consumption] = await Promise.all([ ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload), ctx.dataProxy.requestDataMap('getWrkExtData', poolPayload), - ctx.dataProxy.requestDataMap('getGlobalConfig', globalConfigPayload) + ctx.dataProxy.requestDataMap('getGlobalConfig', globalConfigPayload), + getSiteConsumption(ctx) ]) return composeSiteStatus( tailLogResults, poolDataResults, - globalConfigResults + globalConfigResults, + consumption ) } diff --git a/workers/lib/server/handlers/site.utils.js b/workers/lib/server/handlers/site.utils.js new file mode 100644 index 0000000..a4ad6e6 --- /dev/null +++ b/workers/lib/server/handlers/site.utils.js @@ -0,0 +1,246 @@ +'use strict' + +const { extractKeyEntry, mhsToThs } = require('../../metrics.utils') +const { WORKER_TAGS } = require('../../constants') + +function hsToMhs (hs) { + return hs / 1000000 +} + +// tailLogMulti key index 0 = miner +function aggregateMinerStats (tailLogResults) { + const stats = { + hashrate: 0, + nominalHashrate: 0, + online: 0, + error: 0, + offline: 0, + total: 0 + } + + for (const orkResult of tailLogResults) { + const entry = extractKeyEntry(orkResult, 0) + if (!entry) continue + + stats.hashrate += entry.hashrate_mhs_1m_sum_aggr || 0 + stats.nominalHashrate += entry.nominal_hashrate_mhs_sum_aggr || 0 + stats.online += entry.online_or_minor_error_miners_amount_aggr || 0 + stats.error += entry.not_mining_miners_amount_aggr || 0 + stats.offline += entry.offline_or_sleeping_miners_amount_aggr || 0 + stats.total += entry.hashrate_mhs_1m_cnt_aggr || 0 + } + + return stats +} + +// Sums alerts over miner/powermeter/container entries (UI getTotalAlerts parity) +function aggregateAlertStats (tailLogResults) { + const alerts = { critical: 0, high: 0, medium: 0 } + + for (const orkResult of tailLogResults) { + for (let keyIndex = 0; keyIndex <= 2; keyIndex++) { + const entry = extractKeyEntry(orkResult, keyIndex) + const entryAlerts = entry && entry.alerts_aggr + if (!entryAlerts || typeof entryAlerts !== 'object') continue + alerts.critical += entryAlerts.critical || 0 + alerts.high += entryAlerts.high || 0 + alerts.medium += entryAlerts.medium || 0 + } + } + + return alerts +} + +// tailLogMulti key index 2 = container +function aggregateContainerCapacity (tailLogResults) { + let capacity = 0 + + for (const orkResult of tailLogResults) { + const entry = extractKeyEntry(orkResult, 2) + if (!entry) continue + capacity += entry.container_nominal_miner_capacity_sum_aggr || 0 + } + + return capacity +} + +// Each ork entry is { ts, stats: [...] }, one object per pool, hashrate in H/s +function aggregatePoolStats (poolDataResults) { + const stats = { + totalHashrateHs: 0, + activeWorkers: 0, + totalWorkers: 0 + } + + for (const orkResult of poolDataResults) { + if (!Array.isArray(orkResult)) continue + for (const entry of orkResult) { + if (!entry || !entry.stats) continue + const pools = Array.isArray(entry.stats) ? entry.stats : [entry.stats] + for (const pool of pools) { + if (!pool) continue + stats.totalHashrateHs += pool.hashrate || 0 + stats.activeWorkers += pool.active_workers_count || 0 + stats.totalWorkers += pool.worker_count || 0 + } + } + } + + return stats +} + +function extractGlobalConfig (globalConfigResults) { + const config = { + nominalHashrate: 0, + nominalPowerAvailability_MW: 0 + } + + for (const orkResult of globalConfigResults) { + if (!orkResult || typeof orkResult !== 'object') continue + if (orkResult.nominalHashrate) { config.nominalHashrate = orkResult.nominalHashrate } + if (orkResult.nominalPowerAvailability_MW) { + config.nominalPowerAvailability_MW = + orkResult.nominalPowerAvailability_MW + } + } + + return config +} + +function computeUtilization (value, nominal) { + if (!nominal || nominal === 0) return 0 + return Math.round((value / nominal) * 1000) / 10 +} + +function getFirstOrkThings (listThingsResults) { + if (!Array.isArray(listThingsResults)) return [] + const first = listThingsResults[0] + return Array.isArray(first) ? first : [] +} + +function isTransformerPowermeter (type, pos) { + return typeof type === 'string' && + type.startsWith('powermeter-') && + /^tr\d+$/.test(pos || '') +} + +// Sums power over transformer power meters (UI useTotalTransformerPMConsumption parity) +function sumTransformerPowerW (listThingsResults) { + let totalW = 0 + + for (const device of getFirstOrkThings(listThingsResults)) { + if (!device) continue + const pos = device.info && device.info.pos + if (!isTransformerPowermeter(device.type, pos)) continue + const powerW = device.last?.snap?.stats?.power_w + if (typeof powerW !== 'number' || !powerW) continue + totalW += powerW + } + + return totalW +} + +// First t-powermeter thing, falling back to t-container +function extractSiteMeterThing (listThingsResults) { + const things = getFirstOrkThings(listThingsResults) + const byTag = (tag) => things.filter( + (thing) => Array.isArray(thing?.tags) && thing.tags.includes(tag) + ) + + const powerMeters = byTag(WORKER_TAGS.POWERMETER) + const candidates = powerMeters.length > 0 ? powerMeters : byTag(WORKER_TAGS.CONTAINER) + return candidates[0] || null +} + +// Mirrors UI getAlertsString; empty when no alerts +function formatDeviceAlerts (alerts) { + if (!Array.isArray(alerts) || alerts.length === 0) return '' + return alerts.map((alert) => + `(${alert.severity}) ${new Date(alert.createdAt).toISOString()} : ${alert.name} Description: ${alert.description} ${alert.message ? alert.message : ''}` + ).join(',\n\n') +} + +function composeSiteStatus ( + tailLogResults, + poolDataResults, + globalConfigResults, + consumption +) { + const minerStats = aggregateMinerStats(tailLogResults) + const alertStats = aggregateAlertStats(tailLogResults) + const containerCapacity = aggregateContainerCapacity(tailLogResults) + const poolStats = aggregatePoolStats(poolDataResults) + const globalConfig = extractGlobalConfig(globalConfigResults) + + const nominalPowerW = globalConfig.nominalPowerAvailability_MW * 1000000 + const hashrateNominal = + minerStats.nominalHashrate || globalConfig.nominalHashrate || 0 + + const hashrateValue = minerStats.hashrate + const consumptionW = consumption.powerW + // UI getEfficiencyStat: W / TH/s, unrounded, 0 if either input is missing + const efficiencyWPerTh = (consumptionW && hashrateValue) + ? consumptionW / mhsToThs(hashrateValue) + : 0 + + const alertTotal = + alertStats.critical + + alertStats.high + + alertStats.medium + + return { + hashrate: { + value: hashrateValue, + nominal: hashrateNominal, + utilization: computeUtilization(hashrateValue, hashrateNominal), + unit: 'MH/s' + }, + power: { + value: consumptionW, + nominal: nominalPowerW, + utilization: computeUtilization(consumptionW, nominalPowerW), + unit: 'W', + alert: consumption.alert, + error: Boolean(consumption.alert) + }, + efficiency: { + value: efficiencyWPerTh, + unit: 'W/TH/s' + }, + miners: { + online: minerStats.online, + offline: minerStats.offline, + error: minerStats.error, + total: minerStats.total, + containerCapacity + }, + alerts: { + critical: alertStats.critical, + high: alertStats.high, + medium: alertStats.medium, + total: alertTotal + }, + pools: { + totalHashrate: { value: hsToMhs(poolStats.totalHashrateHs), unit: 'MH/s' }, + activeWorkers: poolStats.activeWorkers, + totalWorkers: poolStats.totalWorkers + }, + ts: Date.now() + } +} + +module.exports = { + hsToMhs, + aggregateMinerStats, + aggregateAlertStats, + aggregateContainerCapacity, + aggregatePoolStats, + extractGlobalConfig, + computeUtilization, + getFirstOrkThings, + isTransformerPowermeter, + sumTransformerPowerW, + extractSiteMeterThing, + formatDeviceAlerts, + composeSiteStatus +} From 0da5886b53a0d21f20eec9c1438be540b527ad37 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:03:33 +0530 Subject: [PATCH 59/63] feat: GET /site/power-consumption history API (#96) --- .../power.consumption.handlers.test.js | 489 ++++++++++++++++++ .../routes/power.consumption.routes.test.js | 62 +++ workers/lib/constants.js | 1 + workers/lib/dcs.utils.js | 8 + .../handlers/power.consumption.handlers.js | 203 ++++++++ workers/lib/server/index.js | 2 + .../server/routes/power.consumption.routes.js | 43 ++ 7 files changed, 808 insertions(+) create mode 100644 tests/unit/handlers/power.consumption.handlers.test.js create mode 100644 tests/unit/routes/power.consumption.routes.test.js create mode 100644 workers/lib/server/handlers/power.consumption.handlers.js create mode 100644 workers/lib/server/routes/power.consumption.routes.js diff --git a/tests/unit/handlers/power.consumption.handlers.test.js b/tests/unit/handlers/power.consumption.handlers.test.js new file mode 100644 index 0000000..27137e6 --- /dev/null +++ b/tests/unit/handlers/power.consumption.handlers.test.js @@ -0,0 +1,489 @@ +'use strict' + +const test = require('brittle') +const { + getSitePowerConsumption, + getChartType, + removeContainerPrefix, + getPowerBEAttribute, + getByPath, + buildConsumptionLog, + computeConsumptionSummary +} = require('../../../workers/lib/server/handlers/power.consumption.handlers') +const { withDataProxy } = require('../helpers/mockHelpers') + +// Build a mock ctx whose RPC layer branches by method. `tailLog` returns the +// given points array (single site); `listThings` returns the given things array. +const buildCtx = ({ tailLogPoints = [], listThings = [], onTailLog } = {}) => { + return withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLog') { + if (onTailLog) onTailLog(payload) + return tailLogPoints + } + if (method === 'listThings') return listThings + return [] + } + } + }) +} + +const RANGE = { start: 1700000000000, end: 1700100000000 } + +// ==================== Pure helper tests ==================== + +test('getChartType - resolves type from tag', (t) => { + t.is(getChartType('t-miner'), 'miner', 'miner tag -> miner') + t.is(getChartType('t-powermeter'), 'powermeter', 'powermeter tag -> powermeter (t- stripped)') + t.is(getChartType('container-9a'), 'container', 'container tag -> container') + t.pass() +}) + +test('removeContainerPrefix - strips leading container- only', (t) => { + t.is(removeContainerPrefix('container-9a'), '9a', 'strips container- prefix') + t.is(removeContainerPrefix('t-powermeter'), 't-powermeter', 'leaves non-matching unchanged') + t.pass() +}) + +test('getPowerBEAttribute - mirrors the UI conditional', (t) => { + t.is(getPowerBEAttribute('t-miner'), 'power_w_sum_aggr', 'miner -> power_w_sum_aggr') + t.is(getPowerBEAttribute('t-powermeter'), 'site_power_w', 'powermeter -> site_power_w') + t.is(getPowerBEAttribute('container-9a'), 'container_power_w_aggr.9a', 'container -> nested attr') + t.is(getPowerBEAttribute('t-miner', true), 'transformer_power_w', 'transformer flag -> transformer_power_w') + t.pass() +}) + +test('getByPath - reads dotted paths', (t) => { + t.is(getByPath({ a: { b: 5 } }, 'a.b'), 5, 'nested value') + t.is(getByPath({ container_power_w_aggr: { '9a': 42 } }, 'container_power_w_aggr.9a'), 42, 'aggr path') + t.is(getByPath(null, 'a.b'), undefined, 'null object -> undefined') + t.is(getByPath({ a: 1 }, 'a.b'), undefined, 'missing nested -> undefined') + t.pass() +}) + +test('computeConsumptionSummary - raw min/max/avg with static unit', (t) => { + const points = [ + { ts: 1, power_w_sum_aggr: 1000 }, + { ts: 2, power_w_sum_aggr: 3000 }, + { ts: 3, power_w_sum_aggr: 2000 } + ] + const summary = computeConsumptionSummary(points, 'power_w_sum_aggr') + t.is(summary.min.value, 1000, 'min over raw values') + t.is(summary.max.value, 3000, 'max over raw values') + t.is(summary.avg.value, 2000, 'avg = total / count') + t.is(summary.min.unit, 'W', 'static unit on min') + t.is(summary.avg.unit, 'W', 'static unit on avg') + t.pass() +}) + +test('computeConsumptionSummary - empty range yields null min/max/avg', (t) => { + const summary = computeConsumptionSummary([], 'power_w_sum_aggr') + t.is(summary.min.value, null, 'min null on empty') + t.is(summary.max.value, null, 'max null on empty') + t.is(summary.avg.value, null, 'avg null on empty') + t.is(summary.avg.unit, 'W', 'unit still present on empty') + t.pass() +}) + +test('computeConsumptionSummary - missing attribute counts as 0', (t) => { + const points = [ + { ts: 1, power_w_sum_aggr: 4000 }, + { ts: 2 }, // missing attribute -> 0 + { ts: 3, power_w_sum_aggr: 2000 } + ] + const summary = computeConsumptionSummary(points, 'power_w_sum_aggr') + t.is(summary.min.value, 0, 'missing point pulls min to 0') + t.is(summary.max.value, 4000, 'max unaffected') + t.is(summary.avg.value, 2000, 'avg = (4000+0+2000)/3') + t.pass() +}) + +test('buildConsumptionLog - maps points to ts/value/unit', (t) => { + const points = [{ ts: 1, power_w_sum_aggr: 1000 }, { ts: 2 }] + const log = buildConsumptionLog(points, 'power_w_sum_aggr') + t.alike(log, [ + { ts: 1, value: 1000, unit: 'W' }, + { ts: 2, value: 0, unit: 'W' } + ], 'missing value falls back to 0') + t.pass() +}) + +// ==================== Handler happy paths ==================== + +test('getSitePowerConsumption - miner happy path (power_w_sum_aggr)', async (t) => { + let captured = null + const ctx = buildCtx({ + tailLogPoints: [ + { ts: 1, power_w_sum_aggr: 1000 }, + { ts: 2, power_w_sum_aggr: 3000 }, + { ts: 3, power_w_sum_aggr: 2000 } + ], + onTailLog: (payload) => { captured = payload } + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-miner', interval: '1m' } + }) + + // request shape mirrors the UI's tail-log fetch + t.is(captured.key, 'stat-1m', 'builds stat- key') + t.is(captured.type, 'miner', 'derives type from tag') + t.is(captured.tag, 't-miner', 'passes tag through') + t.is(captured.aggrFields.power_w_sum_aggr, 1, 'requests power_w_sum_aggr') + t.is(captured.aggrFields.site_power_w, 1, 'sends all four aggr fields like the UI') + t.is(captured.fields['last.snap.stats.power_w'], 1, 'requests power_w field') + + t.is(result.log.length, 3, 'one log point per entry') + t.alike(result.log[0], { ts: 1, value: 1000, unit: 'W' }, 'first point mapped') + t.is(result.summary.min.value, 1000, 'min') + t.is(result.summary.max.value, 3000, 'max') + t.is(result.summary.avg.value, 2000, 'avg') + t.is(result.summary.current.value, 2000, 'current = last point value for miner') + t.is(result.summary.current.unit, 'W', 'current has static unit') + t.pass() +}) + +test('getSitePowerConsumption - powermeter pulls current from list-things', async (t) => { + let listThingsCalled = false + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') { + return [ + { ts: 1, site_power_w: 5000 }, + { ts: 2, site_power_w: 7000 } + ] + } + if (method === 'listThings') { + listThingsCalled = true + return [{ id: 'pm1', last: { snap: { stats: { power_w: 9999 } } } }] + } + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-powermeter', interval: '5m' } + }) + + t.ok(listThingsCalled, 'fetches the conditional site power-meter source') + t.is(result.summary.min.value, 5000, 'min over site_power_w') + t.is(result.summary.max.value, 7000, 'max over site_power_w') + t.is(result.summary.avg.value, 6000, 'avg over site_power_w') + t.is(result.summary.current.value, 9999, 'current = live site power-meter value, not last point') + t.pass() +}) + +test('getSitePowerConsumption - miner branch does NOT call list-things', async (t) => { + let listThingsCalled = false + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') return [{ ts: 1, power_w_sum_aggr: 1234 }] + if (method === 'listThings') { listThingsCalled = true; return [] } + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.absent(listThingsCalled, 'no conditional source fetch for non-powermeter tags') + t.is(result.summary.current.value, 1234, 'current = last point value') + t.pass() +}) + +test('getSitePowerConsumption - container uses nested attribute', async (t) => { + const ctx = buildCtx({ + tailLogPoints: [ + { ts: 1, container_power_w_aggr: { '9a': 100 } }, + { ts: 2, container_power_w_aggr: { '9a': 300 } } + ] + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 'container-9a' } + }) + + t.is(result.log[0].value, 100, 'reads container_power_w_aggr.9a') + t.is(result.summary.max.value, 300, 'max over nested values') + t.is(result.summary.current.value, 300, 'current = last nested value') + t.pass() +}) + +test('getSitePowerConsumption - transformer flag selects transformer_power_w', async (t) => { + const ctx = buildCtx({ + tailLogPoints: [{ ts: 1, transformer_power_w: 8000 }] + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-miner', totalTransformerConsumption: true } + }) + + t.is(result.log[0].value, 8000, 'reads transformer_power_w when flagged') + t.is(result.summary.avg.value, 8000, 'avg over transformer values') + t.pass() +}) + +test('getSitePowerConsumption - powerAttribute query param overrides selection', async (t) => { + const ctx = buildCtx({ + tailLogPoints: [{ ts: 1, my_custom_attr: 555, power_w_sum_aggr: 111 }] + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-miner', powerAttribute: 'my_custom_attr' } + }) + + t.is(result.log[0].value, 555, 'uses the override attribute') + t.pass() +}) + +// ==================== Edge cases ==================== + +test('getSitePowerConsumption - empty range', async (t) => { + const ctx = buildCtx({ tailLogPoints: [] }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.is(result.log.length, 0, 'empty log') + t.is(result.summary.min.value, null, 'min null') + t.is(result.summary.max.value, null, 'max null') + t.is(result.summary.avg.value, null, 'avg null') + t.is(result.summary.current.value, 0, 'current defaults to 0 when no points') + t.pass() +}) + +test('getSitePowerConsumption - non-array RPC result is treated as empty', async (t) => { + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.is(result.log.length, 0, 'empty log on malformed result') + t.is(result.summary.avg.value, null, 'avg null on malformed result') + t.pass() +}) + +test('getSitePowerConsumption - powermeter with missing power-meter -> current 0', async (t) => { + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') return [{ ts: 1, site_power_w: 4200 }] + if (method === 'listThings') return [] // no site power meter found + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-powermeter' } }) + + t.is(result.summary.current.value, 0, 'current falls back to 0 when no power meter present') + t.is(result.summary.max.value, 4200, 'log/summary still computed from tail-log') + t.pass() +}) + +test('getSitePowerConsumption - missing start throws', async (t) => { + const ctx = buildCtx({}) + try { + await getSitePowerConsumption(ctx, { query: { end: RANGE.end, tag: 't-miner' } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'throws missing start/end') + } + t.pass() +}) + +test('getSitePowerConsumption - invalid range throws', async (t) => { + const ctx = buildCtx({}) + try { + await getSitePowerConsumption(ctx, { query: { start: RANGE.end, end: RANGE.start } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'throws invalid range') + } + t.pass() +}) + +// ==================== Parity with the UI's combining logic ==================== + +test('getSitePowerConsumption - matches getConsumptionGraphData min/max/avg', async (t) => { + const points = [ + { ts: 1, power_w_sum_aggr: 1500 }, + { ts: 2, power_w_sum_aggr: 4200 }, + { ts: 3, power_w_sum_aggr: 0 }, + { ts: 4, power_w_sum_aggr: 3300 } + ] + const attr = 'power_w_sum_aggr' + + // Replica of the raw (pre-format) arithmetic in ConsumptionLineChart's + // getConsumptionGraphData. + let totalAvgConsumption = 0 + let minConsumption = Number.MAX_SAFE_INTEGER + let maxConsumption = Number.MIN_SAFE_INTEGER + const expectedLog = [] + points.forEach((entry) => { + const sum = entry[attr] || 0 + expectedLog.push({ ts: entry.ts, value: sum, unit: 'W' }) + totalAvgConsumption += sum + if (sum < minConsumption) minConsumption = sum + if (sum > maxConsumption) maxConsumption = sum + }) + const expectedAvg = totalAvgConsumption / (points.length || 1) + + const ctx = buildCtx({ tailLogPoints: points }) + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.alike(result.log, expectedLog, 'log points match the UI mapping') + t.is(result.summary.min.value, minConsumption, 'min matches UI') + t.is(result.summary.max.value, maxConsumption, 'max matches UI') + t.is(result.summary.avg.value, expectedAvg, 'avg matches UI') + t.pass() +}) + +// ==================== Central DCS branch ==================== + +// Mock ctx with centralDCSSetup enabled. jRequest branches by method: +// listThings -> the DCS thing; tailLog -> the site_power_w stat series. +const buildDcsCtx = ({ tailLogPoints = [], dcsThing = null, onTailLog, tag = 't-dcs', tailLogThrows = false } = {}) => { + return withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: true, tag } } + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'listThings') return dcsThing ? [dcsThing] : [] + if (method === 'tailLog') { + if (onTailLog) onTailLog(payload) + if (tailLogThrows) throw new Error('ERR_TYPE_AGGR_INVALID') + return tailLogPoints + } + return [] + } + } + }) +} + +const makeDcsThing = (siteKw) => ({ + type: 'dcs-siemens', + last: { + snap: { + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-RACK-1', role: 'rack', power: { value: 50, unit: 'kW' } }, + { equipment: 'PM-MV', role: 'site_main', power: { value: siteKw, unit: 'kW' } } + ] + } + } + } + } + } +}) + +test('getSitePowerConsumption - centralDCS: history from site_power_w tail-log, current from DCS snapshot', async (t) => { + let captured = null + const ctx = buildDcsCtx({ + dcsThing: makeDcsThing(16700), // 16700 kW + tailLogPoints: [ + { ts: 1, site_power_w: 16000000 }, + { ts: 2, site_power_w: 17000000 }, + { ts: 3, site_power_w: 16500000 } + ], + onTailLog: (p) => { captured = p } + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-powermeter', interval: '5m' } + }) + + // queries the DCS thing's tail-log stat + t.is(captured.key, 'stat-5m', 'builds stat- key') + t.is(captured.type, 'dcs-siemens', 'routes by the DCS thing type') + t.is(captured.tag, 't-dcs', 'uses the configured DCS tag') + t.is(captured.aggrFields.site_power_w, 1, 'requests the site_power_w stat') + + t.is(result.log.length, 3, 'history sourced from DCS tail-log') + t.alike(result.log[0], { ts: 1, value: 16000000, unit: 'W' }, 'watts log point') + t.is(result.summary.min.value, 16000000, 'min') + t.is(result.summary.max.value, 17000000, 'max') + t.is(result.summary.avg.value, 16500000, 'avg') + t.is(result.summary.current.value, 16700 * 1000, 'current = DCS site_main kW*1000') + t.is(result.summary.current.unit, 'W', 'normalized to watts') + t.pass() +}) + +test('getSitePowerConsumption - centralDCS: tail-log error degrades to current-only', async (t) => { + const ctx = buildDcsCtx({ dcsThing: makeDcsThing(15000), tailLogThrows: true }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-powermeter' } }) + + t.is(result.log.length, 0, 'empty log when DCS tail-log not yet available') + t.is(result.summary.min.value, null, 'min null') + t.is(result.summary.avg.value, null, 'avg null') + t.is(result.summary.current.value, 15000 * 1000, 'current still from DCS snapshot') + t.pass() +}) + +test('getSitePowerConsumption - centralDCS: empty tail-log -> current-only', async (t) => { + const ctx = buildDcsCtx({ dcsThing: makeDcsThing(0), tailLogPoints: [] }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-powermeter' } }) + + t.is(result.log.length, 0, 'empty log') + t.is(result.summary.avg.value, null, 'avg null') + t.is(result.summary.current.value, 0, 'no site_main value -> 0') + t.pass() +}) + +test('getSitePowerConsumption - centralDCS does not affect miner tag', async (t) => { + let listThingsCalled = false + const ctx = withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') return [{ ts: 1, power_w_sum_aggr: 4321 }] + if (method === 'listThings') { listThingsCalled = true; return [] } + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.is(result.log[0].value, 4321, 'miner path unaffected by centralDCS') + t.is(result.summary.current.value, 4321, 'current = last miner point') + t.absent(listThingsCalled, 'no DCS/list-things fetch for the miner tag') + t.pass() +}) + +test('getSitePowerConsumption - centralDCS disabled: powermeter uses legacy site_power_w path', async (t) => { + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, // no centralDCSSetup + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') return [{ ts: 1, site_power_w: 5000 }] + if (method === 'listThings') return [{ last: { snap: { stats: { power_w: 8888 } } } }] + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-powermeter' } }) + + t.is(result.summary.max.value, 5000, 'legacy powermeter site_power_w history') + t.is(result.summary.current.value, 8888, 'current from list-things (legacy), not DCS') + t.pass() +}) diff --git a/tests/unit/routes/power.consumption.routes.test.js b/tests/unit/routes/power.consumption.routes.test.js new file mode 100644 index 0000000..d18b8ca --- /dev/null +++ b/tests/unit/routes/power.consumption.routes.test.js @@ -0,0 +1,62 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/power.consumption.routes.js' + +test('power consumption routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, '/site/power-consumption') + t.pass() +}) + +test('power consumption routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/site/power-consumption'), 'should have power consumption route') + + t.pass() +}) + +test('power consumption routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const route = routes.find(r => r.url === '/auth/site/power-consumption') + t.is(route.method, 'GET', 'power consumption route should be GET') + + t.pass() +}) + +test('power consumption routes - schema validation', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const route = routes.find(r => r.url === '/auth/site/power-consumption') + t.ok(route.schema, 'route should have schema') + t.ok(route.schema.querystring, 'should have querystring schema') + t.is(route.schema.querystring.properties.start.type, 'integer', 'start should be integer') + t.is(route.schema.querystring.properties.end.type, 'integer', 'end should be integer') + t.is(route.schema.querystring.properties.interval.type, 'string', 'interval should be string') + t.is(route.schema.querystring.properties.tag.type, 'string', 'tag should be string') + t.is(route.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + t.alike(route.schema.querystring.required, ['start', 'end'], 'start and end should be required') + + t.pass() +}) + +test('power consumption routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, '/site/power-consumption') + t.pass() +}) + +test('power consumption routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.ok(typeof route.onRequest === 'function', `route ${route.url} should have onRequest function`) + }) + + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index b4d0fb4..8653068 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -152,6 +152,7 @@ const ENDPOINTS = { POOLS_CONTAINERS_STATS: '/auth/pools/stats/containers', SITE_STATUS_LIVE: '/auth/site/status/live', + SITE_POWER_CONSUMPTION: '/auth/site/power-consumption', // Generic Config endpoints (type passed as parameter) // Note: Config mutations (register, update, delete) go through pushAction endpoint diff --git a/workers/lib/dcs.utils.js b/workers/lib/dcs.utils.js index 6e1eae0..7822b95 100644 --- a/workers/lib/dcs.utils.js +++ b/workers/lib/dcs.utils.js @@ -25,6 +25,13 @@ function extractDcsThing (rpcResults) { return null } +// Site-wide power in watts from a DCS thing's "site_main" meter (reported in kW). +function extractSiteMainMeterPowerW (dcsThing) { + const powerMeters = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.power_meters || [] + const siteMeter = powerMeters.find(pm => pm.role === 'site_main') + return (siteMeter?.power?.value || 0) * 1000 +} + function getSensorReading (sensors, sensorId, defaultConfig = null) { if (!sensorId) return defaultConfig const sensor = sensors?.find(s => s.equipment === sensorId) @@ -62,6 +69,7 @@ module.exports = { isCentralDCSEnabled, getDCSTag, extractDcsThing, + extractSiteMainMeterPowerW, getSensorReading, findEquipment, filterEquipmentBy, diff --git a/workers/lib/server/handlers/power.consumption.handlers.js b/workers/lib/server/handlers/power.consumption.handlers.js new file mode 100644 index 0000000..96b7435 --- /dev/null +++ b/workers/lib/server/handlers/power.consumption.handlers.js @@ -0,0 +1,203 @@ +'use strict' + +const { RPC_METHODS, WORKER_TAGS, DCS_POWER_METER_FIELDS } = require('../../constants') +const { validateStartEnd } = require('../../metrics.utils') +const { + isCentralDCSEnabled, + getDCSTag, + fetchDcsThing, + extractSiteMainMeterPowerW +} = require('../../dcs.utils') + +// Mirror the UI consumption chart's tail-log fetch (LIMIT 288, default interval 5m). +const DEFAULT_LIMIT = 288 +const DEFAULT_INTERVAL = '5m' +// Raw watts pass through; the UI keeps any kW/MW display scaling. +const POWER_UNIT = 'W' + +const CONSUMPTION_FIELDS = { 'last.snap.stats.power_w': 1, info: 1 } +const CONSUMPTION_AGGR_FIELDS = { + site_power_w: 1, + power_w_sum_aggr: 1, + container_power_w_aggr: 1, + transformer_power_w: 1 +} + +// Site power-meter lookup (useHeaderStats source for the live "current" value). +const SITE_POWERMETER_QUERY = { 'info.pos': { $eq: 'site' } } +const SITE_POWERMETER_FIELDS = { id: 1, 'last.snap.stats.power_w': 1, tags: 1 } +const SITE_POWERMETER_LIMIT = 100 +const SITE_POWER_W_PATH = 'last.snap.stats.power_w' + +const SITE_POWER_ATTRIBUTE = 'site_power_w' +const DCS_THING_FIELDS = { id: 1, code: 1, type: 1, tags: 1, ...DCS_POWER_METER_FIELDS } + +// getChartType / removeContainerPrefix / getPowerBEAttribute mirror the same +// helpers in the UI's ConsumptionLineChart.tsx / deviceUtils.ts. +function getChartType (tag) { + if (tag.includes('container')) return 'container' + if (tag.includes('miner')) return 'miner' + return tag.replace(/^t-/, '') +} + +function removeContainerPrefix (text) { + return text.replace(/^container-/, '') +} + +function getPowerBEAttribute (tag, totalTransformerConsumption) { + if (tag.includes('container')) { + return `container_power_w_aggr.${removeContainerPrefix(tag)}` + } + if (totalTransformerConsumption) return 'transformer_power_w' + if (tag.includes('powermeter')) return 'site_power_w' + return 'power_w_sum_aggr' +} + +// Dot-path getter (lodash _get equivalent for the simple paths used here). +function getByPath (obj, path) { + if (!obj || !path) return undefined + return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj) +} + +function buildConsumptionLog (points, powerBEAttribute) { + return points.map((entry) => ({ + ts: entry.ts, + value: getByPath(entry, powerBEAttribute) || 0, + unit: POWER_UNIT + })) +} + +// min/max/avg over raw values, replicating getConsumptionGraphData's arithmetic. +// Empty range yields null min/max/avg (clean-null convention). +function computeConsumptionSummary (points, powerBEAttribute) { + if (!points.length) { + return { + min: { value: null, unit: POWER_UNIT }, + max: { value: null, unit: POWER_UNIT }, + avg: { value: null, unit: POWER_UNIT } + } + } + + let total = 0 + let min = Number.MAX_SAFE_INTEGER + let max = Number.MIN_SAFE_INTEGER + + for (const entry of points) { + const value = getByPath(entry, powerBEAttribute) || 0 + total += value + if (value < min) min = value + if (value > max) max = value + } + + return { + min: { value: min, unit: POWER_UNIT }, + max: { value: max, unit: POWER_UNIT }, + avg: { value: total / points.length, unit: POWER_UNIT } + } +} + +// "current" value, mirroring getConsumptionGraphData: for site_power_w it's the +// live site power-meter (list-things, useHeaderStats' source); otherwise the last +// historical point. The site_power_w case is the UI's conditional second fetch. +async function resolveCurrentValue (ctx, points, powerBEAttribute) { + if (powerBEAttribute === 'site_power_w') { + const listRes = await ctx.dataProxy.requestDataMap(RPC_METHODS.LIST_THINGS, { + status: 1, + query: SITE_POWERMETER_QUERY, + fields: SITE_POWERMETER_FIELDS, + limit: SITE_POWERMETER_LIMIT + }) + + const things = Array.isArray(listRes) && Array.isArray(listRes[0]) ? listRes[0] : [] + const head = things[0] + return Number(getByPath(head, SITE_POWER_W_PATH)) || 0 + } + + const last = points[points.length - 1] + return getByPath(last, powerBEAttribute) || 0 +} + +// Central-DCS site consumption: current from the DCS site_main snapshot (kW->W), +// history from the DCS thing's site_power_w tail-log stat. Until the worker/ork +// pipeline is deployed the tail-log may error or be empty, so we degrade to +// current-only (empty log, null min/max/avg) rather than fabricating history. +async function getDCSSitePowerConsumption (ctx, { start, end, interval, limit }) { + const dcsThing = await fetchDcsThing(ctx, DCS_THING_FIELDS) + const currentValue = extractSiteMainMeterPowerW(dcsThing) + + let points = [] + try { + const res = await ctx.dataProxy.requestDataMap(RPC_METHODS.TAIL_LOG, { + key: `stat-${interval}`, + type: dcsThing?.type || 'dcs', + tag: getDCSTag(ctx), + aggrFields: { [SITE_POWER_ATTRIBUTE]: 1 }, + start, + end, + limit + }) + points = Array.isArray(res) && Array.isArray(res[0]) ? res[0] : [] + } catch (e) { + points = [] + } + + const log = buildConsumptionLog(points, SITE_POWER_ATTRIBUTE) + const summary = computeConsumptionSummary(points, SITE_POWER_ATTRIBUTE) + summary.current = { value: currentValue, unit: POWER_UNIT } + + return { log, summary } +} + +// GET /site/power-consumption — server-side reproduction of the consumption line +// chart: tail-log over a tag/interval/range, tag-appropriate power attribute, +// returning { summary: min/max/avg + current, log: timeseries } in raw watts. +async function getSitePowerConsumption (ctx, req) { + const { start, end } = validateStartEnd(req) + + const tag = req.query.tag || WORKER_TAGS.MINER + const interval = req.query.interval || DEFAULT_INTERVAL + const limit = Number(req.query.limit) || DEFAULT_LIMIT + const totalTransformerConsumption = !!req.query.totalTransformerConsumption + const powerBEAttribute = req.query.powerAttribute || + getPowerBEAttribute(tag, totalTransformerConsumption) + + // Central-DCS: the site meter lives in the DCS thing, not a powermeter worker. + if (isCentralDCSEnabled(ctx) && powerBEAttribute === SITE_POWER_ATTRIBUTE) { + return getDCSSitePowerConsumption(ctx, { start, end, interval, limit }) + } + + const res = await ctx.dataProxy.requestDataMap(RPC_METHODS.TAIL_LOG, { + key: `stat-${interval}`, + type: getChartType(tag), + tag, + fields: CONSUMPTION_FIELDS, + aggrFields: CONSUMPTION_AGGR_FIELDS, + start, + end, + limit + }) + + // The chart consumes the first site's series (`_head(tailLogData)`); mirror that. + const points = Array.isArray(res) && Array.isArray(res[0]) ? res[0] : [] + + const log = buildConsumptionLog(points, powerBEAttribute) + const summary = computeConsumptionSummary(points, powerBEAttribute) + summary.current = { + value: await resolveCurrentValue(ctx, points, powerBEAttribute), + unit: POWER_UNIT + } + + return { log, summary } +} + +module.exports = { + getSitePowerConsumption, + getDCSSitePowerConsumption, + getChartType, + removeContainerPrefix, + getPowerBEAttribute, + getByPath, + buildConsumptionLog, + computeConsumptionSummary, + resolveCurrentValue +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 5ced8d0..13de83e 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -11,6 +11,7 @@ const wsRoutes = require('./routes/ws.routes') const financeRoutes = require('./routes/finance.routes') const poolsRoutes = require('./routes/pools.routes') const siteRoutes = require('./routes/site.routes') +const powerConsumptionRoutes = require('./routes/power.consumption.routes') const configsRoutes = require('./routes/configs.routes') const devicesRoutes = require('./routes/devices.routes') const metricsRoutes = require('./routes/metrics.routes') @@ -43,6 +44,7 @@ function routes (ctx) { ...financeRoutes(ctx), ...poolsRoutes(ctx), ...siteRoutes(ctx), + ...powerConsumptionRoutes(ctx), ...configsRoutes(ctx), ...devicesRoutes(ctx), ...metricsRoutes(ctx), diff --git a/workers/lib/server/routes/power.consumption.routes.js b/workers/lib/server/routes/power.consumption.routes.js new file mode 100644 index 0000000..2f5ccde --- /dev/null +++ b/workers/lib/server/routes/power.consumption.routes.js @@ -0,0 +1,43 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { getSitePowerConsumption } = require('../handlers/power.consumption.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.SITE_POWER_CONSUMPTION, + schema: { + querystring: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + interval: { type: 'string' }, + tag: { type: 'string' }, + powerAttribute: { type: 'string' }, + totalTransformerConsumption: { type: 'boolean' }, + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'site-power-consumption', req.query.start, req.query.end, + req.query.interval, req.query.tag, req.query.powerAttribute, + req.query.totalTransformerConsumption, req.query.limit + ], + ENDPOINTS.SITE_POWER_CONSUMPTION, + getSitePowerConsumption + ) + } + ] +} From 457336de9444610308e2d42646e42992de875f23 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Tue, 16 Jun 2026 13:35:40 +0300 Subject: [PATCH 60/63] Feat: Inventory v3 additions (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: add ACME Container inventory location Insert 'ACME Container' between Vendor and Scrapped in MINER_LOCATIONS per Inventory v3 spec (Site overview locations table). Asana: T1 * Feat: expand work order types to 1/2/3/4 Redefine WORK_ORDER_TYPES = REGISTER(1), MOVE(2), MICROBT_MINER(3), MICROBT_NON_MINER(4) per Inventory v3 numbering. Widen the create/list type enum to [1,2,3,4] and require 'issue' for the MicroBT repair types (3,4) instead of the old type 2. The IVI-- numbering already supports any numeric type. Update REGULAR references (issue rule, diagnosis parts-seed) to the MicroBT types. Asana: T2 * Feat: seed a move parts-move for Move Devices work orders Add the Move(2) branch to createWorkOrder: resolve the part and seed a role:'move' parts-move capturing fromLocation (part's current location) and toLocation (requested info.location). Register(1) and MicroBT(3,4) seeds unchanged; the issue-required remap landed with the type change. Asana: T4 * Feat: stamp closedAt when a work order is closed Set info.closedAt on closeWorkOrder so the WO explorer can render a 'date closed' column from listThings (status/assignedTo/deviceType already present). Auto-closed Register/Move WOs stamp closedAt in the rack on creation. Asana: T5 * Feat: add Export RMA (multi-select CSV, MicroBT Miner only) New GET /auth/work-orders/export/rma?ids= endpoint that loads the selected WOs, keeps only MicroBT Miner (type 3) ones, and emits a CSV with the fixed RMA columns (Ticket, Repaired type, Repaired Miner Sn, Repaired/Replaced part SN, Repaired Analyze/Treatment, Remark, Miner Model, Repair Date, Engineer). renderRmaCsv reuses csvEscape and reads repaired/replacement parts from partsMoves roles. The per-WO PDF ('Export Selected') is deferred until its format is finalized. Asana: T7 * Fix: skip capCheck under --noauth so dev/local mode can serve /auth routes authLib is only constructed when not in noAuth mode, but createAuthOnRequest still ran capCheck (which calls ctx.authLib.tokenHasPerms) for any route with perms, so every /auth route 500'd under --noauth. Guard capCheck with !ctx.noAuth. Production 'start' never sets noAuth, so permission enforcement is unchanged there. Enables local curl verification of the work-order routes. Not part of the inventory tickets — separate so it can be dropped/owned independently. * Refactor: move RMA_COLUMNS to constants Move the fixed RMA_COLUMNS list to workers/lib/constants.js and import it in work.order.export.js. Repair-date formatting is inlined in renderRmaCsv (single use, no shared helper). No behaviour change. * Style: remove explanatory comments * Refactor: align MINER_LOCATIONS with FE dotted-lowercase format Per FE/BE alignment on PR #95: switch all location values from Title Case display strings (e.g. 'Site Warehouse') to dotted-lowercase keys (e.g. 'site.warehouse'). The set is the union of the spec's locations and the FE's existing constant — 11 values total. - MINER_LOCATIONS: workshop.warehouse, workshop.lab, site.warehouse, site.lab, site.container, miner.room, vendor, acme.container, scrapped, disposed, unknown - SPARE_PART_INITIAL_LOCATION: 'Site Warehouse' -> 'site.warehouse' - Update fixture strings in work-orders + spare-parts tests NOTE: miningos-wrk-inventory needs the matching change (constants + _validateLocation set) or the inventory worker will reject these values with ERR_INVALID_LOCATION. Existing stored rows ('Site Warehouse', etc.) need a one-time migration to the new keys. --- .../handlers/spare.parts.handlers.test.js | 24 +++++----- .../handlers/work.orders.handlers.test.js | 45 ++++++++++++++++--- tests/unit/lib/routeHelpers.test.js | 14 ++++++ tests/unit/lib/work.order.export.test.js | 41 ++++++++++++++++- tests/unit/routes/spare.parts.routes.test.js | 4 +- tests/unit/routes/work.orders.routes.test.js | 3 +- workers/lib/constants.js | 24 ++++++++-- .../server/handlers/work.orders.handlers.js | 41 +++++++++++++++-- workers/lib/server/lib/routeHelpers.js | 2 +- workers/lib/server/lib/work.order.export.js | 29 +++++++++++- .../lib/server/routes/work.orders.routes.js | 9 +++- .../lib/server/schemas/work.orders.schemas.js | 17 +++++-- 12 files changed, 218 insertions(+), 35 deletions(-) diff --git a/tests/unit/handlers/spare.parts.handlers.test.js b/tests/unit/handlers/spare.parts.handlers.test.js index 534c33e..0a0bb43 100644 --- a/tests/unit/handlers/spare.parts.handlers.test.js +++ b/tests/unit/handlers/spare.parts.handlers.test.js @@ -58,7 +58,7 @@ test('handlers: updateSparePart rejects location/status changes without workOrde () => handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, info: { location: 'site.lab' } } }), /ERR_PART_MOVE_REQUIRES_WO/ ) @@ -70,7 +70,7 @@ test('handlers: updateSparePart rejects when WO is closed', async (t) => { () => handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'site.lab' } } }), /ERR_WO_INVALID_STATUS_TRANSITION/ ) @@ -82,7 +82,7 @@ test('handlers: updateSparePart 404s when WO is missing', async (t) => { () => handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, workOrderId: 'wo-missing', info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, workOrderId: 'wo-missing', info: { location: 'site.lab' } } }), /ERR_WORK_ORDER_NOT_FOUND/ ) @@ -93,14 +93,14 @@ test('handlers: updateSparePart pushes part update + WO partsMoves append on a v const out = await handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'site.lab' } } }) t.is(pushed.length, 2, 'two actions pushed (part + WO)') const partAction = pushed.find(p => p.params[0].rackId === PART_RACK) t.is(partAction.action, 'updateThing') - t.is(partAction.params[0].info.location, 'Site Lab') + t.is(partAction.params[0].info.location, 'site.lab') t.is(partAction.params[0].info.workOrderId, 'wo-1', 'workOrderId injected into part info') t.is(partAction.params[0].info.workOrderCode, 'IVI-2-0001', 'workOrderCode injected too') @@ -110,7 +110,7 @@ test('handlers: updateSparePart pushes part update + WO partsMoves append on a v t.is(moves[0].partId, PART.id) t.is(moves[0].partCode, PART.code) t.is(moves[0].fromLocation, 'Lab') - t.is(moves[0].toLocation, 'Site Lab') + t.is(moves[0].toLocation, 'site.lab') t.is(moves[0].workOrderCode, 'IVI-2-0001') t.ok(out.move, 'response includes the move record') @@ -135,7 +135,7 @@ test('handlers: updateSparePart aborts WO append when the part pushAction return () => handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'site.lab' } } }), /ERR_PART_UPDATE_PUSH_FAILED:ERR_RACK_DOWN/ ) @@ -211,7 +211,7 @@ test('handlers: registerSparePart fires part + Type-1 WO pushActions in parallel t.is(woAction.action, 'registerThing') t.is(woAction.params[0].info.type, 1, 'Type-1 WO') t.is(woAction.params[0].info.partsMoves[0].fromLocation, null) - t.is(woAction.params[0].info.partsMoves[0].toLocation, 'Site Warehouse') + t.is(woAction.params[0].info.partsMoves[0].toLocation, 'site.warehouse') t.is(woAction.params[0].info.partsMoves[0].partId, out.partId, 'WO partsMoves entry links to the pre-generated partId') t.ok(out.partId, 'returns partId') @@ -265,9 +265,9 @@ test('handlers: listSpareParts honors an explicit type filter (overrides $ne def test('handlers: listSpareParts maps location/status shortcuts to info.* mingo paths', async (t) => { const flow = listFlow() await handlers.listSpareParts(flow.ctx, { - query: { location: 'Site Lab', status: 'faulty' } + query: { location: 'site.lab', status: 'faulty' } }) - t.is(flow.lastList.query['info.location'], 'Site Lab') + t.is(flow.lastList.query['info.location'], 'site.lab') t.is(flow.lastList.query['info.status'], 'faulty') }) @@ -290,10 +290,10 @@ test('handlers: listSpareParts ?q escapes regex metacharacters', async (t) => { test('handlers: listSpareParts ANDs location/status/q in a single query payload', async (t) => { const flow = listFlow() await handlers.listSpareParts(flow.ctx, { - query: { location: 'Site Lab', status: 'faulty', q: 'PS-' } + query: { location: 'site.lab', status: 'faulty', q: 'PS-' } }) const q = flow.lastList.query - t.is(q['info.location'], 'Site Lab') + t.is(q['info.location'], 'site.lab') t.is(q['info.status'], 'faulty') t.ok(Array.isArray(q.$or) && q.$or.length === 3) }) diff --git a/tests/unit/handlers/work.orders.handlers.test.js b/tests/unit/handlers/work.orders.handlers.test.js index ba6ae76..d5248d5 100644 --- a/tests/unit/handlers/work.orders.handlers.test.js +++ b/tests/unit/handlers/work.orders.handlers.test.js @@ -30,12 +30,12 @@ function buildSubmitFlow ({ rackId = RACK, parts = [] } = {}) { return { ctx, get lastPush () { return lastPush } } } -test('handlers: createWorkOrder Type 2 resolves part and forwards body as info', async (t) => { +test('handlers: createWorkOrder Type 3 resolves part and forwards body as info', async (t) => { const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'AM-1' } }] }) await handlers.createWorkOrder(flow.ctx, { ...userMeta(), body: { - type: 2, + type: 3, deviceType: 'miner', deviceModel: 'antminer-s19xp', deviceIdentifier: 'AM-1', @@ -48,6 +48,25 @@ test('handlers: createWorkOrder Type 2 resolves part and forwards body as info', t.is(flow.lastPush.params[0].info.partsMoves[0].role, 'diagnosis') }) +test('handlers: createWorkOrder Type 2 (move) seeds a move parts-move with from/to locations', async (t) => { + const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'SN-1', location: 'site.lab' } }] }) + await handlers.createWorkOrder(flow.ctx, { + ...userMeta(), + body: { + type: 2, + deviceType: 'psu', + deviceModel: 'PSU-1', + deviceIdentifier: 'SN-1', + info: { location: 'site.warehouse' } + } + }) + const move = flow.lastPush.params[0].info.partsMoves[0] + t.is(move.role, 'move') + t.is(move.partId, 'part-1') + t.is(move.fromLocation, 'site.lab') + t.is(move.toLocation, 'site.warehouse') +}) + test('handlers: createWorkOrder merges info.notes, info.remarks, info.site, info.location into thing info', async (t) => { const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'SN-1' } }] }) await handlers.createWorkOrder(flow.ctx, { @@ -61,7 +80,7 @@ test('handlers: createWorkOrder merges info.notes, info.remarks, info.site, info notes: 'batch registration', remarks: 'test remark', site: 'Ivinhema', - location: 'Site Warehouse' + location: 'site.warehouse' } } }) @@ -69,7 +88,7 @@ test('handlers: createWorkOrder merges info.notes, info.remarks, info.site, info t.is(info.notes, 'batch registration') t.is(info.remarks, 'test remark') t.is(info.site, 'Ivinhema') - t.is(info.location, 'Site Warehouse') + t.is(info.location, 'site.warehouse') t.is(info.deviceType, 'psu', 'top-level fields still present') t.ok(!info.info, 'no nested info.info') }) @@ -90,7 +109,7 @@ test('handlers: createWorkOrder 400s ERR_PART_NOT_FOUND when deviceIdentifier re await t.exception( () => handlers.createWorkOrder(flow.ctx, { ...userMeta(), - body: { type: 2, deviceType: 'psu', deviceModel: 'm', deviceIdentifier: 'unknown-sn', issue: 'i' } + body: { type: 3, deviceType: 'psu', deviceModel: 'm', deviceIdentifier: 'unknown-sn', issue: 'i' } }), /ERR_PART_NOT_FOUND/ ) @@ -119,6 +138,7 @@ test('handlers: closeWorkOrder maps to updateThing with status=closed and finalR t.is(flow.lastPush.params[0].id, 'wo-1') t.is(flow.lastPush.params[0].info.status, 'closed') t.is(flow.lastPush.params[0].info.finalResult, 'replaced PSU') + t.ok(flow.lastPush.params[0].info.closedAt, 'stamps closedAt') }) test('handlers: cancelWorkOrder maps to updateThing with status=cancelled', async (t) => { @@ -329,6 +349,21 @@ test('handlers: exportWorkOrder csv sets text/csv content-type and attachment fi t.ok(typeof rep._body === 'string' && rep._body.startsWith('code,status,type')) }) +test('handlers: exportWorkOrdersRma returns CSV of only the MicroBT Miner WOs selected', async (t) => { + const miner = { id: 'wo-3', code: 'IVI-3-0001', info: { type: 3, deviceModel: 'M63S++_VL28', deviceIdentifier: 'MINER-SN-1', issue: 'low hashrate', finalResult: 'replaced HB', remarks: 'r', assignedTo: 'eng@test', createdAt: 1, partsMoves: [{ role: 'diagnosis', partCode: 'HB-OLD' }, { role: 'replacement', partCode: 'HB-NEW' }] } } + const move = { id: 'wo-2', code: 'IVI-2-0002', info: { type: 2, partsMoves: [] } } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async () => [miner, move]) + const rep = mkRep() + await handlers.exportWorkOrdersRma(ctx, { query: { ids: 'IVI-3-0001,IVI-2-0002' } }, rep) + t.is(rep._headers['content-type'], 'text/csv; charset=utf-8') + t.ok(rep._headers['content-disposition'].includes('rma.csv')) + const lines = rep._body.trim().split('\r\n') + t.is(lines.length, 2, 'header + 1 MicroBT Miner row (Move WO ignored)') + t.ok(lines[0].startsWith('Ticket,Repaired type')) + t.ok(lines[1].startsWith('IVI-3-0001,')) + t.ok(lines[1].includes('HB-OLD') && lines[1].includes('HB-NEW')) +}) + test('handlers: getWorkOrderAudit calls getHistoricalLogs filtered by id', async (t) => { let received const ctx = createMockCtxWithOrks( diff --git a/tests/unit/lib/routeHelpers.test.js b/tests/unit/lib/routeHelpers.test.js index 53d8615..659c385 100644 --- a/tests/unit/lib/routeHelpers.test.js +++ b/tests/unit/lib/routeHelpers.test.js @@ -109,6 +109,20 @@ test('createAuthOnRequest - calls capCheck when perms provided', async (t) => { t.pass() }) +test('createAuthOnRequest - skips capCheck when ctx.noAuth is set', async (t) => { + let permsChecked = false + const mockCtx = { + noAuth: true, + authLib: { tokenHasPerms: async () => { permsChecked = true; return false } } + } + const mockReq = { headers: {}, _info: {} } + const mockRep = { status: function () { return this }, send: function () { return this } } + + const onRequest = createAuthOnRequest(mockCtx, ['test:perm']) + await onRequest(mockReq, mockRep) + t.absent(permsChecked, 'capCheck/tokenHasPerms is not invoked under noAuth') +}) + test('createCachedHandler - uses cachedRoute', async (t) => { const mockCtx = { conf: { diff --git a/tests/unit/lib/work.order.export.test.js b/tests/unit/lib/work.order.export.test.js index b403cbd..11817f1 100644 --- a/tests/unit/lib/work.order.export.test.js +++ b/tests/unit/lib/work.order.export.test.js @@ -1,7 +1,7 @@ 'use strict' const test = require('brittle') -const { renderWorkOrderCsv } = require('../../../workers/lib/server/lib/work.order.export') +const { renderWorkOrderCsv, renderRmaCsv } = require('../../../workers/lib/server/lib/work.order.export') const WO = { id: 'wo-1', @@ -60,3 +60,42 @@ test('work.order.export: CSV escapes commas / quotes / newlines in field values' const csv = renderWorkOrderCsv(wo) t.ok(csv.includes('"fan, broken\nreplaced"'), 'value with comma/newline wrapped in quotes') }) + +test('work.order.export: RMA CSV emits the fixed RMA column header', (t) => { + t.is( + renderRmaCsv([]).trim(), + 'Ticket,Repaired type,Repaired Miner Sn,Repaired Mac/HB SN/PSU SN,Replaced Mac/HB SN/PSU SN,Repaired Analyze,Repaired Treatment,Remark,Miner Model,Repair Date,Engineer' + ) +}) + +test('work.order.export: RMA CSV maps a MicroBT Miner WO to the fixed columns', (t) => { + const wo = { + code: 'IVI-3-0001', + info: { + type: 3, + deviceModel: 'M63S++_VL28', + deviceIdentifier: 'MINER-SN-1', + issue: 'low hashrate', + finalResult: 'replaced HB', + remarks: 'tech remark', + assignedTo: 'eng@test', + createdBy: 'op@test', + closedAt: 1730764800000, + partsMoves: [ + { role: 'diagnosis', partCode: 'HB-OLD' }, + { role: 'replacement', partCode: 'HB-NEW' } + ] + } + } + const row = renderRmaCsv([wo]).trim().split('\r\n')[1].split(',') + t.is(row[0], 'IVI-3-0001', 'Ticket') + t.is(row[1], 'M63S++_VL28', 'Repaired type') + t.is(row[2], 'MINER-SN-1', 'Repaired Miner Sn') + t.is(row[3], 'HB-OLD', 'Repaired part identifier') + t.is(row[4], 'HB-NEW', 'Replaced part identifier') + t.is(row[5], 'low hashrate', 'Repaired Analyze') + t.is(row[6], 'replaced HB', 'Repaired Treatment') + t.is(row[7], 'tech remark', 'Remark') + t.is(row[9], new Date(wo.info.closedAt).toISOString().slice(0, 10), 'Repair Date from closedAt') + t.is(row[10], 'eng@test', 'Engineer') +}) diff --git a/tests/unit/routes/spare.parts.routes.test.js b/tests/unit/routes/spare.parts.routes.test.js index 1fabd2e..4931627 100644 --- a/tests/unit/routes/spare.parts.routes.test.js +++ b/tests/unit/routes/spare.parts.routes.test.js @@ -50,12 +50,12 @@ test('spare.parts.routes: list cache key includes every filter shortcut', (t) => offset: 0, limit: 10, q: 'AB:CD', - location: 'Site Lab', + location: 'site.lab', status: 'faulty' } } const key = capturedKeyFn(req) - for (const expected of ['{"info.foo":1}', '{"code":1}', '{}', 0, 10, 'AB:CD', 'Site Lab', 'faulty']) { + for (const expected of ['{"info.foo":1}', '{"code":1}', '{}', 0, 10, 'AB:CD', 'site.lab', 'faulty']) { t.ok(key.includes(expected), `cache key includes ${JSON.stringify(expected)}`) } diff --git a/tests/unit/routes/work.orders.routes.test.js b/tests/unit/routes/work.orders.routes.test.js index e64acc0..b25b723 100644 --- a/tests/unit/routes/work.orders.routes.test.js +++ b/tests/unit/routes/work.orders.routes.test.js @@ -28,7 +28,8 @@ test('work.orders.routes: registers every WO endpoint', (t) => { { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_CANCEL }, { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_ASSIGN }, { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_LOG }, - { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_EXPORT } + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_EXPORT }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_EXPORT_RMA } ] for (const e of expected) { const found = routes.find(r => r.method === e.method && r.url === e.url) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 8653068..a780a7f 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -45,11 +45,11 @@ const AUTH_PERMISSIONS = { const WORK_ORDER_THING_TYPE = 'inventory-work_order' -const WORK_ORDER_TYPES = { REGISTER: 1, REGULAR: 2 } +const WORK_ORDER_TYPES = { REGISTER: 1, MOVE: 2, MICROBT_MINER: 3, MICROBT_NON_MINER: 4 } const WORK_ORDER_TERMINAL_STATUSES = ['closed', 'cancelled'] const WORK_ORDER_VALID_DEVICE_TYPES = ['miner', 'psu', 'hashboard', 'controller'] -const MINER_LOCATIONS = ['Site Warehouse', 'Site Lab', 'Miner Room', 'Vendor', 'Scrapped', 'Disposed'] -const SPARE_PART_INITIAL_LOCATION = 'Site Warehouse' +const MINER_LOCATIONS = ['workshop.warehouse', 'workshop.lab', 'site.warehouse', 'site.lab', 'site.container', 'miner.room', 'vendor', 'acme.container', 'scrapped', 'disposed', 'unknown'] +const SPARE_PART_INITIAL_LOCATION = 'site.warehouse' const FILE_TYPES = { WORK_ORDER: 'work_order' } const WORK_ORDER_FILE_MAX_BYTES_DEFAULT = 10 * 1024 * 1024 const WORK_ORDER_FILE_COUNT_CAP_DEFAULT = 20 @@ -212,11 +212,26 @@ const ENDPOINTS = { SPARE_PART_BY_ID: '/auth/spare-parts/:id', SPARE_PART_REPAIR_HISTORY: '/auth/spare-parts/:id/repair-history', // Work Order export - WORK_ORDER_EXPORT: '/auth/work-orders/:id/export' + WORK_ORDER_EXPORT: '/auth/work-orders/:id/export', + WORK_ORDER_EXPORT_RMA: '/auth/work-orders/export/rma' } const WORK_ORDER_EXPORT_FORMATS = ['pdf', 'csv', 'docx'] +const RMA_COLUMNS = [ + 'Ticket', + 'Repaired type', + 'Repaired Miner Sn', + 'Repaired Mac/HB SN/PSU SN', + 'Replaced Mac/HB SN/PSU SN', + 'Repaired Analyze', + 'Repaired Treatment', + 'Remark', + 'Miner Model', + 'Repair Date', + 'Engineer' +] + const HTTP_METHODS = { GET: 'GET', POST: 'POST', @@ -795,5 +810,6 @@ module.exports = { WORK_ORDER_FILE_COUNT_CAP_DEFAULT, WORK_ORDER_FILE_MIME_ALLOWLIST_DEFAULT, WORK_ORDER_EXPORT_FORMATS, + RMA_COLUMNS, MICROSOFT_AUTH_SCOPE } diff --git a/workers/lib/server/handlers/work.orders.handlers.js b/workers/lib/server/handlers/work.orders.handlers.js index 81b7480..196dd8a 100644 --- a/workers/lib/server/handlers/work.orders.handlers.js +++ b/workers/lib/server/handlers/work.orders.handlers.js @@ -8,7 +8,7 @@ const { WORK_ORDER_VALID_DEVICE_TYPES, SPARE_PART_INITIAL_LOCATION } = require('../../constants') -const { renderWorkOrderCsv } = require('../lib/work.order.export') +const { renderWorkOrderCsv, renderRmaCsv } = require('../lib/work.order.export') const { submitWorkOrderAction, getWorkOrderRackId } = require('../lib/work.orders') async function _resolvePartByIdentifier (ctx, identifier) { @@ -38,7 +38,7 @@ async function createWorkOrder (ctx, req) { const { info: extraInfo, ...body } = req.body const info = { ...body, ...extraInfo, createdBy: voter, createdAt: Date.now() } - if (type === WORK_ORDER_TYPES.REGULAR) { + if (type === WORK_ORDER_TYPES.MICROBT_MINER || type === WORK_ORDER_TYPES.MICROBT_NON_MINER) { const part = await _resolvePartByIdentifier(ctx, deviceIdentifier) if (!part) { const err = new Error('ERR_PART_NOT_FOUND') @@ -68,6 +68,22 @@ async function createWorkOrder (ctx, req) { ts: Date.now(), user: voter }] + } else if (type === WORK_ORDER_TYPES.MOVE) { + const part = await _resolvePartByIdentifier(ctx, deviceIdentifier) + if (!part) { + const err = new Error('ERR_PART_NOT_FOUND') + err.statusCode = 400 + throw err + } + info.partsMoves = [{ + partId: part.id, + partCode: part.code, + fromLocation: part.info?.location ?? null, + toLocation: info.location ?? null, + role: 'move', + ts: Date.now(), + user: voter + }] } return submitWorkOrderAction(ctx, req, 'registerThing', { info }) @@ -79,7 +95,7 @@ async function updateWorkOrder (ctx, req) { } async function closeWorkOrder (ctx, req) { - const info = { status: 'closed' } + const info = { status: 'closed', closedAt: Date.now() } if (req.body?.finalResult) info.finalResult = req.body.finalResult return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info }) } @@ -206,6 +222,22 @@ async function exportWorkOrder (ctx, req, rep) { return rep.send(renderWorkOrderCsv(wo)) } +async function exportWorkOrdersRma (ctx, req, rep) { + const ids = req.query.ids.split(',').map(s => s.trim()).filter(Boolean) + const params = { + query: { + type: WORK_ORDER_THING_TYPE, + $or: [{ id: { $in: ids } }, { code: { $in: ids } }] + } + } + const results = await ctx.dataProxy.requestData('listThings', params) + const wos = flattenRpcResults(results).filter(wo => wo?.info?.type === WORK_ORDER_TYPES.MICROBT_MINER) + + rep.header('content-type', 'text/csv; charset=utf-8') + rep.header('content-disposition', 'attachment; filename="rma.csv"') + return rep.send(renderRmaCsv(wos)) +} + async function getWorkOrderAudit (ctx, req) { const payload = { logType: 'info', @@ -229,5 +261,6 @@ module.exports = { assignWorkOrder, appendWorkLogEntry, getWorkOrderAudit, - exportWorkOrder + exportWorkOrder, + exportWorkOrdersRma } diff --git a/workers/lib/server/lib/routeHelpers.js b/workers/lib/server/lib/routeHelpers.js index 154ff27..a6d083f 100644 --- a/workers/lib/server/lib/routeHelpers.js +++ b/workers/lib/server/lib/routeHelpers.js @@ -26,7 +26,7 @@ function createAuthHandler (ctx, handler) { function createAuthOnRequest (ctx, perms = null) { return async (req, rep) => { await authCheck(ctx, req, rep) - if (perms) { + if (perms && !ctx.noAuth) { await capCheck(ctx, req, rep, perms) } } diff --git a/workers/lib/server/lib/work.order.export.js b/workers/lib/server/lib/work.order.export.js index df6b8ad..5acc1b0 100644 --- a/workers/lib/server/lib/work.order.export.js +++ b/workers/lib/server/lib/work.order.export.js @@ -1,6 +1,7 @@ 'use strict' const { csvEscape } = require('../../utils') +const { RMA_COLUMNS } = require('../../constants') function renderWorkOrderCsv (wo) { const { partsMoves, ...woFields } = wo.info || {} @@ -25,4 +26,30 @@ function renderWorkOrderCsv (wo) { return lines.join('\r\n') + '\r\n' } -module.exports = { renderWorkOrderCsv } +function renderRmaCsv (workOrders) { + const rows = workOrders.map((wo) => { + const info = wo.info || {} + const moves = Array.isArray(info.partsMoves) ? info.partsMoves : [] + const repaired = moves.find(m => m.role === 'repaired') || moves.find(m => m.role === 'diagnosis') || moves[0] || {} + const replaced = moves.find(m => m.role === 'replacement') || repaired + const repairTs = info.closedAt ?? info.createdAt + return [ + wo.code, + info.deviceModel, + info.deviceIdentifier, + repaired.partCode, + replaced.partCode, + info.issue, + info.finalResult, + info.remarks, + info.deviceModel, + repairTs ? new Date(repairTs).toISOString().slice(0, 10) : '', + info.assignedTo ?? info.createdBy + ] + }) + + const lines = [RMA_COLUMNS, ...rows].map(row => row.map(csvEscape).join(',')) + return lines.join('\r\n') + '\r\n' +} + +module.exports = { renderWorkOrderCsv, renderRmaCsv } diff --git a/workers/lib/server/routes/work.orders.routes.js b/workers/lib/server/routes/work.orders.routes.js index cf95210..a9f9e9a 100644 --- a/workers/lib/server/routes/work.orders.routes.js +++ b/workers/lib/server/routes/work.orders.routes.js @@ -16,7 +16,8 @@ const { assignWorkOrder, appendWorkLogEntry, getWorkOrderAudit, - exportWorkOrder + exportWorkOrder, + exportWorkOrdersRma } = require('../handlers/work.orders.handlers') const { createAuthRoute, createCachedAuthRoute } = require('../lib/routeHelpers') const { stableJsonString } = require('../../utils') @@ -114,5 +115,11 @@ module.exports = (ctx) => [ url: ENDPOINTS.WORK_ORDER_EXPORT, schema: schemas.export, ...createAuthRoute(ctx, exportWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.WORK_ORDER_EXPORT_RMA, + schema: schemas.exportRma, + ...createAuthRoute(ctx, exportWorkOrdersRma, [AUTH_PERMISSIONS.WORK_ORDER]) } ] diff --git a/workers/lib/server/schemas/work.orders.schemas.js b/workers/lib/server/schemas/work.orders.schemas.js index f0858e8..adba7b1 100644 --- a/workers/lib/server/schemas/work.orders.schemas.js +++ b/workers/lib/server/schemas/work.orders.schemas.js @@ -1,6 +1,6 @@ 'use strict' -const types = { type: 'integer', enum: [1, 2] } +const types = { type: 'integer', enum: [1, 2, 3, 4] } const warranty = { type: ['object', 'null'], @@ -34,7 +34,7 @@ const create = { } } }, - if: { properties: { type: { const: 2 } } }, + if: { properties: { type: { enum: [3, 4] } } }, then: { required: ['issue'] } } } @@ -162,4 +162,15 @@ const exportRoute = { } } -module.exports = { create, list, byId, update, close, cancel, assign, audit, log, export: exportRoute } +const exportRma = { + querystring: { + type: 'object', + required: ['ids'], + additionalProperties: false, + properties: { + ids: { type: 'string', minLength: 1, maxLength: 4000 } + } + } +} + +module.exports = { create, list, byId, update, close, cancel, assign, audit, log, export: exportRoute, exportRma } From 929f27cb9f1218a724c9f1f69d1a278753b2f8d9 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:23:40 +0530 Subject: [PATCH 61/63] feat(alerts): filter & search alerts by device tag; fix site deviceId filter (#97) - expose `message` (device/equipment tag) on historical alerts and whitelist it for filter+search on both site and history endpoints - expose `deviceId` on site alerts so the existing deviceId filter works (alert objects previously only carried `id`); aligns site with history - add a Postman collection for the alerts endpoints --- tests/unit/handlers/alerts.handlers.test.js | 137 ++++++++++++++++++ workers/lib/constants.js | 10 +- .../lib/server/handlers/alerts.handlers.js | 2 + 3 files changed, 145 insertions(+), 4 deletions(-) diff --git a/tests/unit/handlers/alerts.handlers.test.js b/tests/unit/handlers/alerts.handlers.test.js index f6560c5..55d767e 100644 --- a/tests/unit/handlers/alerts.handlers.test.js +++ b/tests/unit/handlers/alerts.handlers.test.js @@ -521,3 +521,140 @@ test('getAlertsHistory - throws on invalid date range', async (t) => { t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw date range error') } }) + +// ==================== device tag (message) — filter & search ==================== + +test('extractAlertsFromThings - preserves message (device tag)', (t) => { + const things = [ + { + id: 'dcs-1', + type: 'dcs-siemens', + code: 'PCS7', + info: { container: 'cont-A' }, + last: { + alerts: [ + { severity: 'warning', name: 'flow_warning', message: 'FIT-7513', description: 'Cooling-loop flow below warning threshold — FIT-7513: 320m³/h (threshold 330m³/h)' } + ] + } + } + ] + + const result = extractAlertsFromThings(things) + t.is(result[0].message, 'FIT-7513', 'should preserve the device tag in message') +}) + +test('flattenHistoryAlert - preserves message (device tag)', (t) => { + const alert = { + name: 'flow_warning', + description: 'Cooling-loop flow below warning threshold — FIT-7513: 320m³/h (threshold 330m³/h)', + severity: 'warning', + uuid: 'abc', + createdAt: 1000, + message: 'FIT-7513', + thing: { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', tags: ['siemens'], info: { container: 'cont-A' } } + } + + const result = flattenHistoryAlert(alert) + t.is(result.message, 'FIT-7513', 'should expose the device tag in the history payload') +}) + +const dcsThings = () => [ + { + id: 'dcs-1', + type: 'dcs-siemens', + code: 'PCS7', + info: { container: 'cont-A' }, + last: { + alerts: [ + { severity: 'warning', name: 'flow_warning', message: 'FIT-7513' }, + { severity: 'critical', name: 'flow_alarm', message: 'FIT-7514' } + ] + } + } +] + +test('extractAlertsFromThings - exposes deviceId (alias of thing id)', (t) => { + const things = [ + { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', info: {}, last: { alerts: [{ severity: 'high', name: 'flow_alarm' }] } } + ] + const result = extractAlertsFromThings(things) + t.is(result[0].deviceId, 'dcs-1', 'should expose deviceId so the deviceId filter works') + t.is(result[0].id, 'dcs-1', 'should keep id for backward compatibility') +}) + +test('getSiteAlerts - filters by deviceId', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => [ + { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', info: { container: 'cont-A' }, last: { alerts: [{ severity: 'high', name: 'a1' }] } }, + { id: 'miner-9', type: 'miner', code: 'S19', info: { container: 'cont-B' }, last: { alerts: [{ severity: 'low', name: 'a2' }] } } + ]) + const mockReq = { query: { filter: JSON.stringify({ deviceId: 'dcs-1' }) } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 1, 'should filter to the one device') + t.is(result.alerts[0].deviceId, 'dcs-1', 'should return only the dcs-1 alert') +}) + +test('getSiteAlerts - filters by exact device tag (message)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsThings()) + const mockReq = { query: { filter: JSON.stringify({ message: 'FIT-7513' }) } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 1, 'should filter to the one matching tag') + t.is(result.alerts[0].message, 'FIT-7513', 'should return the FIT-7513 alert') +}) + +test('getSiteAlerts - filters by multiple device tags (array)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsThings()) + const mockReq = { query: { filter: JSON.stringify({ message: ['FIT-7513', 'FIT-7514'] }) } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 2, 'should match both tags') +}) + +test('getSiteAlerts - searches by device tag (message)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsThings()) + const mockReq = { query: { search: 'fit-7514' } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 1, 'should match one alert by tag substring (case-insensitive)') + t.is(result.alerts[0].message, 'FIT-7514', 'should return the FIT-7514 alert') +}) + +const dcsHistory = () => [ + { + uuid: 'h1', + createdAt: 1000, + severity: 'warning', + name: 'flow_warning', + description: 'Cooling-loop flow below warning threshold — FIT-7513: 320m³/h (threshold 330m³/h)', + message: 'FIT-7513', + thing: { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', tags: ['siemens'], info: { container: 'cont-A' } } + }, + { + uuid: 'h2', + createdAt: 2000, + severity: 'critical', + name: 'flow_alarm', + description: 'Cooling-loop flow below alarm threshold — FIT-7514: 295m³/h (threshold 300m³/h)', + message: 'FIT-7514', + thing: { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', tags: ['siemens'], info: { container: 'cont-A' } } + } +] + +test('getAlertsHistory - filters by exact device tag (message)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsHistory()) + const mockReq = { query: { start: 1, end: 5000, filter: JSON.stringify({ message: 'FIT-7514' }) } } + + const result = await getAlertsHistory(mockCtx, mockReq) + t.is(result.total, 1, 'should filter history to the matching tag') + t.is(result.alerts[0].message, 'FIT-7514', 'should return the FIT-7514 history alert') +}) + +test('getAlertsHistory - searches by device tag (message)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsHistory()) + const mockReq = { query: { start: 1, end: 5000, search: 'FIT-7513' } } + + const result = await getAlertsHistory(mockCtx, mockReq) + 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') +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index a780a7f..75aafdb 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -310,11 +310,13 @@ const ALERTS_DEFAULT_LIMIT = 100 const ALERTS_MAX_SITE_LIMIT = 200 const ALERTS_MAX_HISTORY_LIMIT = 1000 -const SITE_ALERTS_FILTER_FIELDS = ['severity', 'type', 'container', 'deviceId'] -const SITE_ALERTS_SEARCH_FIELDS = ['id', 'code', 'container'] +// `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 HISTORY_FILTER_FIELDS = ['severity', 'code', 'deviceType', 'container', 'deviceId', 'tags'] -const HISTORY_SEARCH_FIELDS = ['name', 'description', 'position', 'code'] +const HISTORY_FILTER_FIELDS = ['severity', 'code', 'deviceType', 'container', 'deviceId', 'tags', 'message'] +const HISTORY_SEARCH_FIELDS = ['name', 'description', 'position', 'code', 'message'] const POOL_ALERT_TYPES = [ 'all_pools_dead', diff --git a/workers/lib/server/handlers/alerts.handlers.js b/workers/lib/server/handlers/alerts.handlers.js index 7e864a7..9537a5a 100644 --- a/workers/lib/server/handlers/alerts.handlers.js +++ b/workers/lib/server/handlers/alerts.handlers.js @@ -22,6 +22,7 @@ function extractAlertsFromThings (things) { alerts.push({ ...alert, id: thing.id, + deviceId: thing.id, type: thing.type, code: thing.code, container: thing.info?.container @@ -79,6 +80,7 @@ function flattenHistoryAlert (entry) { severity: entry.severity, createdAt: entry.createdAt, uuid: entry.uuid, + message: entry.message, deviceId: thing.id, deviceType: thing.type, code: thing.code, From 6171ee15f40a583d28d33da74b2cb51b8feafb52 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 18 Jun 2026 15:10:08 +0300 Subject: [PATCH 62/63] Feat: batch create endpoints for work orders and spare parts (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: batch create endpoints for work orders and spare parts * refactor: use WORK_ORDER_TYPES constants in work order schemas Replace magic enum values [3, 4] and [1, 2, 3, 4] with the existing WORK_ORDER_TYPES constant, addressing PR #98 review feedback. * Feat: move work order relocates the device atomically A type-2 (move) work order auto-closes on creation, so it must perform the relocation itself — otherwise the closed WO records a move that never happened. createWorkOrder and createWorkOrdersBatch reuse submitWorkOrderAction (now taking an optional rackId) to push an updateThing to the part's own rack before recording the WO. Claude-Session: https://claude.ai/code/session_01Hk3GYz4TPiRmSs3Qmvv27V --- .../handlers/spare.parts.handlers.test.js | 52 +++++++++ .../handlers/work.orders.handlers.test.js | 109 ++++++++++++++++++ workers/lib/constants.js | 2 + .../server/handlers/spare.parts.handlers.js | 94 ++++++++++++++- .../server/handlers/work.orders.handlers.js | 74 ++++++++++++ workers/lib/server/lib/work.orders.js | 4 +- .../lib/server/routes/spare.parts.routes.js | 8 +- .../lib/server/routes/work.orders.routes.js | 7 ++ .../lib/server/schemas/spare.parts.schemas.js | 26 ++++- .../lib/server/schemas/work.orders.schemas.js | 50 +++++++- 10 files changed, 418 insertions(+), 8 deletions(-) diff --git a/tests/unit/handlers/spare.parts.handlers.test.js b/tests/unit/handlers/spare.parts.handlers.test.js index 0a0bb43..66da474 100644 --- a/tests/unit/handlers/spare.parts.handlers.test.js +++ b/tests/unit/handlers/spare.parts.handlers.test.js @@ -231,6 +231,58 @@ test('handlers: registerSparePart surfaces ork-side errors in the response', asy t.is(out.partActionId, null) }) +test('handlers: registerSparePartsBatch creates one shared register WO carrying every part and attaches the note', async (t) => { + const { ctx, pushed } = buildRegisterCtx() + const out = await handlers.registerSparePartsBatch(ctx, { + ...userMeta(), + body: { + rackId: PART_RACK, + note: 'Pallets 1-3', + parts: [ + { deviceType: 'psu', deviceModel: 'PSU-A', serialNum: 'SN-1' }, + { deviceType: 'psu', deviceModel: 'PSU-A', serialNum: 'SN-2' }, + { deviceType: 'psu', deviceModel: 'PSU-A', serialNum: 'SN-3' } + ] + } + }) + + t.is(pushed.length, 4, 'three part actions + one shared WO action') + const woAction = pushed.find(p => p.params[0].rackId === WO_RACK) + const partActions = pushed.filter(p => p.params[0].rackId === PART_RACK) + t.is(partActions.length, 3, 'one registerThing per part') + + const woInfo = woAction.params[0].info + t.is(woInfo.type, 1, 'single Type-1 register WO') + t.is(woInfo.deviceCount, 3) + t.is(woInfo.partsMoves.length, 3, 'register WO carries every part') + t.is(woInfo.note, 'Pallets 1-3', 'note recorded on the register WO') + t.alike(woInfo.partsMoves.map(m => m.partId).sort(), out.parts.map(p => p.partId).sort(), 'WO links every returned part') + + for (const pa of partActions) { + t.is(pa.params[0].info.note, 'Pallets 1-3', 'note attached to each registered part') + } + t.is(out.parts.length, 3, 'returns a result row per part') + t.alike(out.errors, [], 'no errors on happy path') +}) + +test('handlers: registerSparePartsBatch rejects the whole batch if any part is invalid', async (t) => { + const { ctx, pushed } = buildRegisterCtx() + await t.exception( + () => handlers.registerSparePartsBatch(ctx, { + ...userMeta(), + body: { + rackId: PART_RACK, + parts: [ + { deviceType: 'psu', deviceModel: 'PSU-A', serialNum: 'SN-1' }, + { deviceType: 'psu', deviceModel: 'PSU-A' } + ] + } + }), + /ERR_SERIAL_NUM_REQUIRED/ + ) + t.is(pushed.length, 0, 'nothing pushed when any part fails validation') +}) + function listFlow ({ items = [], total = 0 } = {}) { let lastList, lastCount const handler = async (_key, method, params) => { diff --git a/tests/unit/handlers/work.orders.handlers.test.js b/tests/unit/handlers/work.orders.handlers.test.js index d5248d5..de3215d 100644 --- a/tests/unit/handlers/work.orders.handlers.test.js +++ b/tests/unit/handlers/work.orders.handlers.test.js @@ -67,6 +67,52 @@ test('handlers: createWorkOrder Type 2 (move) seeds a move parts-move with from/ t.is(move.toLocation, 'site.warehouse') }) +test('handlers: createWorkOrder Type 2 (move) relocates the part on its own rack', async (t) => { + const pushed = [] + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async (_k, method, params) => { + if (method === 'pushAction') { pushed.push(params); return { id: 'a', errors: [] } } + if (method === 'listThings') return [{ id: 'part-1', type: 'inventory-miner_part-psu', rack: 'psu-rack-1', info: { location: 'site.lab' } }] + return null + }) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = RACK + await handlers.createWorkOrder(ctx, { + ...userMeta(), + body: { type: 2, deviceType: 'psu', deviceModel: 'P', deviceIdentifier: 'SN-1', info: { location: 'site.warehouse' } } + }) + const partPush = pushed.find(p => p.action === 'updateThing') + t.is(partPush.params[0].rackId, 'psu-rack-1', 'relocation targets the part rack') + t.is(partPush.params[0].info.location, 'site.warehouse', 'part moved to the destination') +}) + +test('handlers: createWorkOrdersBatch Type 2 (move) relocates every part', async (t) => { + const pushed = [] + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async (_k, method, params) => { + if (method === 'pushAction') { pushed.push(params); return { id: 'a', errors: [] } } + if (method === 'listThings') { + const sn = (params.query?.$or || []).map(c => c['info.serialNum']).find(Boolean) + return [{ id: sn, type: 'inventory-miner_part-psu', rack: 'psu-rack-1', info: { location: 'site.warehouse' } }] + } + return null + }) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = RACK + await handlers.createWorkOrdersBatch(ctx, { + ...userMeta(), + body: { + type: 2, + devices: [ + { deviceType: 'psu', deviceModel: 'P', deviceIdentifier: 'SN-1' }, + { deviceType: 'psu', deviceModel: 'P', deviceIdentifier: 'SN-2' } + ], + info: { location: 'site.miner-room' } + } + }) + const partPushes = pushed.filter(p => p.action === 'updateThing') + t.is(partPushes.length, 2, 'one relocation per device') + t.is(partPushes[0].params[0].info.location, 'site.miner-room') +}) + test('handlers: createWorkOrder merges info.notes, info.remarks, info.site, info.location into thing info', async (t) => { const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'SN-1' } }] }) await handlers.createWorkOrder(flow.ctx, { @@ -115,6 +161,69 @@ test('handlers: createWorkOrder 400s ERR_PART_NOT_FOUND when deviceIdentifier re ) }) +test('handlers: createWorkOrdersBatch builds one WO with a parts-move per device, first device as summary', async (t) => { + const parts = [ + { id: 'part-1', code: 'WMM-1', type: 'inventory-miner_part-controller', info: { serialNum: 'WMM63S-2024-04829', location: 'site.warehouse' } }, + { id: 'part-2', code: 'WMM-2', type: 'inventory-miner_part-controller', info: { serialNum: 'WMM63S-2024-04830', location: 'site.warehouse' } }, + { id: 'part-3', code: 'WMM-3', type: 'inventory-miner_part-controller', info: { serialNum: 'WMM63S-2024-04831', location: 'site.warehouse' } } + ] + let lastPush + const handler = async (_key, method, params) => { + if (method === 'pushAction') { lastPush = params; return { id: 'action-1', errors: [] } } + if (method === 'listThings') { + const or = params.query?.$or || [] + const sn = or.map(c => c.id || c.code || c['info.serialNum'] || c['info.macAddress']).find(Boolean) + return parts.filter(p => p.info.serialNum === sn) + } + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = RACK + + await handlers.createWorkOrdersBatch(ctx, { + ...userMeta(), + body: { + type: 2, + devices: [ + { deviceType: 'miner', deviceModel: 'whatsminer-m63s', deviceIdentifier: 'WMM63S-2024-04829' }, + { deviceType: 'miner', deviceModel: 'whatsminer-m63s', deviceIdentifier: 'WMM63S-2024-04830' }, + { deviceType: 'miner', deviceModel: 'whatsminer-m63s', deviceIdentifier: 'WMM63S-2024-04831' } + ], + info: { location: 'site.miner-room' } + } + }) + + const info = lastPush.params[0].info + t.is(lastPush.action, 'registerThing') + t.is(info.deviceCount, 3, 'records device count for the scope badge') + t.is(info.deviceIdentifier, 'WMM63S-2024-04829', 'first device is the summary identifier') + t.is(info.partsMoves.length, 3, 'one parts-move per device') + t.alike(info.partsMoves.map(m => m.deviceIdentifier), ['WMM63S-2024-04829', 'WMM63S-2024-04830', 'WMM63S-2024-04831']) + t.alike(info.partsMoves.map(m => m.partId), ['part-1', 'part-2', 'part-3'], 'each move resolves its own part') + t.is(info.partsMoves[0].role, 'move') + t.is(info.partsMoves[0].fromLocation, 'site.warehouse') + t.is(info.partsMoves[0].toLocation, 'site.miner-room', 'all moved to the WO target location') +}) + +test('handlers: createWorkOrdersBatch rejects the whole batch if any device type is invalid', async (t) => { + const flow = buildSubmitFlow({ parts: [{ id: 'p', code: 'c', type: 'inventory-miner_part-psu', info: { serialNum: 'SN-1' } }] }) + await t.exception( + () => handlers.createWorkOrdersBatch(flow.ctx, { + ...userMeta(), + body: { + type: 2, + devices: [ + { deviceType: 'miner', deviceModel: 'm', deviceIdentifier: 'SN-1' }, + { deviceType: 'cooling', deviceModel: 'm', deviceIdentifier: 'SN-2' } + ] + } + }), + /ERR_INVALID_DEVICE_TYPE/ + ) + t.absent(flow.lastPush, 'nothing pushed when validation fails') +}) + test('handlers: updateWorkOrder forwards warranty payload to updateThing', async (t) => { const flow = buildSubmitFlow() await handlers.updateWorkOrder(flow.ctx, { diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 75aafdb..b6b4e5f 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -199,6 +199,7 @@ const ENDPOINTS = { ENERGY_AVAILABLE: '/auth/energy/available', // Work Order endpoints WORK_ORDERS: '/auth/work-orders', + WORK_ORDERS_BATCH: '/auth/work-orders/batch', WORK_ORDER_BY_ID: '/auth/work-orders/:id', WORK_ORDER_AUDIT: '/auth/work-orders/:id/audit', WORK_ORDER_LOG: '/auth/work-orders/:id/log', @@ -209,6 +210,7 @@ const ENDPOINTS = { WORK_ORDER_CANCEL: '/auth/work-orders/:id/cancel', // Spare Part endpoints SPARE_PARTS: '/auth/spare-parts', + SPARE_PARTS_BATCH: '/auth/spare-parts/batch', SPARE_PART_BY_ID: '/auth/spare-parts/:id', SPARE_PART_REPAIR_HISTORY: '/auth/spare-parts/:id/repair-history', // Work Order export diff --git a/workers/lib/server/handlers/spare.parts.handlers.js b/workers/lib/server/handlers/spare.parts.handlers.js index 8e07209..2817f56 100644 --- a/workers/lib/server/handlers/spare.parts.handlers.js +++ b/workers/lib/server/handlers/spare.parts.handlers.js @@ -212,6 +212,98 @@ async function registerSparePart (ctx, req) { } } +// Batch sibling of registerSparePart: N parts + one shared Type-1 register WO, with an optional note. +async function registerSparePartsBatch (ctx, req) { + const { rackId, parts, note } = req.body + + for (const part of parts) { + if (!WORK_ORDER_VALID_DEVICE_TYPES.includes(part.deviceType)) { + const err = new Error('ERR_INVALID_DEVICE_TYPE') + err.statusCode = 400 + throw err + } + if (!part.deviceModel || typeof part.deviceModel !== 'string') { + const err = new Error('ERR_DEVICE_MODEL_REQUIRED') + err.statusCode = 400 + throw err + } + if (!part.serialNum || typeof part.serialNum !== 'string') { + const err = new Error('ERR_SERIAL_NUM_REQUIRED') + err.statusCode = 400 + throw err + } + } + + const workOrderRackId = await getWorkOrderRackId(ctx) + const voter = req._info.user.metadata.email + const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) + const authPerms = permissions || [] + const ts = Date.now() + + // Assign every part id up front so the single shared register WO can reference them all. + const prepared = parts.map((part) => { + const partInfo = { + ...part, + location: part.location ?? SPARE_PART_INITIAL_LOCATION + } + if (note) partInfo.note = note + return { partId: randomUUID(), part, partInfo } + }) + + const woId = randomUUID() + const [summary] = prepared + const woInfo = { + type: WORK_ORDER_TYPES.REGISTER, + deviceType: summary.part.deviceType, + deviceModel: summary.part.deviceModel, + deviceIdentifier: summary.part.serialNum, + deviceCount: prepared.length, + createdBy: voter, + createdAt: ts, + partsMoves: prepared.map(({ partId, part }) => ({ + partId, + deviceType: part.deviceType, + deviceModel: part.deviceModel, + deviceIdentifier: part.serialNum, + fromLocation: null, + toLocation: SPARE_PART_INITIAL_LOCATION, + role: 'register', + ts, + user: voter + })) + } + if (note) woInfo.note = note + + const pushSingleAction = (rack, id, info) => ctx.dataProxy.requestData('pushAction', { + action: 'registerThing', + query: { rack }, + params: [{ rackId: rack, id, info }], + voter, + authPerms + }, (res, arr) => { + if (res?.error) arr.push({ id: null, errors: [res.error] }) + else arr.push(res) + }) + + const [woResults, ...partResultsList] = await Promise.all([ + pushSingleAction(workOrderRackId, woId, woInfo), + ...prepared.map(({ partId, partInfo }) => pushSingleAction(rackId, partId, partInfo)) + ]) + + const partsOut = prepared.map(({ partId }, i) => ({ + partId, + partActionId: partResultsList[i].find(r => r?.id)?.id ?? null + })) + + return { + parts: partsOut, + workOrderId: woId, + workOrderActionId: woResults.find(r => r?.id)?.id ?? null, + errors: [..._pushErrors(woResults), ...partResultsList.flatMap(_pushErrors)], + expectedActionLatencyMs: ctx.conf?.expectedActionLatencyMs ?? 1000 + } +} + function _buildSparePartQuery (qs) { const query = qs.query ? parseJsonQueryParam(qs.query, 'ERR_QUERY_INVALID_JSON') @@ -267,4 +359,4 @@ async function getRepairHistory (ctx, req) { } } -module.exports = { registerSparePart, listSpareParts, updateSparePart, getRepairHistory } +module.exports = { registerSparePart, registerSparePartsBatch, listSpareParts, updateSparePart, getRepairHistory } diff --git a/workers/lib/server/handlers/work.orders.handlers.js b/workers/lib/server/handlers/work.orders.handlers.js index 196dd8a..7e2ace9 100644 --- a/workers/lib/server/handlers/work.orders.handlers.js +++ b/workers/lib/server/handlers/work.orders.handlers.js @@ -84,11 +84,84 @@ async function createWorkOrder (ctx, req) { ts: Date.now(), user: voter }] + // Move WOs auto-close, so the relocation has to happen here or it never will. + if (info.location != null) await submitWorkOrderAction(ctx, req, 'updateThing', { id: part.id, info: { location: info.location } }, part.rack) } return submitWorkOrderAction(ctx, req, 'registerThing', { info }) } +function _buildPartsMove (type, part, device, info, voter, ts) { + const base = { + partId: part.id, + partCode: part.code, + deviceType: device.deviceType, + deviceModel: device.deviceModel, + deviceIdentifier: device.deviceIdentifier, + ts, + user: voter + } + if (type === WORK_ORDER_TYPES.MICROBT_MINER || type === WORK_ORDER_TYPES.MICROBT_NON_MINER) { + return { ...base, role: 'diagnosis' } + } + if (type === WORK_ORDER_TYPES.REGISTER) { + return { ...base, role: 'register', fromLocation: null, toLocation: SPARE_PART_INITIAL_LOCATION } + } + if (type === WORK_ORDER_TYPES.MOVE) { + return { ...base, role: 'move', fromLocation: part.info?.location ?? null, toLocation: info.location ?? null } + } + return null +} + +// Batch sibling of createWorkOrder: one work order whose partsMoves carries every device. +async function createWorkOrdersBatch (ctx, req) { + const { type, devices, info: extraInfo, ...rest } = req.body + + for (const device of devices) { + if (!WORK_ORDER_VALID_DEVICE_TYPES.includes(device.deviceType)) { + const err = new Error('ERR_INVALID_DEVICE_TYPE') + err.statusCode = 400 + throw err + } + } + + const voter = req._info.user.metadata.email + const ts = Date.now() + const [summary] = devices + + // First device is the summary used by the thing-side validator, RMA export, and single-device views. + const info = { + type, + ...rest, + ...extraInfo, + deviceType: summary.deviceType, + deviceModel: summary.deviceModel, + deviceIdentifier: summary.deviceIdentifier, + deviceCount: devices.length, + createdBy: voter, + createdAt: ts + } + + const partsMoves = [] + for (const device of devices) { + const part = await _resolvePartByIdentifier(ctx, device.deviceIdentifier) + if (!part) { + const err = new Error('ERR_PART_NOT_FOUND') + err.statusCode = 400 + throw err + } + const move = _buildPartsMove(type, part, device, info, voter, ts) + if (move) partsMoves.push(move) + // Move WOs auto-close, so relocate each part here or it never happens. + if (type === WORK_ORDER_TYPES.MOVE && info.location != null) { + await submitWorkOrderAction(ctx, req, 'updateThing', { id: part.id, info: { location: info.location } }, part.rack) + } + } + info.partsMoves = partsMoves + + return submitWorkOrderAction(ctx, req, 'registerThing', { info }) +} + async function updateWorkOrder (ctx, req) { const { info: extraInfo, ...body } = req.body return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info: { ...body, ...extraInfo } }) @@ -253,6 +326,7 @@ async function getWorkOrderAudit (ctx, req) { module.exports = { createWorkOrder, + createWorkOrdersBatch, listWorkOrders, getWorkOrder, updateWorkOrder, diff --git a/workers/lib/server/lib/work.orders.js b/workers/lib/server/lib/work.orders.js index 6a0127a..291139b 100644 --- a/workers/lib/server/lib/work.orders.js +++ b/workers/lib/server/lib/work.orders.js @@ -14,8 +14,8 @@ async function getWorkOrderRackId (ctx) { return rack.id } -async function submitWorkOrderAction (ctx, req, action, paramObj) { - const rackId = await getWorkOrderRackId(ctx) +async function submitWorkOrderAction (ctx, req, action, paramObj, rackId) { + rackId = rackId || await getWorkOrderRackId(ctx) const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) return ctx.dataProxy.requestData('pushAction', { diff --git a/workers/lib/server/routes/spare.parts.routes.js b/workers/lib/server/routes/spare.parts.routes.js index cc28933..3b80d27 100644 --- a/workers/lib/server/routes/spare.parts.routes.js +++ b/workers/lib/server/routes/spare.parts.routes.js @@ -6,7 +6,7 @@ const { AUTH_PERMISSIONS } = require('../../constants') const schemas = require('../schemas/spare.parts.schemas') -const { registerSparePart, listSpareParts, updateSparePart, getRepairHistory } = require('../handlers/spare.parts.handlers') +const { registerSparePart, registerSparePartsBatch, listSpareParts, updateSparePart, getRepairHistory } = require('../handlers/spare.parts.handlers') const { createAuthRoute, createCachedAuthRoute } = require('../lib/routeHelpers') const { stableJsonString } = require('../../utils') @@ -17,6 +17,12 @@ module.exports = (ctx) => [ schema: schemas.register, ...createAuthRoute(ctx, registerSparePart, [AUTH_PERMISSIONS.INVENTORY]) }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.SPARE_PARTS_BATCH, + schema: schemas.registerBatch, + ...createAuthRoute(ctx, registerSparePartsBatch, [AUTH_PERMISSIONS.INVENTORY]) + }, { method: HTTP_METHODS.GET, url: ENDPOINTS.SPARE_PARTS, diff --git a/workers/lib/server/routes/work.orders.routes.js b/workers/lib/server/routes/work.orders.routes.js index a9f9e9a..9f73541 100644 --- a/workers/lib/server/routes/work.orders.routes.js +++ b/workers/lib/server/routes/work.orders.routes.js @@ -8,6 +8,7 @@ const { const schemas = require('../schemas/work.orders.schemas') const { createWorkOrder, + createWorkOrdersBatch, listWorkOrders, getWorkOrder, updateWorkOrder, @@ -29,6 +30,12 @@ module.exports = (ctx) => [ schema: schemas.create, ...createAuthRoute(ctx, createWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.WORK_ORDERS_BATCH, + schema: schemas.createBatch, + ...createAuthRoute(ctx, createWorkOrdersBatch, [AUTH_PERMISSIONS.WORK_ORDER]) + }, { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDERS, diff --git a/workers/lib/server/schemas/spare.parts.schemas.js b/workers/lib/server/schemas/spare.parts.schemas.js index 69dea30..b5c59f1 100644 --- a/workers/lib/server/schemas/spare.parts.schemas.js +++ b/workers/lib/server/schemas/spare.parts.schemas.js @@ -17,6 +17,30 @@ const register = { } } +// Batch variant of `register`: many parts in one request, plus an optional operator note. +const registerBatch = { + body: { + type: 'object', + required: ['rackId', 'parts'], + additionalProperties: false, + properties: { + rackId: { type: 'string', minLength: 1 }, + parts: { + type: 'array', + minItems: 1, + maxItems: 100, + items: { + type: 'object', + additionalProperties: true, + minProperties: 1, + required: ['deviceType'] + } + }, + note: { type: 'string', maxLength: 4000 } + } + } +} + const update = { params: { type: 'object', @@ -74,4 +98,4 @@ const list = { } } -module.exports = { register, list, update, repairHistory } +module.exports = { register, registerBatch, list, update, repairHistory } diff --git a/workers/lib/server/schemas/work.orders.schemas.js b/workers/lib/server/schemas/work.orders.schemas.js index adba7b1..e7615e7 100644 --- a/workers/lib/server/schemas/work.orders.schemas.js +++ b/workers/lib/server/schemas/work.orders.schemas.js @@ -1,6 +1,8 @@ 'use strict' -const types = { type: 'integer', enum: [1, 2, 3, 4] } +const { WORK_ORDER_TYPES } = require('../../constants') + +const types = { type: 'integer', enum: Object.values(WORK_ORDER_TYPES) } const warranty = { type: ['object', 'null'], @@ -34,7 +36,49 @@ const create = { } } }, - if: { properties: { type: { enum: [3, 4] } } }, + if: { properties: { type: { enum: [WORK_ORDER_TYPES.MICROBT_MINER, WORK_ORDER_TYPES.MICROBT_NON_MINER] } } }, + then: { required: ['issue'] } + } +} + +// Batch variant of `create`: one work order carrying many devices. +const createBatch = { + body: { + type: 'object', + required: ['type', 'devices'], + additionalProperties: false, + properties: { + type: types, + devices: { + type: 'array', + minItems: 1, + maxItems: 100, + items: { + type: 'object', + required: ['deviceType', 'deviceModel', 'deviceIdentifier'], + additionalProperties: false, + properties: { + deviceType: { type: 'string', minLength: 1, maxLength: 100 }, + deviceModel: { type: 'string', minLength: 1, maxLength: 100 }, + deviceIdentifier: { type: 'string', minLength: 1, maxLength: 200 } + } + } + }, + issue: { type: 'string', minLength: 1, maxLength: 2000 }, + assignedTo: { type: ['string', 'null'], maxLength: 200 }, + warranty, + info: { + type: 'object', + additionalProperties: false, + properties: { + notes: { type: 'string', maxLength: 4000 }, + remarks: { type: 'string', maxLength: 4000 }, + site: { type: 'string', maxLength: 200 }, + location: { type: 'string', maxLength: 200 } + } + } + }, + if: { properties: { type: { enum: [WORK_ORDER_TYPES.MICROBT_MINER, WORK_ORDER_TYPES.MICROBT_NON_MINER] } } }, then: { required: ['issue'] } } } @@ -173,4 +217,4 @@ const exportRma = { } } -module.exports = { create, list, byId, update, close, cancel, assign, audit, log, export: exportRoute, exportRma } +module.exports = { create, createBatch, list, byId, update, close, cancel, assign, audit, log, export: exportRoute, exportRma } From 4f5c0e8a17f249404b345a762629bb9e7f6e7078 Mon Sep 17 00:00:00 2001 From: Parag More <34959548+paragmore@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:48:01 +0530 Subject: [PATCH 63/63] feat(cooling): redesigned Operations Centre cooling/energy data (#100) cooling-system: explicit fan-coil valve_tag/temperature_tag map; binary cooling_towers[].vibration_switch (replaces numeric vibration); HVAC C1 control_valves.pressure_bypass.pressure (PIT-7501); miners C1 lines[].differential_pressure[] plus summary inlet/outlet/delta_p averages; mining_room.groups[].rack_statuses (online = live rack power); ambient averaging over real readings (drop only missing). energy-system: site_pm.current.total used for the SLD aggregate. metrics: new GET /auth/metrics/cooling historical endpoint (hourly/daily) backed by DCS stat ops; WORKER_TYPES.DCS set to the actual thing type 'dcs-siemens'. Tests added/updated. --- .../handlers/coolingSystem.handlers.test.js | 212 +++++++++++++++++- .../handlers/energySystem.handlers.test.js | 40 ++++ .../handlers/metricsCooling.handlers.test.js | 100 +++++++++ workers/lib/constants.js | 26 ++- .../handlers/cooling.system.handlers.js | 153 +++++++++++-- .../server/handlers/energy.system.handlers.js | 1 - .../lib/server/handlers/metrics.handlers.js | 97 +++++++- workers/lib/server/routes/metrics.routes.js | 19 ++ workers/lib/server/schemas/metrics.schemas.js | 10 + 9 files changed, 625 insertions(+), 33 deletions(-) create mode 100644 tests/unit/handlers/energySystem.handlers.test.js create mode 100644 tests/unit/handlers/metricsCooling.handlers.test.js diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js index 7fe3bd1..3b88359 100644 --- a/tests/unit/handlers/coolingSystem.handlers.test.js +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -97,6 +97,10 @@ const createMockEquipment = () => ({ { equipment: 'VT-7501', value: 0.6, unit: 'mm/s', status: 'Normal' }, { equipment: 'VT-7503', value: 0.8, unit: 'mm/s', status: 'Normal' } ], + vibration_switches: [ + { equipment: 'VS-7581', state: 'ok' }, + { equipment: 'VS-7591', state: 'error' } + ], flow_switches: [ { equipment: 'FS-7501', is_active: true }, { equipment: 'FS-7502', is_active: true } @@ -165,6 +169,7 @@ const createMockConfig = () => ({ }, tower_level_sensor: 'LIT-7501', tower_vibration_sensor: 'VT-7501', + tower_vibration_switch: 'VS-7581', tower_fan: 'FAN-7501', heat_exchangers: { 'tc-7501': { miner_side_out_sensor: 'TS-7521', tower_side_in_sensor: 'TS-7513', tower_side_out_sensor: 'TS-7515' }, @@ -206,13 +211,23 @@ const createMockConfig = () => ({ }, control_valves: { pressure_bypass: 'PCV-7501' - } + }, + fan_coils: [ + { id: 'FC-7513', valve_tag: 'TCV-7501A', temperature_tag: 'TT-7501C' }, + { id: 'FC-7514', valve_tag: 'TCV-7501B', temperature_tag: 'TT-7501C' }, + { id: 'FC-7529', valve_tag: 'TCV-7501C', temperature_tag: 'TT-7501C' }, + { id: 'FC-7530', valve_tag: 'TCV-7501D', temperature_tag: 'TT-7501C' } + ] }, hvac_condenser: { name: 'Circuit 2 - Condenser Water Loop', defaults: { supply_temp: { value: 29, unit: '°C' }, return_temp: { value: 39, unit: '°C' } + }, + tower: { + level_sensor: 'LIT-7504', + vibration_switch: 'VS-7591' } }, ambient: { @@ -412,7 +427,9 @@ test('buildMinersCircuit2View - builds view from enriched equipment', (t) => { t.ok(view.cooling_towers[0].level.unit, 'level should have unit') // Check tower sensor refs t.ok(view.cooling_towers[0].level_sensor, 'should have level_sensor ref') - t.ok(view.cooling_towers[0].vibration_sensor, 'should have vibration_sensor ref') + t.is(view.cooling_towers[0].vibration, undefined, 'numeric vibration removed') + t.is(view.cooling_towers[0].vibration_switch.tag, 'VS-7581', 'vibration_switch tag from config') + t.is(view.cooling_towers[0].vibration_switch.state, 'ok', 'vibration_switch state from DI') t.pass() }) @@ -1166,18 +1183,21 @@ test('buildHvacAmbientView - rooms with no matching fan coils or humidity', (t) t.pass() }) -test('buildHvacAmbientView - room with fan coils that have zero temperature', (t) => { +test('buildHvacAmbientView - 11 keeps valid zero/low temps, drops only missing', (t) => { const equipment = createEmptyEquipment() equipment.fan_coils = [ - { equipment: 'FC-1', is_running: false, temperature: { value: 0, unit: '°C' }, valve_position: { value: 0, unit: '%' } } + // valid cold reading (kept) + a unit with no reading (dropped) + { equipment: 'FC-1', is_running: true, temperature: { value: 0, unit: '°C' }, valve_position: { value: 0, unit: '%' } }, + { equipment: 'FC-2', is_running: false, temperature: { value: null, unit: '°C' }, valve_position: { value: 0, unit: '%' } } ] const config = createMinimalConfig() config.cooling_system.ambient = { - rooms: [{ name: 'Cold Room', fan_coils: ['FC-1'], humidity_sensors: [] }] + rooms: [{ name: 'Cold Room', fan_coils: ['FC-1', 'FC-2'], humidity_sensors: [] }] } const view = buildHvacAmbientView(equipment, config, {}) - t.is(view.rooms[0].temperature, null, 'zero temp filtered out') + t.ok(view.rooms[0].temperature, 'valid zero temp is not hidden') + t.is(view.rooms[0].temperature.value, 0, 'averages the real 0°C reading') t.pass() }) @@ -1251,3 +1271,183 @@ test('getCoolingSystemData - miners ambient is invalid view', async (t) => { } t.pass() }) + +test('1 - renamed level/valve tags resolve from config', (t) => { + const equipment = createMockEquipment() + equipment.levels = [ + { equipment: 'LT-7501', value: 76, unit: '%' }, + { equipment: 'LIT-7581', value: 82, unit: '%' } + ] + const config = createMockConfig() + config.cooling_system.cooling_tower_loop.tower_level_sensor = 'LIT-7581' + config.cooling_system.cooling_tower_loop.makeup.level_sensor = 'LT-7501' + config.cooling_system.cooling_tower_loop.makeup.level_control_valve = 'LCV-7501' + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.summary.tower_level.value, 82, 'tower level resolves via renamed LIT-7581') + t.is(view.makeup.tank.level.value, 76, 'makeup level resolves via renamed LT-7501') + t.is(view.makeup.level_control_valve.id, 'LCV-7501', 'makeup LCV uses renamed id') + t.pass() +}) + +test('2 - fan-coil map yields the configured TCV/TT tags', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit1View(equipment, config) + + const fc13 = view.fan_coils.units.find(u => u.id === 'FC-7513') + t.is(fc13.valve_tag, 'TCV-7501A', 'valve_tag from map (not PIV-7513)') + t.is(fc13.temperature_tag, 'TT-7501C', 'temperature_tag from map (grouped, not TT-7513)') + const fc29 = view.fan_coils.units.find(u => u.id === 'FC-7529') + t.is(fc29.valve_tag, 'TCV-7501C', 'grouped unit gets its own TCV') + t.pass() +}) + +test('2 - fan-coil with no map entry yields null tags (no fabrication)', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + config.cooling_system.hvac_chilled_water.fan_coils = [] + const view = buildHvacCircuit1View(equipment, config) + + t.is(view.fan_coils.units[0].valve_tag, null, 'no derived PIV tag') + t.is(view.fan_coils.units[0].temperature_tag, null, 'no derived TT tag') + t.pass() +}) + +test('3 - miners tower exposes vibration_switch from DI, drops numeric', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.cooling_towers[0].vibration, undefined, 'numeric vibration removed') + t.is(view.cooling_towers[0].vibration_switch.tag, 'VS-7581', 'switch tag') + t.is(view.cooling_towers[0].vibration_switch.state, 'ok', 'state from DI') + t.pass() +}) + +test('3 - hvac tower vibration_switch reflects error DI', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit2View(equipment, config) + + t.is(view.cooling_towers[0].vibration_switch.tag, 'VS-7591', 'hvac switch tag') + t.is(view.cooling_towers[0].vibration_switch.state, 'error', 'state from DI') + t.pass() +}) + +test('3 - vibration_switch state is null when DI absent (no fabrication)', (t) => { + const equipment = createMockEquipment() + equipment.vibration_switches = [] // DI not provisioned + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.cooling_towers[0].vibration_switch.tag, 'VS-7581', 'tag still surfaced') + t.is(view.cooling_towers[0].vibration_switch.state, null, 'state null, never fabricated') + t.pass() +}) + +test('4 - pressure_bypass exposes pressure (PIT-7501) and tag-less aperture', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit1View(equipment, config) + + const pb = view.control_valves.pressure_bypass + t.ok(pb.pressure, 'should have pressure') + t.is(pb.pressure.tag, 'PIT-7501', 'pressure tag') + t.is(pb.pressure.value, 3.1, 'pressure value from PIT-7501') + t.is(pb.pressure.unit, 'bar', 'pressure unit') + t.absent(pb.position?.tag, 'aperture position stays tag-less') + t.pass() +}) + +test('5 - HVAC pumps include current {value, unit}', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit1View(equipment, config) + + t.ok(view.return_pumps.length > 0, 'has return pumps') + t.ok(view.return_pumps[0].current, 'return pump has current') + t.is(view.return_pumps[0].current.unit, 'A', 'current in amps') + t.pass() +}) + +// 6 — per-group differential pressure + summary averages +test('6 - differential_pressure empty by default, summary avgs from line PTs', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.alike(view.lines[0].differential_pressure, [], 'empty until per-group PTs provisioned') + t.ok(view.summary.inlet_pressure_avg, 'has inlet_pressure_avg') + t.ok(view.summary.outlet_pressure_avg, 'has outlet_pressure_avg') + t.ok(view.summary.delta_p_avg, 'has delta_p_avg') + t.is(view.summary.inlet_pressure_avg.value, 2.8, 'inlet avg from supply PTs') + t.is(view.summary.outlet_pressure_avg.value, 1.35, 'outlet avg from return PTs') + t.is(view.summary.delta_p_avg.value, 1.45, 'delta-p avg') + t.pass() +}) + +test('6 - differential_pressure populated when per-group PTs configured', (t) => { + const equipment = createMockEquipment() + equipment.pressures = [ + ...equipment.pressures, + { equipment: 'PT-7501A', value: 3.0, unit: 'bar' }, + { equipment: 'PT-7501B', value: 1.2, unit: 'bar' } + ] + const config = createMockConfig() + config.cooling_system.miner_loop.line1.group_pressure_sensors = { + supply: ['PT-7501A'], + return: ['PT-7501B'] + } + const view = buildMinersCircuit1View(equipment, config) + + const dp = view.lines[0].differential_pressure + t.is(dp.length, 1, 'one group row') + t.is(dp[0].supply.tag, 'PT-7501A', 'supply PT tag') + t.is(dp[0].return.tag, 'PT-7501B', 'return PT tag') + t.is(dp[0].delta_p.value, 1.8, 'group delta-p = supply - return') + t.pass() +}) + +test('7 - rack_statuses derived from live rack power (online = power > 0)', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const rackPowerByRack = { + 'group-1_rack-1': 1200, + 'group-1_rack-2': 0, + 'group-1_rack-3': 800 + } + const view = buildMinersLayoutView(equipment, config, {}, rackPowerByRack) + + t.alike(view.mining_room.groups[0].rack_statuses, [true, false, true, false], 'online iff live power > 0') + t.pass() +}) + +test('7 - rack_statuses omitted when miner RTD not joined (no fake all-online)', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersLayoutView(equipment, config, {}) + + t.is(view.mining_room.groups[0].rack_statuses, undefined, 'absent rather than fabricated') + t.pass() +}) + +test('getCoolingSystemData - miners layout joins rack RTD into rack_statuses', async (t) => { + const snapData = createMockSnapData() + const dcsResponse = [[{ id: 'dcs-1', type: 'dcs', tags: ['t-dcs'], last: { snap: snapData } }]] + const rtdResponse = [[[{ power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 1500, 'group-1_rack-2': 0 } }]]] + + const ctx = { + conf: { featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } }, orks: [{ rpcPublicKey: 'k' }] }, + dataProxy: { + requestDataMap: async (method) => (method === 'tailLogMulti' ? rtdResponse : dcsResponse) + } + } + const result = await getCoolingSystemData(ctx, { query: { type: 'miners', view: 'layout' } }) + + const statuses = result.data.mining_room.groups[0].rack_statuses + t.ok(Array.isArray(statuses), 'rack_statuses present on layout') + t.is(statuses[0], true, 'rack-1 online (power 1500)') + t.is(statuses[1], false, 'rack-2 offline (power 0)') + t.pass() +}) diff --git a/tests/unit/handlers/energySystem.handlers.test.js b/tests/unit/handlers/energySystem.handlers.test.js new file mode 100644 index 0000000..433819a --- /dev/null +++ b/tests/unit/handlers/energySystem.handlers.test.js @@ -0,0 +1,40 @@ +'use strict' + +const test = require('brittle') +const { + buildLayoutView, + buildMinersView +} = require('../../../workers/lib/server/handlers/energy.system.handlers') + +const siteMeter = () => ({ + equipment: 'PM-MV', + role: 'site_main', + name: 'Site PM', + power: { value: 8412, unit: 'kW' }, + current: { + l1: { value: 100, unit: 'A' }, + l2: { value: 110, unit: 'A' }, + l3: { value: 120, unit: 'A' }, + total: { value: 330, unit: 'A' } + } +}) + +test('BE-8 - energy layout site_pm exposes current.total from BE', (t) => { + const equipment = { power_meters: [siteMeter()], protection_relays: [], transformers: [], distribution_boards: [] } + const config = { energy_layout: {} } + const view = buildLayoutView(equipment, config, {}) + + t.ok(view.site_pm, 'has site_pm') + t.ok(view.site_pm.current, 'site_pm has current') + t.is(view.site_pm.current.total.value, 330, 'current.total comes from BE') + t.is(view.site_pm.current.total.unit, 'A', 'current.total has unit') + t.pass() +}) + +test('BE-8 - miners view site_total resolves from site_main meter', (t) => { + const equipment = { power_meters: [siteMeter()] } + const view = buildMinersView(equipment, {}, {}) + + t.ok(view.site_total, 'has site_total') + t.pass() +}) diff --git a/tests/unit/handlers/metricsCooling.handlers.test.js b/tests/unit/handlers/metricsCooling.handlers.test.js new file mode 100644 index 0000000..cf171c2 --- /dev/null +++ b/tests/unit/handlers/metricsCooling.handlers.test.js @@ -0,0 +1,100 @@ +'use strict' + +const test = require('brittle') +const { + getCooling, + processCoolingData, + calculateCoolingSummary +} = require('../../../workers/lib/server/handlers/metrics.handlers') + +const rtdResults = () => ([ + [ + { + ts: 1000, + miner_supply_temp_c: 37.2, + miner_return_temp_c: 47.1, + miner_flow_m3h: 384, + system_pressure_bar: 2.8, + hvac_supply_temp_c: 7.1, + hvac_return_temp_c: 14.2, + chiller_running: 1, + towers_running: 2, + pumps_running: 7 + }, + { + ts: 2000, + miner_supply_temp_c: 37.8, + miner_return_temp_c: 47.5, + miner_flow_m3h: 380, + system_pressure_bar: 2.9, + hvac_supply_temp_c: 7.3, + hvac_return_temp_c: 14.0, + chiller_running: 0.5, + towers_running: 2, + pumps_running: 6 + } + ] +]) + +test('BE-10 - processCoolingData shapes log rows with delta-t and uptime', (t) => { + const log = processCoolingData(rtdResults(), null) + + t.is(log.length, 2, 'two time points') + t.is(log[0].ts, 1000, 'sorted by ts') + t.is(log[0].minerSupplyTempC, 37.2, 'supply temp') + t.is(log[0].minerDeltaTC, 9.9, 'delta-t = return - supply') + t.is(log[0].chillerUptimePct, 100, 'chiller_running 1 -> 100%') + t.is(log[1].chillerUptimePct, 50, 'chiller_running 0.5 -> 50%') + t.pass() +}) + +test('BE-10 - calculateCoolingSummary averages present values', (t) => { + const log = processCoolingData(rtdResults(), null) + const summary = calculateCoolingSummary(log) + + t.is(summary.avgMinerSupplyTempC, 37.5, 'avg supply temp') + t.is(summary.chillerUptimePct, 75, 'avg chiller uptime') + t.ok(summary.avgMinerDeltaTC != null, 'has avg delta-t') + t.pass() +}) + +test('BE-10 - getCooling returns { interval, log, summary }', async (t) => { + const ctx = { + conf: { featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } }, + dataProxy: { requestData: async () => rtdResults() } + } + const res = await getCooling(ctx, { query: { start: 1000, end: 2000, interval: 'hourly' } }) + + t.ok(res.log.length === 2, 'has log') + t.ok(res.summary, 'has summary') + t.is(res.interval, '1h', 'hourly alias mapped to 1h') + t.pass() +}) + +test('BE-10 - getCooling throws when central DCS disabled', async (t) => { + const ctx = { + conf: { featureConfig: { centralDCSSetup: { enabled: false } } }, + dataProxy: { requestData: async () => [] } + } + try { + await getCooling(ctx, { query: { start: 1000, end: 2000 } }) + t.fail('should throw') + } catch (err) { + t.is(err.message, 'ERR_FEATURE_NOT_ENABLED') + } + t.pass() +}) + +test('BE-10 - getCooling validates start/end', async (t) => { + const ctx = { + conf: { featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } }, + dataProxy: { requestData: async () => [] } + } + try { + await getCooling(ctx, { query: {} }) + t.fail('should throw') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END') + } + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index b6b4e5f..85f424b 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -170,6 +170,7 @@ const ENDPOINTS = { METRICS_POWER_MODE: '/auth/metrics/power-mode', METRICS_POWER_MODE_TIMELINE: '/auth/metrics/power-mode/timeline', METRICS_TEMPERATURE: '/auth/metrics/temperature', + METRICS_COOLING: '/auth/metrics/cooling', METRICS_CONTAINER_TELEMETRY: '/auth/metrics/containers/:id', METRICS_CONTAINER_HISTORY: '/auth/metrics/containers/:id/history', @@ -303,7 +304,25 @@ const WORKER_TYPES = { POWERMETER: 'powermeter', MINERPOOL: 'minerpool', MEMPOOL: 'mempool', - ELECTRICITY: 'electricity' + ELECTRICITY: 'electricity', + // The Siemens DCS worker registers its thing type as 'dcs-siemens' + // (WrkDCSBase 'dcs' + '-siemens'); the stat log is tailed by this type. + DCS: 'dcs-siemens' +} + +// BE-10 — historical cooling metric fields produced by the DCS worker stat spec +// (miningos-wrk-dcs-siemens/workers/lib/stats.js -> libStats.specs.dcs.ops). Each +// is a per-interval average; chiller_running is averaged over [0,1] -> uptime ratio. +const COOLING_METRICS_AGGR_FIELDS = { + miner_supply_temp_c: 1, + miner_return_temp_c: 1, + miner_flow_m3h: 1, + system_pressure_bar: 1, + hvac_supply_temp_c: 1, + hvac_return_temp_c: 1, + chiller_running: 1, + towers_running: 1, + pumps_running: 1 } const SEVERITY_LEVELS = new Set(['critical', 'high', 'medium', 'low']) @@ -419,6 +438,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.valves': 1, 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_switches': 1, 'last.snap.stats.dcs_specific.equipment.fans': 1, 'last.snap.config.cooling_system': 1 }, @@ -433,6 +453,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.valves': 1, 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_switches': 1, 'last.snap.stats.dcs_specific.equipment.fans': 1, 'last.snap.stats.flow': 1, 'last.snap.config.cooling_system': 1, @@ -461,6 +482,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.levels': 1, 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_switches': 1, 'last.snap.config.cooling_system': 1 }, layout: { @@ -477,6 +499,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.flow_switches': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_switches': 1, 'last.snap.config.cooling_system': 1 }, ambient: { @@ -801,6 +824,7 @@ module.exports = { EXPLORER_RACK_AGGR_FIELDS, EXPLORER_RACK_DEFAULT_LIMIT, EXPLORER_RACK_MAX_LIMIT, + COOLING_METRICS_AGGR_FIELDS, LOG_FIELDS, ELECTRICITY_EXT_DATA_KEYS, WORK_ORDER_THING_TYPE, diff --git a/workers/lib/server/handlers/cooling.system.handlers.js b/workers/lib/server/handlers/cooling.system.handlers.js index b20d922..fc0d328 100644 --- a/workers/lib/server/handlers/cooling.system.handlers.js +++ b/workers/lib/server/handlers/cooling.system.handlers.js @@ -1,13 +1,36 @@ 'use strict' -const { COOLING_SYSTEM_PROJECTIONS } = require('../../constants') +const { + COOLING_SYSTEM_PROJECTIONS, + LOG_KEYS, + WORKER_TYPES, + WORKER_TAGS, + EXPLORER_RACK_AGGR_FIELDS +} = require('../../constants') const { isCentralDCSEnabled, getDCSTag, extractDcsThing, getSensorReading } = require('../../dcs.utils') +const { aggregateRackStats } = require('./explorer.handlers') +/** + * BE-9 — Layout positional / equipment-id contract (FE binds by position or id): + * - circuit1.lines[0] = Line 1 / Groups 1-8, lines[1] = Line 2 / Groups 9-16. + * - pumps[] keep config order: pumps[0/1/2] = A/B/C per circuit. + * - Cooling towers are matched by `id` (TR-7501 miner loop, TR-7502 HVAC condenser). + * - Control valves / sensors are resolved by their config tag against the single + * flat `equipment.*` arrays, so every equipment `.equipment` id MUST stay + * GLOBALLY UNIQUE. Two entries sharing an id make `find()` ambiguous and cannot + * be disambiguated by circuit — keep ids unique at the source (DCS config). + * Do not reorder or reshape these arrays. + * + * BE-1 deferred renames (kept unique pending controls/DCS provisioning of a + * globally-unique tag — applying them verbatim would duplicate an existing id): + * - PCV-7503 -> PCV-7501 (collides with HVAC pressure_bypass PCV-7501). + * - LIT-7504 -> LT-7591 (collides with HVAC buffer-tank LT-7591). + */ function getFieldProjection (type, view) { const base = COOLING_SYSTEM_PROJECTIONS.base const typeProjections = COOLING_SYSTEM_PROJECTIONS[type] @@ -45,6 +68,38 @@ function getSensorWithTag (sensors, sensorId, defaultConfig) { } } +function buildVibrationSwitch (vibrationSwitches, switchTag) { + if (!switchTag) return null + const sw = (vibrationSwitches || []).find(s => s.equipment === switchTag) + return { tag: switchTag, state: sw?.state ?? null } +} + +function buildGroupDifferentialPressure (lineConfig, pressures) { + const groupSensors = lineConfig.group_pressure_sensors || {} + const supplyIds = groupSensors.supply || [] + const returnIds = groupSensors.return || [] + const count = Math.max(supplyIds.length, returnIds.length) + + const rows = [] + for (let i = 0; i < count; i++) { + const supply = getSensorWithTag(pressures, supplyIds[i]) + const ret = getSensorWithTag(pressures, returnIds[i]) + const supplyVal = supply?.reading?.value + const returnVal = ret?.reading?.value + const deltaPVal = (supplyVal != null && returnVal != null) + ? Math.round((supplyVal - returnVal) * 100) / 100 + : null + const unit = supply?.reading?.unit || ret?.reading?.unit || 'bar' + rows.push({ + group: i + 1, + supply, + return: ret, + delta_p: deltaPVal != null ? { value: deltaPVal, unit } : null + }) + } + return rows +} + function buildMinersCircuit1View (equipment, config) { const pumps = equipment.pumps const temperatures = equipment.temperatures @@ -97,6 +152,7 @@ function buildMinersCircuit1View (equipment, config) { pressure: getSensorReading(pressures, lineConfig.return_pressure_sensor), sensors: [returnTempSensor, returnPressureSensor].filter(Boolean) }, + differential_pressure: buildGroupDifferentialPressure(lineConfig, pressures), heat_exchanger: hx ? { id: hx.equipment, @@ -133,6 +189,7 @@ function buildMinersCircuit1View (equipment, config) { const allReturnTemps = lines.map(l => l.return.temperature?.value).filter(v => v != null) const allSupplyFlows = lines.map(l => l.supply.flow?.value).filter(v => v != null) const allSupplyPressures = lines.map(l => l.supply.pressure?.value).filter(v => v != null) + const allReturnPressures = lines.map(l => l.return.pressure?.value).filter(v => v != null) const avgSupplyTemp = allSupplyTemps.length > 0 ? Math.round((allSupplyTemps.reduce((a, b) => a + b, 0) / allSupplyTemps.length) * 10) / 10 @@ -150,6 +207,16 @@ function buildMinersCircuit1View (equipment, config) { ? Math.round((avgReturnTemp - avgSupplyTemp) * 10) / 10 : null + const inletPressureAvg = allSupplyPressures.length > 0 + ? Math.round((allSupplyPressures.reduce((a, b) => a + b, 0) / allSupplyPressures.length) * 100) / 100 + : null + const outletPressureAvg = allReturnPressures.length > 0 + ? Math.round((allReturnPressures.reduce((a, b) => a + b, 0) / allReturnPressures.length) * 100) / 100 + : null + const deltaPAvg = (inletPressureAvg != null && outletPressureAvg != null) + ? Math.round((inletPressureAvg - outletPressureAvg) * 100) / 100 + : null + const controlValveEntries = coolingConfig.control_valves || {} const controlValves = {} for (const [role, valveId] of Object.entries(controlValveEntries)) { @@ -177,7 +244,10 @@ function buildMinersCircuit1View (equipment, config) { delta_t: deltaT != null ? { value: deltaT, unit: tempUnit } : null, total_flow: totalFlow != null ? { value: totalFlow, unit: flowUnit } : null, rated_flow: coolingConfig.defaults?.rated_flow || null, - system_pressure: systemPressure != null ? { value: systemPressure, unit: pressureUnit } : null + system_pressure: systemPressure != null ? { value: systemPressure, unit: pressureUnit } : null, + inlet_pressure_avg: inletPressureAvg != null ? { value: inletPressureAvg, unit: pressureUnit } : null, + outlet_pressure_avg: outletPressureAvg != null ? { value: outletPressureAvg, unit: pressureUnit } : null, + delta_p_avg: deltaPAvg != null ? { value: deltaPAvg, unit: pressureUnit } : null }, pumps_config: coolingConfig.defaults?.pumps_config || null, lines, @@ -192,6 +262,7 @@ function buildMinersCircuit2View (equipment, config) { const levels = equipment.levels const heatExchangers = equipment.heat_exchangers const coolingTowers = equipment.cooling_towers + const vibrationSwitches = equipment.vibration_switches const valves = equipment.valves const tanks = equipment.tanks const towerConfig = config?.cooling_system?.cooling_tower_loop || {} @@ -269,8 +340,8 @@ function buildMinersCircuit2View (equipment, config) { const towerLevel = getSensorReading(levels, towerLevelSensor) // Cooling towers with sensor tag references - const towerVibrationSensorId = towerConfig.tower_vibration_sensor const towerFanId = towerConfig.tower_fan + const towerVibrationSwitch = buildVibrationSwitch(vibrationSwitches, towerConfig.tower_vibration_switch) const towerData = (coolingTowers || []).map(ct => ({ id: ct.equipment, @@ -282,9 +353,7 @@ function buildMinersCircuit2View (equipment, config) { fan_id: towerFanId, level: ct.level, level_sensor: towerLevelSensor, - vibration: ct.vibration, - vibration_sensor: towerVibrationSensorId, - vibration_threshold: ct.vibration_threshold || null, + vibration_switch: towerVibrationSwitch, capacity_flow: towerConfig.defaults?.tower_capacity || null, capacity_gcal: towerConfig.defaults?.tower_capacity_gcal || null })) @@ -358,7 +427,7 @@ function buildMinersCircuit2View (equipment, config) { } } -function buildMinersLayoutView (equipment, config, stats) { +function buildMinersLayoutView (equipment, config, stats, rackPowerByRack) { const circuit1 = buildMinersCircuit1View(equipment, config) const circuit2 = buildMinersCircuit2View(equipment, config) const { pumps } = equipment @@ -375,11 +444,20 @@ function buildMinersLayoutView (equipment, config, stats) { const groups = [] for (let i = 1; i <= totalGroups; i++) { - groups.push({ + const group = { id: `G${i}`, name: `G${i}`, vlan: vlanStart + (i - 1) - }) + } + if (rackPowerByRack) { + const statuses = [] + for (let r = 1; r <= racksPerGroup; r++) { + const powerW = rackPowerByRack[`group-${i}_rack-${r}`] + statuses.push(powerW != null && powerW > 0) + } + group.rack_statuses = statuses + } + groups.push(group) } return { @@ -555,19 +633,31 @@ function buildHvacCircuit1View (equipment, config) { } } + if (controlValves.pressure_bypass) { + controlValves.pressure_bypass.pressure = systemPressure + ? { tag: supplyReturnConfig.pressure_sensor || null, value: systemPressure.value, unit: systemPressure.unit } + : null + } + const returnPumps = filterPumpsByCircuit(pumps, 'HVAC_RETURN').map(formatPump) const supplyPumps = filterPumpsByCircuit(pumps, 'HVAC_SUPPLY').map(formatPump) + const fanCoilTagMap = {} + for (const fcCfg of (chilledConfig.fan_coils || [])) { + if (fcCfg.id) fanCoilTagMap[fcCfg.id] = fcCfg + } + const fanCoilsSummary = { total: (fanCoils || []).length, running: (fanCoils || []).filter(fc => fc.is_running).length, units: (fanCoils || []).map(fc => { + const fcMeta = fanCoilTagMap[fc.equipment] || {} + const valveTag = fcMeta.valve_tag || null + const temperatureTag = fcMeta.temperature_tag || null const fcNumber = fc.equipment.replace(/^FCT?-/, '') const fanId = `V-${fcNumber}` - const valveId = `PIV-${fcNumber}` - const tempSensorId = `TT-${fcNumber}` const fan = (fans || []).find(f => f.equipment === fanId) - const valve = valves?.find(v => v.equipment === valveId) + const valve = valveTag ? valves?.find(v => v.equipment === valveTag) : null return { id: fc.equipment, @@ -575,9 +665,9 @@ function buildHvacCircuit1View (equipment, config) { fan_id: fanId, fan_running: fan?.fbk_run_out || false, fan_speed: fc.fan_speed, - valve_tag: valveId, + valve_tag: valveTag, valve_position: valve?.position || fc.valve_position, - temperature_tag: tempSensorId, + temperature_tag: temperatureTag, temperature: fc.temperature } }) @@ -659,8 +749,8 @@ function buildHvacCircuit2View (equipment, config) { const towerConfigRef = condenserConfig.tower || {} const towerLevelSensorId = towerConfigRef.level_sensor const towerLevel = getSensorReading(levels, towerLevelSensorId) - const towerVibrationSensorId = towerConfigRef.vibration_sensor const towerFanId = towerConfigRef.fan + const towerVibrationSwitch = buildVibrationSwitch(equipment.vibration_switches, towerConfigRef.vibration_switch) const towerData = (coolingTowers || []).map(ct => ({ id: ct.equipment, @@ -672,9 +762,7 @@ function buildHvacCircuit2View (equipment, config) { fan_id: towerFanId || null, level: ct.level, level_sensor: towerLevelSensorId || null, - vibration: ct.vibration, - vibration_sensor: towerVibrationSensorId || null, - vibration_threshold: ct.vibration_threshold || null, + vibration_switch: towerVibrationSwitch, capacity_mcal: condenserConfig.defaults?.tower_capacity_mcal || null, capacity_flow: condenserConfig.defaults?.tower_flow || null })) @@ -772,7 +860,7 @@ function buildHvacAmbientView (equipment, config, stats) { })) const roomTemps = roomFanCoils - .filter(fc => fc.temperature?.value > 0) + .filter(fc => fc.temperature?.value != null && Number.isFinite(fc.temperature.value)) .map(fc => fc.temperature.value) const avgTemp = roomTemps.length > 0 ? Math.round((roomTemps.reduce((a, b) => a + b, 0) / roomTemps.length) * 10) / 10 @@ -832,7 +920,7 @@ function buildHvacAmbientView (equipment, config, stats) { * @param {string} view - View name (circuit1, circuit2, layout, ambient) * @returns {Object|null} */ -function buildCoolingViewData (snap, type, view) { +function buildCoolingViewData (snap, type, view, rackPowerByRack) { const equipment = snap.stats?.dcs_specific?.equipment || {} const config = snap.config || {} const stats = snap.stats || {} @@ -844,7 +932,7 @@ function buildCoolingViewData (snap, type, view) { case 'circuit2': return buildMinersCircuit2View(equipment, config) case 'layout': - return buildMinersLayoutView(equipment, config, stats) + return buildMinersLayoutView(equipment, config, stats, rackPowerByRack) default: return null } @@ -909,7 +997,22 @@ async function getCoolingSystemData (ctx, req) { fields } - const rpcResults = await ctx.dataProxy.requestDataMap('listThings', payload) + const needsRackStatuses = type === 'miners' && view === 'layout' + const rackTailLogPayload = { + keys: [ + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } + ], + limit: 1, + aggrFields: EXPLORER_RACK_AGGR_FIELDS + } + + const [rpcResults, rackTailLogResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('listThings', payload), + needsRackStatuses + ? ctx.dataProxy.requestDataMap('tailLogMulti', rackTailLogPayload) + : Promise.resolve(null) + ]) + const dcsThing = extractDcsThing(rpcResults) if (!dcsThing) { @@ -918,7 +1021,11 @@ async function getCoolingSystemData (ctx, req) { const snap = dcsThing.last.snap - const viewData = buildCoolingViewData(snap, type, view) + const rackPowerByRack = rackTailLogResults + ? aggregateRackStats(rackTailLogResults).powerByRack + : null + + const viewData = buildCoolingViewData(snap, type, view, rackPowerByRack) if (!viewData) { throw new Error('ERR_VIEW_DATA_NOT_AVAILABLE') diff --git a/workers/lib/server/handlers/energy.system.handlers.js b/workers/lib/server/handlers/energy.system.handlers.js index 7592c75..e46c824 100644 --- a/workers/lib/server/handlers/energy.system.handlers.js +++ b/workers/lib/server/handlers/energy.system.handlers.js @@ -26,7 +26,6 @@ function buildMinersView (equipment, config, stats) { } // Rack meters (role: rack) - console.log('powerMeters', powerMeters) const rackMeters = powerMeters .filter(pm => pm.role === 'rack') .sort((a, b) => { diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index 8dd23b9..35740aa 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -10,12 +10,17 @@ const { LOG_KEYS, WORKER_TAGS, DEVICE_LIST_FIELDS, - LOG_FIELDS + LOG_FIELDS, + COOLING_METRICS_AGGR_FIELDS } = require('../../constants') const { getStartOfDay, safeDiv } = require('../../utils') +const { + isCentralDCSEnabled, + getDCSTag +} = require('../../dcs.utils') const { parseEntryTs, validateStartEnd, @@ -860,6 +865,91 @@ function processContainerHistoryData (results, containerId) { return log } +const COOLING_INTERVAL_ALIASES = { hourly: '1h', daily: '1d', weekly: '1w' } + +const round1 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10) / 10) + +async function getCooling (ctx, req) { + if (!isCentralDCSEnabled(ctx)) { + throw new Error('ERR_FEATURE_NOT_ENABLED') + } + + const { start, end } = validateStartEnd(req) + + const requested = COOLING_INTERVAL_ALIASES[req.query.interval] || req.query.interval + const interval = resolveInterval(start, end, requested) + const config = getIntervalConfig(interval) + + const rpcPayload = { + key: config.key, + type: WORKER_TYPES.DCS, + tag: getDCSTag(ctx), + aggrFields: COOLING_METRICS_AGGR_FIELDS, + start, + end + } + if (config.groupRange) { + rpcPayload.groupRange = config.groupRange + } + + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, rpcPayload) + + const log = processCoolingData(results, config.groupRange) + const summary = calculateCoolingSummary(log) + + return { interval, log, summary } +} + +function processCoolingData (results, groupRange) { + const points = [] + for (const entry of iterateRpcEntries(results)) { + const rawTs = parseEntryTs(entry.ts || entry.timestamp) + const ts = groupRange && rawTs ? getStartOfDay(rawTs) : rawTs + if (!ts) continue + + const read = (field) => { + const v = entry[field] ?? entry.aggrFields?.[field] + return v == null || !Number.isFinite(Number(v)) ? null : Number(v) + } + + const supply = read('miner_supply_temp_c') + const ret = read('miner_return_temp_c') + const chillerRunning = read('chiller_running') + + points.push({ + ts: Number(ts), + minerSupplyTempC: round1(supply), + minerReturnTempC: round1(ret), + minerDeltaTC: (supply != null && ret != null) ? round1(ret - supply) : null, + minerFlowM3h: round1(read('miner_flow_m3h')), + systemPressureBar: round1(read('system_pressure_bar')), + hvacSupplyTempC: round1(read('hvac_supply_temp_c')), + hvacReturnTempC: round1(read('hvac_return_temp_c')), + chillerUptimePct: chillerRunning == null ? null : Math.round(chillerRunning * 1000) / 10, + towersRunning: round1(read('towers_running')), + pumpsRunning: round1(read('pumps_running')) + }) + } + return points.sort((a, b) => a.ts - b.ts) +} + +function calculateCoolingSummary (log) { + const avgOf = (key) => { + const vals = log.map(e => e[key]).filter(v => v != null) + return vals.length ? round1(vals.reduce((a, b) => a + b, 0) / vals.length) : null + } + return { + avgMinerSupplyTempC: avgOf('minerSupplyTempC'), + avgMinerReturnTempC: avgOf('minerReturnTempC'), + avgMinerDeltaTC: avgOf('minerDeltaTC'), + avgMinerFlowM3h: avgOf('minerFlowM3h'), + avgSystemPressureBar: avgOf('systemPressureBar'), + avgHvacSupplyTempC: avgOf('hvacSupplyTempC'), + avgHvacReturnTempC: avgOf('hvacReturnTempC'), + chillerUptimePct: avgOf('chillerUptimePct') + } +} + module.exports = { ...require('../../metrics.utils'), getHashrate, @@ -889,5 +979,8 @@ module.exports = { processContainerMiners, processContainerSensorSnapshot, getContainerHistory, - processContainerHistoryData + processContainerHistoryData, + getCooling, + processCoolingData, + calculateCoolingSummary } diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js index bd29650..6ac935d 100644 --- a/workers/lib/server/routes/metrics.routes.js +++ b/workers/lib/server/routes/metrics.routes.js @@ -12,6 +12,7 @@ const { getPowerMode, getPowerModeTimeline, getTemperature, + getCooling, getContainerTelemetry, getContainerHistory } = require('../handlers/metrics.handlers') @@ -148,6 +149,24 @@ module.exports = (ctx) => { getTemperature ) }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_COOLING, + schema: { + querystring: schemas.query.cooling + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/cooling', + req.query.start, + req.query.end, + req.query.interval + ], + ENDPOINTS.METRICS_COOLING, + getCooling + ) + }, { method: HTTP_METHODS.GET, url: ENDPOINTS.METRICS_CONTAINER_HISTORY, diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index 84840a8..2f80707 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -72,6 +72,16 @@ const schemas = { }, required: ['start', 'end'] }, + cooling: { + type: 'object', + properties: { + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, + interval: { type: 'string', enum: ['1h', '1d', '1w', 'hourly', 'daily', 'weekly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, containerTelemetry: { type: 'object', properties: {