diff --git a/tests/unit/handlers/explorer.handlers.test.js b/tests/unit/handlers/explorer.handlers.test.js new file mode 100644 index 00000000..12c1c7bf --- /dev/null +++ b/tests/unit/handlers/explorer.handlers.test.js @@ -0,0 +1,461 @@ +'use strict' + +const test = require('brittle') +const { + listExplorerRacks, + aggregateRackStats, + buildRackList, + filterByGroups, + filterBySearch, + sortRacks +} = require('../../../workers/lib/server/handlers/explorer.handlers') + +function createMockRackStats (overrides = {}) { + return { + hashrateByRack: { + 'group-1_rack-1': 5000000000, // 5 PH/s + 'group-1_rack-2': 4000000000, + 'group-2_rack-1': 3000000000, + 'group-2_rack-2': 6000000000, + ...overrides.hashrateByRack + }, + powerByRack: { + 'group-1_rack-1': 500000, + 'group-1_rack-2': 400000, + 'group-2_rack-1': 300000, + 'group-2_rack-2': 600000, + ...overrides.powerByRack + }, + efficiencyByRack: { + 'group-1_rack-1': 6, + 'group-1_rack-2': 6, + 'group-2_rack-1': 6, + 'group-2_rack-2': 6, + ...overrides.efficiencyByRack + } + } +} + +function createMockMiningConfig (overrides = {}) { + return { + total_groups: 2, + racks_per_group: 2, + miners_per_rack: 20, + ...overrides + } +} + +function createMockDcsThing (miningConfig) { + return { + id: 'dcs-1', + type: 'wrk-dcs-siemens', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: miningConfig + } + } + } + } +} + +function createMockCtx (tailLogData, dcsThing = null) { + return { + conf: { + featureConfig: dcsThing ? { centralDCSSetup: { enabled: true, tag: 't-dcs' } } : {} + }, + dataProxy: { + requestDataMap: async (method, payload) => { + if (method === 'tailLogMulti') return [tailLogData] + if (method === 'listThings') return dcsThing ? [[dcsThing]] : [[]] + return [{}] + } + } + } +} + +// ==================== aggregateRackStats Tests ==================== + +test('aggregateRackStats - extracts per-rack hashrate, power, efficiency', (t) => { + const tailLogResults = [ + [ + [{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 5000000000, 'group-1_rack-2': 4000000000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 500000, 'group-1_rack-2': 400000 }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 6, 'group-1_rack-2': 6.5 } + }] + ] + ] + + const stats = aggregateRackStats(tailLogResults) + t.is(stats.hashrateByRack['group-1_rack-1'], 5000000000) + t.is(stats.powerByRack['group-1_rack-2'], 400000) + t.is(stats.efficiencyByRack['group-1_rack-2'], 6.5) + t.pass() +}) + +test('aggregateRackStats - merges across multiple orks', (t) => { + const tailLogResults = [ + [[{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 3000000000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 300000 }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 5 } + }]], + [[{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 2000000000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 200000 }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 7 } + }]] + ] + + const stats = aggregateRackStats(tailLogResults) + t.is(stats.hashrateByRack['group-1_rack-1'], 5000000000, 'hashrate sums across orks') + t.is(stats.powerByRack['group-1_rack-1'], 500000, 'power sums across orks') + t.is(stats.efficiencyByRack['group-1_rack-1'], 7, 'efficiency takes max across orks') + t.pass() +}) + +test('aggregateRackStats - handles empty results', (t) => { + const stats = aggregateRackStats([]) + t.alike(stats.hashrateByRack, {}) + t.alike(stats.powerByRack, {}) + t.alike(stats.efficiencyByRack, {}) + t.pass() +}) + +// ==================== buildRackList Tests ==================== + +test('buildRackList - builds racks from config', (t) => { + const config = createMockMiningConfig() + const stats = createMockRackStats() + + const racks = buildRackList(config, stats) + + t.is(racks.length, 4, 'should create 2 groups * 2 racks = 4 racks') + t.is(racks[0].id, 'group-1_rack-1') + t.is(racks[0].name, 'Rack 1') + t.is(racks[0].group.id, 'group-1') + t.is(racks[0].group.name, 'Group 1') + t.is(racks[0].miners_count, 20) + t.is(racks[0].efficiency.unit, 'W/TH/s') + t.is(racks[0].hashrate.unit, 'PH/s') + t.is(racks[0].consumption.unit, 'kW') + t.pass() +}) + +test('buildRackList - sequential rack naming across groups', (t) => { + const config = createMockMiningConfig({ total_groups: 3, racks_per_group: 2 }) + const stats = createMockRackStats() + + const racks = buildRackList(config, stats) + + t.is(racks[0].name, 'Rack 1') + t.is(racks[1].name, 'Rack 2') + t.is(racks[2].name, 'Rack 3') + t.is(racks[3].name, 'Rack 4') + t.is(racks[4].name, 'Rack 5') + t.is(racks[5].name, 'Rack 6') + t.pass() +}) + +test('buildRackList - handles missing stats gracefully', (t) => { + const config = createMockMiningConfig({ total_groups: 1, racks_per_group: 1 }) + const stats = { hashrateByRack: {}, powerByRack: {}, efficiencyByRack: {} } + + const racks = buildRackList(config, stats) + + t.is(racks.length, 1) + t.is(racks[0].efficiency.value, 0) + t.is(racks[0].hashrate.value, 0) + t.is(racks[0].consumption.value, 0) + t.pass() +}) + +test('buildRackList - handles empty config', (t) => { + const racks = buildRackList({}, { hashrateByRack: {}, powerByRack: {}, efficiencyByRack: {} }) + t.is(racks.length, 0) + t.pass() +}) + +// ==================== filterByGroups Tests ==================== + +test('filterByGroups - filters to matching groups', (t) => { + const racks = [ + { id: 'group-1_rack-1', group: { id: 'group-1' } }, + { id: 'group-2_rack-1', group: { id: 'group-2' } }, + { id: 'group-3_rack-1', group: { id: 'group-3' } } + ] + + const result = filterByGroups(racks, ['group-1', 'group-3']) + t.is(result.length, 2) + t.is(result[0].id, 'group-1_rack-1') + t.is(result[1].id, 'group-3_rack-1') + t.pass() +}) + +test('filterByGroups - returns empty for no matches', (t) => { + const racks = [{ id: 'group-1_rack-1', group: { id: 'group-1' } }] + const result = filterByGroups(racks, ['group-99']) + t.is(result.length, 0) + t.pass() +}) + +// ==================== filterBySearch Tests ==================== + +test('filterBySearch - matches rack name case-insensitively', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1' }, + { id: 'group-1_rack-2', name: 'Rack 2' }, + { id: 'group-2_rack-1', name: 'Rack 3' } + ] + + const result = filterBySearch(racks, 'rack 1') + t.is(result.length, 1) + t.is(result[0].name, 'Rack 1') + t.pass() +}) + +test('filterBySearch - matches rack id', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1' }, + { id: 'group-2_rack-1', name: 'Rack 3' } + ] + + const result = filterBySearch(racks, 'group-2') + t.is(result.length, 1) + t.is(result[0].id, 'group-2_rack-1') + t.pass() +}) + +test('filterBySearch - returns all on empty match', (t) => { + const racks = [ + { id: 'group-1_rack-1', name: 'Rack 1' } + ] + + const result = filterBySearch(racks, 'xyz') + t.is(result.length, 0) + t.pass() +}) + +// ==================== sortRacks Tests ==================== + +test('sortRacks - sorts by efficiency descending', (t) => { + const racks = [ + { efficiency: { value: 5 } }, + { efficiency: { value: 8 } }, + { efficiency: { value: 3 } } + ] + + sortRacks(racks, { efficiency: -1 }) + t.is(racks[0].efficiency.value, 8) + t.is(racks[1].efficiency.value, 5) + t.is(racks[2].efficiency.value, 3) + t.pass() +}) + +test('sortRacks - sorts by hashrate ascending', (t) => { + const racks = [ + { hashrate: { value: 10 } }, + { hashrate: { value: 5 } }, + { hashrate: { value: 15 } } + ] + + sortRacks(racks, { hashrate: 1 }) + t.is(racks[0].hashrate.value, 5) + t.is(racks[1].hashrate.value, 10) + t.is(racks[2].hashrate.value, 15) + t.pass() +}) + +test('sortRacks - sorts by group', (t) => { + const racks = [ + { group: { id: 'group-3' } }, + { group: { id: 'group-1' } }, + { group: { id: 'group-2' } } + ] + + sortRacks(racks, { group: 1 }) + t.is(racks[0].group.id, 'group-1') + t.is(racks[1].group.id, 'group-2') + t.is(racks[2].group.id, 'group-3') + t.pass() +}) + +// ==================== listExplorerRacks Tests ==================== + +test('listExplorerRacks - returns paginated rack list', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 4 }) + const dcsThing = createMockDcsThing(miningConfig) + const tailLogData = [ + [{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 5000000000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 500000 }, + efficiency_w_ths_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 6 } + }] + ] + + const ctx = createMockCtx(tailLogData, dcsThing) + const req = { query: {} } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 16, 'total should be 4 groups * 4 racks') + t.is(result.data.length, 16, 'default limit should show all when <= 20') + t.is(result.offset, 0) + t.is(result.limit, 20) + t.is(result.hasMore, false) + t.is(result.data[0].id, 'group-1_rack-1') + t.is(result.data[0].group.name, 'Group 1') + t.pass() +}) + +test('listExplorerRacks - filters by group', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { group: 'group-2' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 2, 'should only return racks from group-2') + result.data.forEach(rack => { + t.is(rack.group.id, 'group-2') + }) + t.pass() +}) + +test('listExplorerRacks - filters by multiple groups', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { group: 'group-1,group-3' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 4) + const groupIds = new Set(result.data.map(r => r.group.id)) + t.ok(groupIds.has('group-1')) + t.ok(groupIds.has('group-3')) + t.ok(!groupIds.has('group-2')) + t.pass() +}) + +test('listExplorerRacks - applies search filter', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 4 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { search: 'Rack 1' } } + + const result = await listExplorerRacks(ctx, req) + + // "Rack 1" matches "Rack 1", "Rack 10", "Rack 11", etc. + t.ok(result.totalCount >= 1) + result.data.forEach(rack => { + t.ok(rack.name.toLowerCase().includes('rack 1') || rack.id.toLowerCase().includes('rack 1')) + }) + t.pass() +}) + +test('listExplorerRacks - combined group + search filter', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 4, racks_per_group: 4 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { group: 'group-2', search: 'rack-1' } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 1) + t.is(result.data[0].group.id, 'group-2') + t.ok(result.data[0].id.includes('rack-1')) + t.pass() +}) + +test('listExplorerRacks - applies pagination', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 8, racks_per_group: 4 }) + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { offset: 5, limit: 3 } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 32) + t.is(result.data.length, 3) + t.is(result.offset, 5) + t.is(result.limit, 3) + t.is(result.hasMore, true) + t.pass() +}) + +test('listExplorerRacks - enforces max limit', async (t) => { + const miningConfig = createMockMiningConfig() + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { limit: 500 } } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.limit, 100) + t.pass() +}) + +test('listExplorerRacks - applies sort', async (t) => { + const miningConfig = createMockMiningConfig({ total_groups: 2, racks_per_group: 2 }) + const dcsThing = createMockDcsThing(miningConfig) + const tailLogData = [ + [{ + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { + 'group-1_rack-1': 1000000000, + 'group-1_rack-2': 5000000000, + 'group-2_rack-1': 3000000000, + 'group-2_rack-2': 2000000000 + }, + power_w_pdu_rack_group_sum_aggr: { + 'group-1_rack-1': 100000, + 'group-1_rack-2': 500000, + 'group-2_rack-1': 300000, + 'group-2_rack-2': 200000 + }, + efficiency_w_ths_pdu_rack_group_avg_aggr: {} + }] + ] + + const ctx = createMockCtx(tailLogData, dcsThing) + const req = { query: { sort: '{"hashrate":-1}' } } + + const result = await listExplorerRacks(ctx, req) + + t.ok(result.data[0].hashrate.value >= result.data[1].hashrate.value, 'first rack should have highest hashrate') + t.pass() +}) + +test('listExplorerRacks - handles no DCS (empty rack list)', async (t) => { + const ctx = { + conf: { featureConfig: {} }, + dataProxy: { + requestDataMap: async () => [[{}]] + } + } + const req = { query: {} } + + const result = await listExplorerRacks(ctx, req) + + t.is(result.totalCount, 0) + t.is(result.data.length, 0) + t.pass() +}) + +test('listExplorerRacks - throws on invalid sort JSON', async (t) => { + const miningConfig = createMockMiningConfig() + const dcsThing = createMockDcsThing(miningConfig) + const ctx = createMockCtx([[{}]], dcsThing) + const req = { query: { sort: 'not-json' } } + + try { + await listExplorerRacks(ctx, req) + t.fail('should have thrown') + } catch (err) { + t.ok(err instanceof SyntaxError || err.message.includes('JSON')) + } + t.pass() +}) diff --git a/tests/unit/routes/explorer.routes.test.js b/tests/unit/routes/explorer.routes.test.js new file mode 100644 index 00000000..6c811783 --- /dev/null +++ b/tests/unit/routes/explorer.routes.test.js @@ -0,0 +1,63 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/explorer.routes.js' + +test('explorer routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, 'explorer') + t.pass() +}) + +test('explorer routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/explorer/racks'), 'should have explorer racks route') + + t.pass() +}) + +test('explorer routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const racksRoute = routes.find(r => r.url === '/auth/explorer/racks') + t.is(racksRoute.method, 'GET', 'racks route should be GET') + + t.pass() +}) + +test('explorer routes - schema validation', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const racksRoute = routes.find(r => r.url === '/auth/explorer/racks') + t.ok(racksRoute.schema, 'should have schema') + t.ok(racksRoute.schema.querystring, 'should have querystring schema') + + const props = racksRoute.schema.querystring.properties + t.is(props.group.type, 'string', 'group should be string') + t.is(props.search.type, 'string', 'search should be string') + t.is(props.sort.type, 'string', 'sort should be string') + t.is(props.offset.type, 'integer', 'offset should be integer') + t.is(props.limit.type, 'integer', 'limit should be integer') + + t.pass() +}) + +test('explorer routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, 'explorer') + t.pass() +}) + +test('explorer routes - onRequest functions (auth)', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.ok(typeof route.onRequest === 'function', `explorer route ${route.url} should have onRequest function`) + }) + + t.pass() +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index e4edc58e..629be162 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -165,7 +165,9 @@ const ENDPOINTS = { // Site Overview endpoints SITE_OVERVIEW_GROUPS: '/auth/site/overview/groups', // Site Efficiency endpoint - SITE_EFFICIENCY: '/auth/site/efficiency' + SITE_EFFICIENCY: '/auth/site/efficiency', + // Explorer endpoints + EXPLORER_RACKS: '/auth/explorer/racks' } const HTTP_METHODS = { @@ -612,6 +614,16 @@ const MINER_DEFAULT_FIELDS = { const MINER_MAX_LIMIT = 200 const MINER_DEFAULT_LIMIT = 50 +// Explorer racks aggregation fields +const EXPLORER_RACK_AGGR_FIELDS = { + hashrate_mhs_5m_pdu_rack_group_avg_aggr: 1, + power_w_pdu_rack_group_sum_aggr: 1, + efficiency_w_ths_pdu_rack_group_avg_aggr: 1 +} + +const EXPLORER_RACK_DEFAULT_LIMIT = 20 +const EXPLORER_RACK_MAX_LIMIT = 100 + module.exports = { SUPER_ADMIN_ROLE, GLOBAL_DATA_TYPES, @@ -669,5 +681,8 @@ module.exports = { ENERGY_SYSTEM_PROJECTIONS, SITE_OVERVIEW_AGGR_FIELDS, DCS_POWER_METER_FIELDS, - DCS_EFFICIENCY_FIELDS + DCS_EFFICIENCY_FIELDS, + EXPLORER_RACK_AGGR_FIELDS, + EXPLORER_RACK_DEFAULT_LIMIT, + EXPLORER_RACK_MAX_LIMIT } diff --git a/workers/lib/server/handlers/explorer.handlers.js b/workers/lib/server/handlers/explorer.handlers.js new file mode 100644 index 00000000..ba83d834 --- /dev/null +++ b/workers/lib/server/handlers/explorer.handlers.js @@ -0,0 +1,238 @@ +'use strict' + +const { + extractKeyEntry, + mhsToPhs, + mhsToThs, + mergeGroupedField, + getGroupNumber +} = require('../../metrics.utils') +const { + LOG_KEYS, + WORKER_TYPES, + WORKER_TAGS, + EXPLORER_RACK_AGGR_FIELDS, + EXPLORER_RACK_DEFAULT_LIMIT, + EXPLORER_RACK_MAX_LIMIT, + DCS_POWER_METER_FIELDS +} = require('../../constants') +const { + isCentralDCSEnabled, + getDCSTag, + extractDcsThing +} = require('../../dcs.utils') + +/** + * Aggregates per-rack stats from tailLogMulti results across all orks. + * + * @param {Array} tailLogResults - Array of ork responses from tailLogMulti + * @returns {Object} { hashrateByRack, powerByRack, efficiencyByRack } + */ +function aggregateRackStats (tailLogResults) { + const stats = { + hashrateByRack: {}, + powerByRack: {}, + efficiencyByRack: {} + } + + for (const orkResult of tailLogResults) { + const entry = extractKeyEntry(orkResult, 0) + if (!entry) continue + + mergeGroupedField(stats.hashrateByRack, entry.hashrate_mhs_5m_pdu_rack_group_avg_aggr) + mergeGroupedField(stats.powerByRack, entry.power_w_pdu_rack_group_sum_aggr) + mergeGroupedField(stats.efficiencyByRack, entry.efficiency_w_ths_pdu_rack_group_avg_aggr, true) + } + + return stats +} + +/** + * Builds a flat list of all racks from mining config, enriched with tailLog stats. + * + * @param {Object} miningConfig - DCS mining config (total_groups, racks_per_group, miners_per_rack) + * @param {Object} rackStats - Per-rack stats from aggregateRackStats + * @returns {Array} Array of rack objects + */ +function buildRackList (miningConfig, rackStats) { + const totalGroups = miningConfig?.total_groups || 0 + const racksPerGroup = miningConfig?.racks_per_group || 4 + const minersPerRack = miningConfig?.miners_per_rack || 20 + const racks = [] + + for (let g = 1; g <= totalGroups; g++) { + const groupId = `group-${g}` + const groupName = `Group ${g}` + + for (let r = 1; r <= racksPerGroup; r++) { + const rackKey = `${groupId}_rack-${r}` + const hashrateMhs = rackStats.hashrateByRack[rackKey] || 0 + const powerW = rackStats.powerByRack[rackKey] || 0 + const powerKw = Math.round(powerW / 10) / 100 + const hashrateThs = mhsToThs(hashrateMhs) + const efficiency = hashrateThs > 0 + ? Math.round((powerW / hashrateThs) * 10) / 10 + : rackStats.efficiencyByRack[rackKey] || 0 + + racks.push({ + id: rackKey, + name: `Rack ${((g - 1) * racksPerGroup) + r}`, + group: { id: groupId, name: groupName }, + miners_count: minersPerRack, + efficiency: { value: efficiency, unit: 'W/TH/s' }, + hashrate: { value: mhsToPhs(hashrateMhs), unit: 'PH/s' }, + consumption: { value: powerKw, unit: 'kW' } + }) + } + } + + return racks +} + +/** + * Filters racks by group ids. + * + * @param {Array} racks - Array of rack objects + * @param {Array} groups - Group ids to filter by + * @returns {Array} Filtered racks + */ +function filterByGroups (racks, groups) { + const groupSet = new Set(groups) + return racks.filter(rack => groupSet.has(rack.group.id)) +} + +/** + * Filters racks by search string (matches rack name or id, case-insensitive). + * + * @param {Array} racks - Array of rack objects + * @param {string} search - Search term + * @returns {Array} Filtered racks + */ +function filterBySearch (racks, search) { + const term = search.toLowerCase() + return racks.filter(rack => + rack.name.toLowerCase().includes(term) || + rack.id.toLowerCase().includes(term) + ) +} + +/** + * Sorts racks by the given sort spec. + * Supported fields: efficiency, hashrate, consumption, name, group + * + * @param {Array} racks - Array of rack objects + * @param {Object} sort - Sort spec: { field: 1 or -1 } + * @returns {Array} Sorted racks (mutates the original) + */ +function sortRacks (racks, sort) { + const entries = Object.entries(sort) + if (entries.length === 0) return racks + + return racks.sort((a, b) => { + for (const [field, direction] of entries) { + let aVal, bVal + if (field === 'efficiency' || field === 'hashrate' || field === 'consumption') { + aVal = a[field]?.value + bVal = b[field]?.value + } else if (field === 'group') { + aVal = getGroupNumber(a.group.id) + bVal = getGroupNumber(b.group.id) + } else if (field === 'name') { + aVal = a.name + bVal = b.name + } else { + continue + } + + if (aVal === bVal) continue + if (aVal == null) return direction + if (bVal == null) return -direction + if (aVal < bVal) return -direction + if (aVal > bVal) return direction + } + return 0 + }) +} + +/** + * GET /auth/explorer/racks + * + * Returns a paginated list of racks with per-rack stats. + * Combines DCS mining config (rack structure) with tailLog RTD (per-rack hashrate/power/efficiency). + * + * Query params: + * group - comma-separated group ids to filter by (e.g. "group-2" or "group-1,group-3") + * search - text search on rack name/id + * sort - JSON sort spec (e.g. '{"efficiency":-1}') + * offset - pagination offset (default 0) + * limit - page size (default 20, max 100) + */ +async function listExplorerRacks (ctx, req) { + const tailLogPayload = { + keys: [ + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } + ], + limit: 1, + aggrFields: EXPLORER_RACK_AGGR_FIELDS + } + + const dcsEnabled = isCentralDCSEnabled(ctx) + let dcsPayload = null + if (dcsEnabled) { + const dcsTag = getDCSTag(ctx) + dcsPayload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields: { id: 1, code: 1, type: 1, tags: 1, ...DCS_POWER_METER_FIELDS } + } + } + + const [tailLogResults, dcsResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload), + dcsEnabled ? ctx.dataProxy.requestDataMap('listThings', dcsPayload) : Promise.resolve(null) + ]) + + const rackStats = aggregateRackStats(tailLogResults) + const dcsThing = dcsResults ? extractDcsThing(dcsResults) : null + const miningConfig = dcsThing?.last?.snap?.config?.mining || {} + + let racks = buildRackList(miningConfig, rackStats) + + if (req.query.group) { + const groups = req.query.group.split(',').map(g => g.trim()).filter(Boolean) + if (groups.length > 0) { + racks = filterByGroups(racks, groups) + } + } + + if (req.query.search) { + racks = filterBySearch(racks, req.query.search) + } + + if (req.query.sort) { + const sort = typeof req.query.sort === 'string' ? JSON.parse(req.query.sort) : req.query.sort + sortRacks(racks, sort) + } + + const offset = req.query.offset || 0 + const limit = Math.min(req.query.limit || EXPLORER_RACK_DEFAULT_LIMIT, EXPLORER_RACK_MAX_LIMIT) + const totalCount = racks.length + const page = racks.slice(offset, offset + limit) + + return { + data: page, + totalCount, + offset, + limit, + hasMore: offset + limit < totalCount + } +} + +module.exports = { + listExplorerRacks, + aggregateRackStats, + buildRackList, + filterByGroups, + filterBySearch, + sortRacks +} diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 1c10a56d..2721205f 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -19,6 +19,7 @@ const minersRoutes = require('./routes/miners.routes') const groupsRoutes = require('./routes/groups.routes') const coolingSystemRoutes = require('./routes/coolingSystem.routes') const energySystemRoutes = require('./routes/energySystem.routes') +const explorerRoutes = require('./routes/explorer.routes') /** * Collect all routes into a flat array for server injection. @@ -44,7 +45,8 @@ function routes (ctx) { ...minersRoutes(ctx), ...groupsRoutes(ctx), ...coolingSystemRoutes(ctx), - ...energySystemRoutes(ctx) + ...energySystemRoutes(ctx), + ...explorerRoutes(ctx) ] } diff --git a/workers/lib/server/routes/explorer.routes.js b/workers/lib/server/routes/explorer.routes.js new file mode 100644 index 00000000..eae826f2 --- /dev/null +++ b/workers/lib/server/routes/explorer.routes.js @@ -0,0 +1,44 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS, + AUTH_CAPS, + AUTH_LEVELS +} = require('../../constants') +const { listExplorerRacks } = require('../handlers/explorer.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.EXPLORER_RACKS, + schema: { + querystring: { + type: 'object', + properties: { + group: { type: 'string' }, + search: { type: 'string' }, + sort: { type: 'string' }, + offset: { type: 'integer' }, + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + } + } + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'explorer-racks', + req.query.group, + req.query.search, + req.query.sort, + req.query.offset, + req.query.limit + ], + ENDPOINTS.EXPLORER_RACKS, + listExplorerRacks, + [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] + ) + } +]