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/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..514c80c 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 = { @@ -166,7 +168,10 @@ const OPERATIONS = { 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_CONFIG_READ: 'thing.config.read', + + // Site operations + SITE_STATUS_LIVE_READ: 'site.status.live.read' } 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 + ) + } +]