From 708d5e81aa3d1d4920f619c861ae0fe5428f45d3 Mon Sep 17 00:00:00 2001 From: Parag More Date: Wed, 15 Apr 2026 18:43:32 +0530 Subject: [PATCH 1/2] Support site overview endpoints for dcs and miner group wise overview --- workers/lib/constants.js | 4 +- .../server/handlers/siteOverview.handlers.js | 332 ++++++++++++++++++ workers/lib/server/index.js | 4 +- .../lib/server/routes/siteOverview.routes.js | 34 ++ 4 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 workers/lib/server/handlers/siteOverview.handlers.js create mode 100644 workers/lib/server/routes/siteOverview.routes.js diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 78d22ddb..827d73c8 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -160,7 +160,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 = { diff --git a/workers/lib/server/handlers/siteOverview.handlers.js b/workers/lib/server/handlers/siteOverview.handlers.js new file mode 100644 index 00000000..885dd5b0 --- /dev/null +++ b/workers/lib/server/handlers/siteOverview.handlers.js @@ -0,0 +1,332 @@ +'use strict' + +const { + LOG_KEYS, + WORKER_TYPES, + WORKER_TAGS +} = require('../../constants') +const { extractKeyEntry } = require('../../metrics.utils') +const { + isCentralDCSEnabled, + getDCSTag, + extractDcsThing +} = require('../../dcs.utils') + +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 +} + +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 +} + +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 mhsToPhs (mhs) { + return Math.round((mhs / 1000000000) * 100) / 100 +} + +function mhsToThs (mhs) { + return mhs / 1000000 +} + +function getGroupNumber (groupName) { + const match = groupName.match(/group-(\d+)/i) + return match ? parseInt(match[1], 10) : null +} + + +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 +} + +function aggregateMinerStats (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 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) + } + } +} + +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/stats + * + * 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 = aggregateMinerStats(tailLogResults) + + const dcsThing = dcsResults ? extractDcsThing(dcsResults) : null + + const totalGroups = dcsThing?.last?.snap?.config?.mining?.total_groups || null + + return composeGroupsStats(minerStats, dcsThing, totalGroups) +} + +module.exports = { + getSiteOverviewGroupsStats, + composeGroupsStats, + aggregateMinerStats, + parseRackId, + mhsToPhs, + mhsToThs, + buildGroupPowerFromDCS +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 1c10a56d..c42cc1fb 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 siteOverviewRoutes = require('./routes/siteOverview.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), + ...siteOverviewRoutes(ctx) ] } diff --git a/workers/lib/server/routes/siteOverview.routes.js b/workers/lib/server/routes/siteOverview.routes.js new file mode 100644 index 00000000..2036ff0c --- /dev/null +++ b/workers/lib/server/routes/siteOverview.routes.js @@ -0,0 +1,34 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_CAPS, + AUTH_LEVELS +} = require('../../constants') +const { getSiteOverviewGroupsStats } = require('../handlers/siteOverview.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + return [ + { + 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 b4644279755ec7cad045094bde3c69faae2223f1 Mon Sep 17 00:00:00 2001 From: Parag More Date: Wed, 15 Apr 2026 22:00:03 +0530 Subject: [PATCH 2/2] update move to site handlers --- tests/unit/routes/site.routes.test.js | 9 + workers/lib/constants.js | 31 +- workers/lib/metrics.utils.js | 102 +++++- workers/lib/server/handlers/site.handlers.js | 220 +++++++++++- .../server/handlers/siteOverview.handlers.js | 332 ------------------ workers/lib/server/index.js | 4 +- workers/lib/server/routes/site.routes.js | 28 +- .../lib/server/routes/siteOverview.routes.js | 34 -- 8 files changed, 384 insertions(+), 376 deletions(-) delete mode 100644 workers/lib/server/handlers/siteOverview.handlers.js delete mode 100644 workers/lib/server/routes/siteOverview.routes.js diff --git a/tests/unit/routes/site.routes.test.js b/tests/unit/routes/site.routes.test.js index 9c15794f..7b67dbc6 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 827d73c8..574a8cc5 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -162,7 +162,7 @@ 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' } const HTTP_METHODS = { @@ -423,6 +423,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', @@ -620,5 +645,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 ffe2dffc..63773b49 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 b6bb873e..31f756b3 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/handlers/siteOverview.handlers.js b/workers/lib/server/handlers/siteOverview.handlers.js deleted file mode 100644 index 885dd5b0..00000000 --- a/workers/lib/server/handlers/siteOverview.handlers.js +++ /dev/null @@ -1,332 +0,0 @@ -'use strict' - -const { - LOG_KEYS, - WORKER_TYPES, - WORKER_TAGS -} = require('../../constants') -const { extractKeyEntry } = require('../../metrics.utils') -const { - isCentralDCSEnabled, - getDCSTag, - extractDcsThing -} = require('../../dcs.utils') - -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 -} - -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 -} - -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 mhsToPhs (mhs) { - return Math.round((mhs / 1000000000) * 100) / 100 -} - -function mhsToThs (mhs) { - return mhs / 1000000 -} - -function getGroupNumber (groupName) { - const match = groupName.match(/group-(\d+)/i) - return match ? parseInt(match[1], 10) : null -} - - -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 -} - -function aggregateMinerStats (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 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) - } - } -} - -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/stats - * - * 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 = aggregateMinerStats(tailLogResults) - - const dcsThing = dcsResults ? extractDcsThing(dcsResults) : null - - const totalGroups = dcsThing?.last?.snap?.config?.mining?.total_groups || null - - return composeGroupsStats(minerStats, dcsThing, totalGroups) -} - -module.exports = { - getSiteOverviewGroupsStats, - composeGroupsStats, - aggregateMinerStats, - parseRackId, - mhsToPhs, - mhsToThs, - buildGroupPowerFromDCS -} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index c42cc1fb..1c10a56d 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -19,7 +19,6 @@ 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 siteOverviewRoutes = require('./routes/siteOverview.routes') /** * Collect all routes into a flat array for server injection. @@ -45,8 +44,7 @@ function routes (ctx) { ...minersRoutes(ctx), ...groupsRoutes(ctx), ...coolingSystemRoutes(ctx), - ...energySystemRoutes(ctx), - ...siteOverviewRoutes(ctx) + ...energySystemRoutes(ctx) ] } diff --git a/workers/lib/server/routes/site.routes.js b/workers/lib/server/routes/site.routes.js index 461b8944..81546886 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}`] + ) } ] diff --git a/workers/lib/server/routes/siteOverview.routes.js b/workers/lib/server/routes/siteOverview.routes.js deleted file mode 100644 index 2036ff0c..00000000 --- a/workers/lib/server/routes/siteOverview.routes.js +++ /dev/null @@ -1,34 +0,0 @@ -'use strict' - -const { - ENDPOINTS, - HTTP_METHODS, - AUTH_CAPS, - AUTH_LEVELS -} = require('../../constants') -const { getSiteOverviewGroupsStats } = require('../handlers/siteOverview.handlers') -const { createCachedAuthRoute } = require('../lib/routeHelpers') - -module.exports = (ctx) => { - return [ - { - 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}`] - ) - } - ] -}