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 +}