diff --git a/.gitignore b/.gitignore index d8552d4..8fb2eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,10 @@ tests/integration/db/* tests/integration/config/* tests/integration/status/* tests/integration/store/* +tests/e2e/db/* +tests/e2e/config/* +tests/e2e/status/* +tests/e2e/store/* +tests/e2e/tmp/* coverage .scannerwork diff --git a/tests/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/alerts.handlers.test.js b/tests/unit/handlers/alerts.handlers.test.js index f6560c5..55d767e 100644 --- a/tests/unit/handlers/alerts.handlers.test.js +++ b/tests/unit/handlers/alerts.handlers.test.js @@ -521,3 +521,140 @@ test('getAlertsHistory - throws on invalid date range', async (t) => { t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw date range error') } }) + +// ==================== device tag (message) — filter & search ==================== + +test('extractAlertsFromThings - preserves message (device tag)', (t) => { + const things = [ + { + id: 'dcs-1', + type: 'dcs-siemens', + code: 'PCS7', + info: { container: 'cont-A' }, + last: { + alerts: [ + { severity: 'warning', name: 'flow_warning', message: 'FIT-7513', description: 'Cooling-loop flow below warning threshold — FIT-7513: 320m³/h (threshold 330m³/h)' } + ] + } + } + ] + + const result = extractAlertsFromThings(things) + t.is(result[0].message, 'FIT-7513', 'should preserve the device tag in message') +}) + +test('flattenHistoryAlert - preserves message (device tag)', (t) => { + const alert = { + name: 'flow_warning', + description: 'Cooling-loop flow below warning threshold — FIT-7513: 320m³/h (threshold 330m³/h)', + severity: 'warning', + uuid: 'abc', + createdAt: 1000, + message: 'FIT-7513', + thing: { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', tags: ['siemens'], info: { container: 'cont-A' } } + } + + const result = flattenHistoryAlert(alert) + t.is(result.message, 'FIT-7513', 'should expose the device tag in the history payload') +}) + +const dcsThings = () => [ + { + id: 'dcs-1', + type: 'dcs-siemens', + code: 'PCS7', + info: { container: 'cont-A' }, + last: { + alerts: [ + { severity: 'warning', name: 'flow_warning', message: 'FIT-7513' }, + { severity: 'critical', name: 'flow_alarm', message: 'FIT-7514' } + ] + } + } +] + +test('extractAlertsFromThings - exposes deviceId (alias of thing id)', (t) => { + const things = [ + { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', info: {}, last: { alerts: [{ severity: 'high', name: 'flow_alarm' }] } } + ] + const result = extractAlertsFromThings(things) + t.is(result[0].deviceId, 'dcs-1', 'should expose deviceId so the deviceId filter works') + t.is(result[0].id, 'dcs-1', 'should keep id for backward compatibility') +}) + +test('getSiteAlerts - filters by deviceId', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => [ + { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', info: { container: 'cont-A' }, last: { alerts: [{ severity: 'high', name: 'a1' }] } }, + { id: 'miner-9', type: 'miner', code: 'S19', info: { container: 'cont-B' }, last: { alerts: [{ severity: 'low', name: 'a2' }] } } + ]) + const mockReq = { query: { filter: JSON.stringify({ deviceId: 'dcs-1' }) } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 1, 'should filter to the one device') + t.is(result.alerts[0].deviceId, 'dcs-1', 'should return only the dcs-1 alert') +}) + +test('getSiteAlerts - filters by exact device tag (message)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsThings()) + const mockReq = { query: { filter: JSON.stringify({ message: 'FIT-7513' }) } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 1, 'should filter to the one matching tag') + t.is(result.alerts[0].message, 'FIT-7513', 'should return the FIT-7513 alert') +}) + +test('getSiteAlerts - filters by multiple device tags (array)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsThings()) + const mockReq = { query: { filter: JSON.stringify({ message: ['FIT-7513', 'FIT-7514'] }) } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 2, 'should match both tags') +}) + +test('getSiteAlerts - searches by device tag (message)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsThings()) + const mockReq = { query: { search: 'fit-7514' } } + + const result = await getSiteAlerts(mockCtx, mockReq) + t.is(result.total, 1, 'should match one alert by tag substring (case-insensitive)') + t.is(result.alerts[0].message, 'FIT-7514', 'should return the FIT-7514 alert') +}) + +const dcsHistory = () => [ + { + uuid: 'h1', + createdAt: 1000, + severity: 'warning', + name: 'flow_warning', + description: 'Cooling-loop flow below warning threshold — FIT-7513: 320m³/h (threshold 330m³/h)', + message: 'FIT-7513', + thing: { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', tags: ['siemens'], info: { container: 'cont-A' } } + }, + { + uuid: 'h2', + createdAt: 2000, + severity: 'critical', + name: 'flow_alarm', + description: 'Cooling-loop flow below alarm threshold — FIT-7514: 295m³/h (threshold 300m³/h)', + message: 'FIT-7514', + thing: { id: 'dcs-1', type: 'dcs-siemens', code: 'PCS7', tags: ['siemens'], info: { container: 'cont-A' } } + } +] + +test('getAlertsHistory - filters by exact device tag (message)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsHistory()) + const mockReq = { query: { start: 1, end: 5000, filter: JSON.stringify({ message: 'FIT-7514' }) } } + + const result = await getAlertsHistory(mockCtx, mockReq) + t.is(result.total, 1, 'should filter history to the matching tag') + t.is(result.alerts[0].message, 'FIT-7514', 'should return the FIT-7514 history alert') +}) + +test('getAlertsHistory - searches by device tag (message)', async (t) => { + const mockCtx = createMockCtxWithOrks([{ rpcPublicKey: 'key1' }], async () => dcsHistory()) + const mockReq = { query: { start: 1, end: 5000, search: 'FIT-7513' } } + + const result = await getAlertsHistory(mockCtx, mockReq) + t.is(result.total, 1, 'should find one history alert by tag') + t.is(result.alerts[0].message, 'FIT-7513', 'should return the FIT-7513 history alert') +}) diff --git a/tests/unit/handlers/coolingSystem.handlers.test.js b/tests/unit/handlers/coolingSystem.handlers.test.js index 7fe3bd1..3b88359 100644 --- a/tests/unit/handlers/coolingSystem.handlers.test.js +++ b/tests/unit/handlers/coolingSystem.handlers.test.js @@ -97,6 +97,10 @@ const createMockEquipment = () => ({ { equipment: 'VT-7501', value: 0.6, unit: 'mm/s', status: 'Normal' }, { equipment: 'VT-7503', value: 0.8, unit: 'mm/s', status: 'Normal' } ], + vibration_switches: [ + { equipment: 'VS-7581', state: 'ok' }, + { equipment: 'VS-7591', state: 'error' } + ], flow_switches: [ { equipment: 'FS-7501', is_active: true }, { equipment: 'FS-7502', is_active: true } @@ -165,6 +169,7 @@ const createMockConfig = () => ({ }, tower_level_sensor: 'LIT-7501', tower_vibration_sensor: 'VT-7501', + tower_vibration_switch: 'VS-7581', tower_fan: 'FAN-7501', heat_exchangers: { 'tc-7501': { miner_side_out_sensor: 'TS-7521', tower_side_in_sensor: 'TS-7513', tower_side_out_sensor: 'TS-7515' }, @@ -206,13 +211,23 @@ const createMockConfig = () => ({ }, control_valves: { pressure_bypass: 'PCV-7501' - } + }, + fan_coils: [ + { id: 'FC-7513', valve_tag: 'TCV-7501A', temperature_tag: 'TT-7501C' }, + { id: 'FC-7514', valve_tag: 'TCV-7501B', temperature_tag: 'TT-7501C' }, + { id: 'FC-7529', valve_tag: 'TCV-7501C', temperature_tag: 'TT-7501C' }, + { id: 'FC-7530', valve_tag: 'TCV-7501D', temperature_tag: 'TT-7501C' } + ] }, hvac_condenser: { name: 'Circuit 2 - Condenser Water Loop', defaults: { supply_temp: { value: 29, unit: '°C' }, return_temp: { value: 39, unit: '°C' } + }, + tower: { + level_sensor: 'LIT-7504', + vibration_switch: 'VS-7591' } }, ambient: { @@ -412,7 +427,9 @@ test('buildMinersCircuit2View - builds view from enriched equipment', (t) => { t.ok(view.cooling_towers[0].level.unit, 'level should have unit') // Check tower sensor refs t.ok(view.cooling_towers[0].level_sensor, 'should have level_sensor ref') - t.ok(view.cooling_towers[0].vibration_sensor, 'should have vibration_sensor ref') + t.is(view.cooling_towers[0].vibration, undefined, 'numeric vibration removed') + t.is(view.cooling_towers[0].vibration_switch.tag, 'VS-7581', 'vibration_switch tag from config') + t.is(view.cooling_towers[0].vibration_switch.state, 'ok', 'vibration_switch state from DI') t.pass() }) @@ -1166,18 +1183,21 @@ test('buildHvacAmbientView - rooms with no matching fan coils or humidity', (t) t.pass() }) -test('buildHvacAmbientView - room with fan coils that have zero temperature', (t) => { +test('buildHvacAmbientView - 11 keeps valid zero/low temps, drops only missing', (t) => { const equipment = createEmptyEquipment() equipment.fan_coils = [ - { equipment: 'FC-1', is_running: false, temperature: { value: 0, unit: '°C' }, valve_position: { value: 0, unit: '%' } } + // valid cold reading (kept) + a unit with no reading (dropped) + { equipment: 'FC-1', is_running: true, temperature: { value: 0, unit: '°C' }, valve_position: { value: 0, unit: '%' } }, + { equipment: 'FC-2', is_running: false, temperature: { value: null, unit: '°C' }, valve_position: { value: 0, unit: '%' } } ] const config = createMinimalConfig() config.cooling_system.ambient = { - rooms: [{ name: 'Cold Room', fan_coils: ['FC-1'], humidity_sensors: [] }] + rooms: [{ name: 'Cold Room', fan_coils: ['FC-1', 'FC-2'], humidity_sensors: [] }] } const view = buildHvacAmbientView(equipment, config, {}) - t.is(view.rooms[0].temperature, null, 'zero temp filtered out') + t.ok(view.rooms[0].temperature, 'valid zero temp is not hidden') + t.is(view.rooms[0].temperature.value, 0, 'averages the real 0°C reading') t.pass() }) @@ -1251,3 +1271,183 @@ test('getCoolingSystemData - miners ambient is invalid view', async (t) => { } t.pass() }) + +test('1 - renamed level/valve tags resolve from config', (t) => { + const equipment = createMockEquipment() + equipment.levels = [ + { equipment: 'LT-7501', value: 76, unit: '%' }, + { equipment: 'LIT-7581', value: 82, unit: '%' } + ] + const config = createMockConfig() + config.cooling_system.cooling_tower_loop.tower_level_sensor = 'LIT-7581' + config.cooling_system.cooling_tower_loop.makeup.level_sensor = 'LT-7501' + config.cooling_system.cooling_tower_loop.makeup.level_control_valve = 'LCV-7501' + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.summary.tower_level.value, 82, 'tower level resolves via renamed LIT-7581') + t.is(view.makeup.tank.level.value, 76, 'makeup level resolves via renamed LT-7501') + t.is(view.makeup.level_control_valve.id, 'LCV-7501', 'makeup LCV uses renamed id') + t.pass() +}) + +test('2 - fan-coil map yields the configured TCV/TT tags', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit1View(equipment, config) + + const fc13 = view.fan_coils.units.find(u => u.id === 'FC-7513') + t.is(fc13.valve_tag, 'TCV-7501A', 'valve_tag from map (not PIV-7513)') + t.is(fc13.temperature_tag, 'TT-7501C', 'temperature_tag from map (grouped, not TT-7513)') + const fc29 = view.fan_coils.units.find(u => u.id === 'FC-7529') + t.is(fc29.valve_tag, 'TCV-7501C', 'grouped unit gets its own TCV') + t.pass() +}) + +test('2 - fan-coil with no map entry yields null tags (no fabrication)', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + config.cooling_system.hvac_chilled_water.fan_coils = [] + const view = buildHvacCircuit1View(equipment, config) + + t.is(view.fan_coils.units[0].valve_tag, null, 'no derived PIV tag') + t.is(view.fan_coils.units[0].temperature_tag, null, 'no derived TT tag') + t.pass() +}) + +test('3 - miners tower exposes vibration_switch from DI, drops numeric', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.cooling_towers[0].vibration, undefined, 'numeric vibration removed') + t.is(view.cooling_towers[0].vibration_switch.tag, 'VS-7581', 'switch tag') + t.is(view.cooling_towers[0].vibration_switch.state, 'ok', 'state from DI') + t.pass() +}) + +test('3 - hvac tower vibration_switch reflects error DI', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit2View(equipment, config) + + t.is(view.cooling_towers[0].vibration_switch.tag, 'VS-7591', 'hvac switch tag') + t.is(view.cooling_towers[0].vibration_switch.state, 'error', 'state from DI') + t.pass() +}) + +test('3 - vibration_switch state is null when DI absent (no fabrication)', (t) => { + const equipment = createMockEquipment() + equipment.vibration_switches = [] // DI not provisioned + const config = createMockConfig() + const view = buildMinersCircuit2View(equipment, config) + + t.is(view.cooling_towers[0].vibration_switch.tag, 'VS-7581', 'tag still surfaced') + t.is(view.cooling_towers[0].vibration_switch.state, null, 'state null, never fabricated') + t.pass() +}) + +test('4 - pressure_bypass exposes pressure (PIT-7501) and tag-less aperture', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit1View(equipment, config) + + const pb = view.control_valves.pressure_bypass + t.ok(pb.pressure, 'should have pressure') + t.is(pb.pressure.tag, 'PIT-7501', 'pressure tag') + t.is(pb.pressure.value, 3.1, 'pressure value from PIT-7501') + t.is(pb.pressure.unit, 'bar', 'pressure unit') + t.absent(pb.position?.tag, 'aperture position stays tag-less') + t.pass() +}) + +test('5 - HVAC pumps include current {value, unit}', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildHvacCircuit1View(equipment, config) + + t.ok(view.return_pumps.length > 0, 'has return pumps') + t.ok(view.return_pumps[0].current, 'return pump has current') + t.is(view.return_pumps[0].current.unit, 'A', 'current in amps') + t.pass() +}) + +// 6 — per-group differential pressure + summary averages +test('6 - differential_pressure empty by default, summary avgs from line PTs', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersCircuit1View(equipment, config) + + t.alike(view.lines[0].differential_pressure, [], 'empty until per-group PTs provisioned') + t.ok(view.summary.inlet_pressure_avg, 'has inlet_pressure_avg') + t.ok(view.summary.outlet_pressure_avg, 'has outlet_pressure_avg') + t.ok(view.summary.delta_p_avg, 'has delta_p_avg') + t.is(view.summary.inlet_pressure_avg.value, 2.8, 'inlet avg from supply PTs') + t.is(view.summary.outlet_pressure_avg.value, 1.35, 'outlet avg from return PTs') + t.is(view.summary.delta_p_avg.value, 1.45, 'delta-p avg') + t.pass() +}) + +test('6 - differential_pressure populated when per-group PTs configured', (t) => { + const equipment = createMockEquipment() + equipment.pressures = [ + ...equipment.pressures, + { equipment: 'PT-7501A', value: 3.0, unit: 'bar' }, + { equipment: 'PT-7501B', value: 1.2, unit: 'bar' } + ] + const config = createMockConfig() + config.cooling_system.miner_loop.line1.group_pressure_sensors = { + supply: ['PT-7501A'], + return: ['PT-7501B'] + } + const view = buildMinersCircuit1View(equipment, config) + + const dp = view.lines[0].differential_pressure + t.is(dp.length, 1, 'one group row') + t.is(dp[0].supply.tag, 'PT-7501A', 'supply PT tag') + t.is(dp[0].return.tag, 'PT-7501B', 'return PT tag') + t.is(dp[0].delta_p.value, 1.8, 'group delta-p = supply - return') + t.pass() +}) + +test('7 - rack_statuses derived from live rack power (online = power > 0)', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const rackPowerByRack = { + 'group-1_rack-1': 1200, + 'group-1_rack-2': 0, + 'group-1_rack-3': 800 + } + const view = buildMinersLayoutView(equipment, config, {}, rackPowerByRack) + + t.alike(view.mining_room.groups[0].rack_statuses, [true, false, true, false], 'online iff live power > 0') + t.pass() +}) + +test('7 - rack_statuses omitted when miner RTD not joined (no fake all-online)', (t) => { + const equipment = createMockEquipment() + const config = createMockConfig() + const view = buildMinersLayoutView(equipment, config, {}) + + t.is(view.mining_room.groups[0].rack_statuses, undefined, 'absent rather than fabricated') + t.pass() +}) + +test('getCoolingSystemData - miners layout joins rack RTD into rack_statuses', async (t) => { + const snapData = createMockSnapData() + const dcsResponse = [[{ id: 'dcs-1', type: 'dcs', tags: ['t-dcs'], last: { snap: snapData } }]] + const rtdResponse = [[[{ power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 1500, 'group-1_rack-2': 0 } }]]] + + const ctx = { + conf: { featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } }, orks: [{ rpcPublicKey: 'k' }] }, + dataProxy: { + requestDataMap: async (method) => (method === 'tailLogMulti' ? rtdResponse : dcsResponse) + } + } + const result = await getCoolingSystemData(ctx, { query: { type: 'miners', view: 'layout' } }) + + const statuses = result.data.mining_room.groups[0].rack_statuses + t.ok(Array.isArray(statuses), 'rack_statuses present on layout') + t.is(statuses[0], true, 'rack-1 online (power 1500)') + t.is(statuses[1], false, 'rack-2 offline (power 0)') + t.pass() +}) diff --git a/tests/unit/handlers/energySystem.handlers.test.js b/tests/unit/handlers/energySystem.handlers.test.js new file mode 100644 index 0000000..433819a --- /dev/null +++ b/tests/unit/handlers/energySystem.handlers.test.js @@ -0,0 +1,40 @@ +'use strict' + +const test = require('brittle') +const { + buildLayoutView, + buildMinersView +} = require('../../../workers/lib/server/handlers/energy.system.handlers') + +const siteMeter = () => ({ + equipment: 'PM-MV', + role: 'site_main', + name: 'Site PM', + power: { value: 8412, unit: 'kW' }, + current: { + l1: { value: 100, unit: 'A' }, + l2: { value: 110, unit: 'A' }, + l3: { value: 120, unit: 'A' }, + total: { value: 330, unit: 'A' } + } +}) + +test('BE-8 - energy layout site_pm exposes current.total from BE', (t) => { + const equipment = { power_meters: [siteMeter()], protection_relays: [], transformers: [], distribution_boards: [] } + const config = { energy_layout: {} } + const view = buildLayoutView(equipment, config, {}) + + t.ok(view.site_pm, 'has site_pm') + t.ok(view.site_pm.current, 'site_pm has current') + t.is(view.site_pm.current.total.value, 330, 'current.total comes from BE') + t.is(view.site_pm.current.total.unit, 'A', 'current.total has unit') + t.pass() +}) + +test('BE-8 - miners view site_total resolves from site_main meter', (t) => { + const equipment = { power_meters: [siteMeter()] } + const view = buildMinersView(equipment, {}, {}) + + t.ok(view.site_total, 'has site_total') + t.pass() +}) diff --git a/tests/unit/handlers/metricsCooling.handlers.test.js b/tests/unit/handlers/metricsCooling.handlers.test.js new file mode 100644 index 0000000..cf171c2 --- /dev/null +++ b/tests/unit/handlers/metricsCooling.handlers.test.js @@ -0,0 +1,100 @@ +'use strict' + +const test = require('brittle') +const { + getCooling, + processCoolingData, + calculateCoolingSummary +} = require('../../../workers/lib/server/handlers/metrics.handlers') + +const rtdResults = () => ([ + [ + { + ts: 1000, + miner_supply_temp_c: 37.2, + miner_return_temp_c: 47.1, + miner_flow_m3h: 384, + system_pressure_bar: 2.8, + hvac_supply_temp_c: 7.1, + hvac_return_temp_c: 14.2, + chiller_running: 1, + towers_running: 2, + pumps_running: 7 + }, + { + ts: 2000, + miner_supply_temp_c: 37.8, + miner_return_temp_c: 47.5, + miner_flow_m3h: 380, + system_pressure_bar: 2.9, + hvac_supply_temp_c: 7.3, + hvac_return_temp_c: 14.0, + chiller_running: 0.5, + towers_running: 2, + pumps_running: 6 + } + ] +]) + +test('BE-10 - processCoolingData shapes log rows with delta-t and uptime', (t) => { + const log = processCoolingData(rtdResults(), null) + + t.is(log.length, 2, 'two time points') + t.is(log[0].ts, 1000, 'sorted by ts') + t.is(log[0].minerSupplyTempC, 37.2, 'supply temp') + t.is(log[0].minerDeltaTC, 9.9, 'delta-t = return - supply') + t.is(log[0].chillerUptimePct, 100, 'chiller_running 1 -> 100%') + t.is(log[1].chillerUptimePct, 50, 'chiller_running 0.5 -> 50%') + t.pass() +}) + +test('BE-10 - calculateCoolingSummary averages present values', (t) => { + const log = processCoolingData(rtdResults(), null) + const summary = calculateCoolingSummary(log) + + t.is(summary.avgMinerSupplyTempC, 37.5, 'avg supply temp') + t.is(summary.chillerUptimePct, 75, 'avg chiller uptime') + t.ok(summary.avgMinerDeltaTC != null, 'has avg delta-t') + t.pass() +}) + +test('BE-10 - getCooling returns { interval, log, summary }', async (t) => { + const ctx = { + conf: { featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } }, + dataProxy: { requestData: async () => rtdResults() } + } + const res = await getCooling(ctx, { query: { start: 1000, end: 2000, interval: 'hourly' } }) + + t.ok(res.log.length === 2, 'has log') + t.ok(res.summary, 'has summary') + t.is(res.interval, '1h', 'hourly alias mapped to 1h') + t.pass() +}) + +test('BE-10 - getCooling throws when central DCS disabled', async (t) => { + const ctx = { + conf: { featureConfig: { centralDCSSetup: { enabled: false } } }, + dataProxy: { requestData: async () => [] } + } + try { + await getCooling(ctx, { query: { start: 1000, end: 2000 } }) + t.fail('should throw') + } catch (err) { + t.is(err.message, 'ERR_FEATURE_NOT_ENABLED') + } + t.pass() +}) + +test('BE-10 - getCooling validates start/end', async (t) => { + const ctx = { + conf: { featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } }, + dataProxy: { requestData: async () => [] } + } + try { + await getCooling(ctx, { query: {} }) + t.fail('should throw') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END') + } + t.pass() +}) diff --git a/tests/unit/handlers/minerLogs.handlers.test.js b/tests/unit/handlers/minerLogs.handlers.test.js index 487c8dc..552171c 100644 --- a/tests/unit/handlers/minerLogs.handlers.test.js +++ b/tests/unit/handlers/minerLogs.handlers.test.js @@ -3,7 +3,8 @@ const test = require('brittle') const { startMinerLogDownload, - getMinerLogDownloadStatus + getMinerLogDownloadStatus, + getMinerLogFile } = require('../../../workers/lib/server/handlers/minerLogs.handlers') // ───────────────────────────────────────────────────────────────────────────── @@ -302,6 +303,101 @@ test('getMinerLogDownloadStatus - returns 500 on unexpected dataProxy error', as t.pass() }) +test('getMinerLogDownloadStatus - failed status carries the worker error code and a readable message', async (t) => { + const workerErrors = [ + 'ERR_DOWNLOAD_LOGS_PARSE_FAILED', + 'ERR_DOWNLOAD_LOGS_FAILED: Code 45', + 'ERR_DOWNLOAD_LOGS_EMPTY', + 'ERR_DOWNLOAD_LOGS_TIMEOUT', + 'ERR_DOWNLOAD_LOGS_INCOMPLETE: connection closed after 10/100 bytes', + 'ERR_DOWNLOAD_LOGS_CONNECT_FAILED: connect ECONNREFUSED' + ] + + for (const errMsg of workerErrors) { + const action = { + targets: { + 'rack-001': { calls: [{ result: { success: false, error_msg: errMsg } }] } + } + } + const ctx = { + authLib: { getTokenPerms: async () => ({}) }, + dataProxy: { requestData: async () => [action] } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogDownloadStatus(ctx, req, reply) + + t.is(reply.body.status, 'failed', `status should be failed for ${errMsg}`) + t.is(reply.body.error, errMsg, 'error should carry the raw worker error code') + t.ok(typeof reply.body.message === 'string' && reply.body.message.length > 0, + `should include a human-readable message for ${errMsg}`) + t.not(reply.body.message, errMsg, 'message should not just repeat the raw code') + } + t.pass() +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getMinerLogFile +// ───────────────────────────────────────────────────────────────────────────── + +test('getMinerLogFile - bridges :jobId to downloadLogFile and returns 404 for unknown action', async (t) => { + let capturedParams = null + const ctx = { + dataProxy: { + requestData: async (method, params) => { + capturedParams = { method, params } + return [] + } + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogFile(ctx, req, reply) + + t.is(capturedParams.method, 'getAction', 'should fetch the action') + t.is(capturedParams.params.id, '42', 'should pass the jobId as the action id') + t.is(reply.statusCode, 404, 'should return 404 for unknown action') + t.is(reply.body.error, 'ERR_ACTION_NOT_FOUND', 'should return ERR_ACTION_NOT_FOUND') + t.pass() +}) + +test('getMinerLogFile - returns 410 when log TTL has expired', async (t) => { + const ctx = { + dataProxy: { + requestData: async () => [makeActionResult({ data: { expiresAt: Date.now() - 1000 } })] + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogFile(ctx, req, reply) + + t.is(reply.statusCode, 410, 'should return 410 Gone') + t.is(reply.body.error, 'ERR_LOG_EXPIRED', 'should return ERR_LOG_EXPIRED') + t.pass() +}) + +test('getMinerLogFile - returns 503 when the log peer is unreachable', async (t) => { + const ctx = { + dataProxy: { + requestData: async () => [makeActionResult()] + }, + logDownloader: { + stream: async () => { throw new Error('ERR_LOG_PEER_TIMEOUT') } + } + } + const req = makeMockReq('miner-001', '42') + const reply = makeMockReply() + + await getMinerLogFile(ctx, req, reply) + + t.is(reply.statusCode, 503, 'should return 503 when peer unreachable') + t.is(reply.body.error, 'ERR_LOG_PEER_TIMEOUT', 'should propagate the peer error code') + t.pass() +}) + test('getMinerLogDownloadStatus - finds successful result across multiple racks', async (t) => { const expiresAt = Date.now() + 3600000 const action = { diff --git a/tests/unit/handlers/power.consumption.handlers.test.js b/tests/unit/handlers/power.consumption.handlers.test.js new file mode 100644 index 0000000..27137e6 --- /dev/null +++ b/tests/unit/handlers/power.consumption.handlers.test.js @@ -0,0 +1,489 @@ +'use strict' + +const test = require('brittle') +const { + getSitePowerConsumption, + getChartType, + removeContainerPrefix, + getPowerBEAttribute, + getByPath, + buildConsumptionLog, + computeConsumptionSummary +} = require('../../../workers/lib/server/handlers/power.consumption.handlers') +const { withDataProxy } = require('../helpers/mockHelpers') + +// Build a mock ctx whose RPC layer branches by method. `tailLog` returns the +// given points array (single site); `listThings` returns the given things array. +const buildCtx = ({ tailLogPoints = [], listThings = [], onTailLog } = {}) => { + return withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'tailLog') { + if (onTailLog) onTailLog(payload) + return tailLogPoints + } + if (method === 'listThings') return listThings + return [] + } + } + }) +} + +const RANGE = { start: 1700000000000, end: 1700100000000 } + +// ==================== Pure helper tests ==================== + +test('getChartType - resolves type from tag', (t) => { + t.is(getChartType('t-miner'), 'miner', 'miner tag -> miner') + t.is(getChartType('t-powermeter'), 'powermeter', 'powermeter tag -> powermeter (t- stripped)') + t.is(getChartType('container-9a'), 'container', 'container tag -> container') + t.pass() +}) + +test('removeContainerPrefix - strips leading container- only', (t) => { + t.is(removeContainerPrefix('container-9a'), '9a', 'strips container- prefix') + t.is(removeContainerPrefix('t-powermeter'), 't-powermeter', 'leaves non-matching unchanged') + t.pass() +}) + +test('getPowerBEAttribute - mirrors the UI conditional', (t) => { + t.is(getPowerBEAttribute('t-miner'), 'power_w_sum_aggr', 'miner -> power_w_sum_aggr') + t.is(getPowerBEAttribute('t-powermeter'), 'site_power_w', 'powermeter -> site_power_w') + t.is(getPowerBEAttribute('container-9a'), 'container_power_w_aggr.9a', 'container -> nested attr') + t.is(getPowerBEAttribute('t-miner', true), 'transformer_power_w', 'transformer flag -> transformer_power_w') + t.pass() +}) + +test('getByPath - reads dotted paths', (t) => { + t.is(getByPath({ a: { b: 5 } }, 'a.b'), 5, 'nested value') + t.is(getByPath({ container_power_w_aggr: { '9a': 42 } }, 'container_power_w_aggr.9a'), 42, 'aggr path') + t.is(getByPath(null, 'a.b'), undefined, 'null object -> undefined') + t.is(getByPath({ a: 1 }, 'a.b'), undefined, 'missing nested -> undefined') + t.pass() +}) + +test('computeConsumptionSummary - raw min/max/avg with static unit', (t) => { + const points = [ + { ts: 1, power_w_sum_aggr: 1000 }, + { ts: 2, power_w_sum_aggr: 3000 }, + { ts: 3, power_w_sum_aggr: 2000 } + ] + const summary = computeConsumptionSummary(points, 'power_w_sum_aggr') + t.is(summary.min.value, 1000, 'min over raw values') + t.is(summary.max.value, 3000, 'max over raw values') + t.is(summary.avg.value, 2000, 'avg = total / count') + t.is(summary.min.unit, 'W', 'static unit on min') + t.is(summary.avg.unit, 'W', 'static unit on avg') + t.pass() +}) + +test('computeConsumptionSummary - empty range yields null min/max/avg', (t) => { + const summary = computeConsumptionSummary([], 'power_w_sum_aggr') + t.is(summary.min.value, null, 'min null on empty') + t.is(summary.max.value, null, 'max null on empty') + t.is(summary.avg.value, null, 'avg null on empty') + t.is(summary.avg.unit, 'W', 'unit still present on empty') + t.pass() +}) + +test('computeConsumptionSummary - missing attribute counts as 0', (t) => { + const points = [ + { ts: 1, power_w_sum_aggr: 4000 }, + { ts: 2 }, // missing attribute -> 0 + { ts: 3, power_w_sum_aggr: 2000 } + ] + const summary = computeConsumptionSummary(points, 'power_w_sum_aggr') + t.is(summary.min.value, 0, 'missing point pulls min to 0') + t.is(summary.max.value, 4000, 'max unaffected') + t.is(summary.avg.value, 2000, 'avg = (4000+0+2000)/3') + t.pass() +}) + +test('buildConsumptionLog - maps points to ts/value/unit', (t) => { + const points = [{ ts: 1, power_w_sum_aggr: 1000 }, { ts: 2 }] + const log = buildConsumptionLog(points, 'power_w_sum_aggr') + t.alike(log, [ + { ts: 1, value: 1000, unit: 'W' }, + { ts: 2, value: 0, unit: 'W' } + ], 'missing value falls back to 0') + t.pass() +}) + +// ==================== Handler happy paths ==================== + +test('getSitePowerConsumption - miner happy path (power_w_sum_aggr)', async (t) => { + let captured = null + const ctx = buildCtx({ + tailLogPoints: [ + { ts: 1, power_w_sum_aggr: 1000 }, + { ts: 2, power_w_sum_aggr: 3000 }, + { ts: 3, power_w_sum_aggr: 2000 } + ], + onTailLog: (payload) => { captured = payload } + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-miner', interval: '1m' } + }) + + // request shape mirrors the UI's tail-log fetch + t.is(captured.key, 'stat-1m', 'builds stat- key') + t.is(captured.type, 'miner', 'derives type from tag') + t.is(captured.tag, 't-miner', 'passes tag through') + t.is(captured.aggrFields.power_w_sum_aggr, 1, 'requests power_w_sum_aggr') + t.is(captured.aggrFields.site_power_w, 1, 'sends all four aggr fields like the UI') + t.is(captured.fields['last.snap.stats.power_w'], 1, 'requests power_w field') + + t.is(result.log.length, 3, 'one log point per entry') + t.alike(result.log[0], { ts: 1, value: 1000, unit: 'W' }, 'first point mapped') + t.is(result.summary.min.value, 1000, 'min') + t.is(result.summary.max.value, 3000, 'max') + t.is(result.summary.avg.value, 2000, 'avg') + t.is(result.summary.current.value, 2000, 'current = last point value for miner') + t.is(result.summary.current.unit, 'W', 'current has static unit') + t.pass() +}) + +test('getSitePowerConsumption - powermeter pulls current from list-things', async (t) => { + let listThingsCalled = false + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') { + return [ + { ts: 1, site_power_w: 5000 }, + { ts: 2, site_power_w: 7000 } + ] + } + if (method === 'listThings') { + listThingsCalled = true + return [{ id: 'pm1', last: { snap: { stats: { power_w: 9999 } } } }] + } + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-powermeter', interval: '5m' } + }) + + t.ok(listThingsCalled, 'fetches the conditional site power-meter source') + t.is(result.summary.min.value, 5000, 'min over site_power_w') + t.is(result.summary.max.value, 7000, 'max over site_power_w') + t.is(result.summary.avg.value, 6000, 'avg over site_power_w') + t.is(result.summary.current.value, 9999, 'current = live site power-meter value, not last point') + t.pass() +}) + +test('getSitePowerConsumption - miner branch does NOT call list-things', async (t) => { + let listThingsCalled = false + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') return [{ ts: 1, power_w_sum_aggr: 1234 }] + if (method === 'listThings') { listThingsCalled = true; return [] } + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.absent(listThingsCalled, 'no conditional source fetch for non-powermeter tags') + t.is(result.summary.current.value, 1234, 'current = last point value') + t.pass() +}) + +test('getSitePowerConsumption - container uses nested attribute', async (t) => { + const ctx = buildCtx({ + tailLogPoints: [ + { ts: 1, container_power_w_aggr: { '9a': 100 } }, + { ts: 2, container_power_w_aggr: { '9a': 300 } } + ] + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 'container-9a' } + }) + + t.is(result.log[0].value, 100, 'reads container_power_w_aggr.9a') + t.is(result.summary.max.value, 300, 'max over nested values') + t.is(result.summary.current.value, 300, 'current = last nested value') + t.pass() +}) + +test('getSitePowerConsumption - transformer flag selects transformer_power_w', async (t) => { + const ctx = buildCtx({ + tailLogPoints: [{ ts: 1, transformer_power_w: 8000 }] + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-miner', totalTransformerConsumption: true } + }) + + t.is(result.log[0].value, 8000, 'reads transformer_power_w when flagged') + t.is(result.summary.avg.value, 8000, 'avg over transformer values') + t.pass() +}) + +test('getSitePowerConsumption - powerAttribute query param overrides selection', async (t) => { + const ctx = buildCtx({ + tailLogPoints: [{ ts: 1, my_custom_attr: 555, power_w_sum_aggr: 111 }] + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-miner', powerAttribute: 'my_custom_attr' } + }) + + t.is(result.log[0].value, 555, 'uses the override attribute') + t.pass() +}) + +// ==================== Edge cases ==================== + +test('getSitePowerConsumption - empty range', async (t) => { + const ctx = buildCtx({ tailLogPoints: [] }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.is(result.log.length, 0, 'empty log') + t.is(result.summary.min.value, null, 'min null') + t.is(result.summary.max.value, null, 'max null') + t.is(result.summary.avg.value, null, 'avg null') + t.is(result.summary.current.value, 0, 'current defaults to 0 when no points') + t.pass() +}) + +test('getSitePowerConsumption - non-array RPC result is treated as empty', async (t) => { + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { jRequest: async () => ({}) } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.is(result.log.length, 0, 'empty log on malformed result') + t.is(result.summary.avg.value, null, 'avg null on malformed result') + t.pass() +}) + +test('getSitePowerConsumption - powermeter with missing power-meter -> current 0', async (t) => { + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') return [{ ts: 1, site_power_w: 4200 }] + if (method === 'listThings') return [] // no site power meter found + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-powermeter' } }) + + t.is(result.summary.current.value, 0, 'current falls back to 0 when no power meter present') + t.is(result.summary.max.value, 4200, 'log/summary still computed from tail-log') + t.pass() +}) + +test('getSitePowerConsumption - missing start throws', async (t) => { + const ctx = buildCtx({}) + try { + await getSitePowerConsumption(ctx, { query: { end: RANGE.end, tag: 't-miner' } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_MISSING_START_END', 'throws missing start/end') + } + t.pass() +}) + +test('getSitePowerConsumption - invalid range throws', async (t) => { + const ctx = buildCtx({}) + try { + await getSitePowerConsumption(ctx, { query: { start: RANGE.end, end: RANGE.start } }) + t.fail('should have thrown') + } catch (err) { + t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'throws invalid range') + } + t.pass() +}) + +// ==================== Parity with the UI's combining logic ==================== + +test('getSitePowerConsumption - matches getConsumptionGraphData min/max/avg', async (t) => { + const points = [ + { ts: 1, power_w_sum_aggr: 1500 }, + { ts: 2, power_w_sum_aggr: 4200 }, + { ts: 3, power_w_sum_aggr: 0 }, + { ts: 4, power_w_sum_aggr: 3300 } + ] + const attr = 'power_w_sum_aggr' + + // Replica of the raw (pre-format) arithmetic in ConsumptionLineChart's + // getConsumptionGraphData. + let totalAvgConsumption = 0 + let minConsumption = Number.MAX_SAFE_INTEGER + let maxConsumption = Number.MIN_SAFE_INTEGER + const expectedLog = [] + points.forEach((entry) => { + const sum = entry[attr] || 0 + expectedLog.push({ ts: entry.ts, value: sum, unit: 'W' }) + totalAvgConsumption += sum + if (sum < minConsumption) minConsumption = sum + if (sum > maxConsumption) maxConsumption = sum + }) + const expectedAvg = totalAvgConsumption / (points.length || 1) + + const ctx = buildCtx({ tailLogPoints: points }) + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.alike(result.log, expectedLog, 'log points match the UI mapping') + t.is(result.summary.min.value, minConsumption, 'min matches UI') + t.is(result.summary.max.value, maxConsumption, 'max matches UI') + t.is(result.summary.avg.value, expectedAvg, 'avg matches UI') + t.pass() +}) + +// ==================== Central DCS branch ==================== + +// Mock ctx with centralDCSSetup enabled. jRequest branches by method: +// listThings -> the DCS thing; tailLog -> the site_power_w stat series. +const buildDcsCtx = ({ tailLogPoints = [], dcsThing = null, onTailLog, tag = 't-dcs', tailLogThrows = false } = {}) => { + return withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: true, tag } } + }, + net_r0: { + jRequest: async (key, method, payload) => { + if (method === 'listThings') return dcsThing ? [dcsThing] : [] + if (method === 'tailLog') { + if (onTailLog) onTailLog(payload) + if (tailLogThrows) throw new Error('ERR_TYPE_AGGR_INVALID') + return tailLogPoints + } + return [] + } + } + }) +} + +const makeDcsThing = (siteKw) => ({ + type: 'dcs-siemens', + last: { + snap: { + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-RACK-1', role: 'rack', power: { value: 50, unit: 'kW' } }, + { equipment: 'PM-MV', role: 'site_main', power: { value: siteKw, unit: 'kW' } } + ] + } + } + } + } + } +}) + +test('getSitePowerConsumption - centralDCS: history from site_power_w tail-log, current from DCS snapshot', async (t) => { + let captured = null + const ctx = buildDcsCtx({ + dcsThing: makeDcsThing(16700), // 16700 kW + tailLogPoints: [ + { ts: 1, site_power_w: 16000000 }, + { ts: 2, site_power_w: 17000000 }, + { ts: 3, site_power_w: 16500000 } + ], + onTailLog: (p) => { captured = p } + }) + + const result = await getSitePowerConsumption(ctx, { + query: { ...RANGE, tag: 't-powermeter', interval: '5m' } + }) + + // queries the DCS thing's tail-log stat + t.is(captured.key, 'stat-5m', 'builds stat- key') + t.is(captured.type, 'dcs-siemens', 'routes by the DCS thing type') + t.is(captured.tag, 't-dcs', 'uses the configured DCS tag') + t.is(captured.aggrFields.site_power_w, 1, 'requests the site_power_w stat') + + t.is(result.log.length, 3, 'history sourced from DCS tail-log') + t.alike(result.log[0], { ts: 1, value: 16000000, unit: 'W' }, 'watts log point') + t.is(result.summary.min.value, 16000000, 'min') + t.is(result.summary.max.value, 17000000, 'max') + t.is(result.summary.avg.value, 16500000, 'avg') + t.is(result.summary.current.value, 16700 * 1000, 'current = DCS site_main kW*1000') + t.is(result.summary.current.unit, 'W', 'normalized to watts') + t.pass() +}) + +test('getSitePowerConsumption - centralDCS: tail-log error degrades to current-only', async (t) => { + const ctx = buildDcsCtx({ dcsThing: makeDcsThing(15000), tailLogThrows: true }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-powermeter' } }) + + t.is(result.log.length, 0, 'empty log when DCS tail-log not yet available') + t.is(result.summary.min.value, null, 'min null') + t.is(result.summary.avg.value, null, 'avg null') + t.is(result.summary.current.value, 15000 * 1000, 'current still from DCS snapshot') + t.pass() +}) + +test('getSitePowerConsumption - centralDCS: empty tail-log -> current-only', async (t) => { + const ctx = buildDcsCtx({ dcsThing: makeDcsThing(0), tailLogPoints: [] }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-powermeter' } }) + + t.is(result.log.length, 0, 'empty log') + t.is(result.summary.avg.value, null, 'avg null') + t.is(result.summary.current.value, 0, 'no site_main value -> 0') + t.pass() +}) + +test('getSitePowerConsumption - centralDCS does not affect miner tag', async (t) => { + let listThingsCalled = false + const ctx = withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') return [{ ts: 1, power_w_sum_aggr: 4321 }] + if (method === 'listThings') { listThingsCalled = true; return [] } + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-miner' } }) + + t.is(result.log[0].value, 4321, 'miner path unaffected by centralDCS') + t.is(result.summary.current.value, 4321, 'current = last miner point') + t.absent(listThingsCalled, 'no DCS/list-things fetch for the miner tag') + t.pass() +}) + +test('getSitePowerConsumption - centralDCS disabled: powermeter uses legacy site_power_w path', async (t) => { + const ctx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, // no centralDCSSetup + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLog') return [{ ts: 1, site_power_w: 5000 }] + if (method === 'listThings') return [{ last: { snap: { stats: { power_w: 8888 } } } }] + return [] + } + } + }) + + const result = await getSitePowerConsumption(ctx, { query: { ...RANGE, tag: 't-powermeter' } }) + + t.is(result.summary.max.value, 5000, 'legacy powermeter site_power_w history') + t.is(result.summary.current.value, 8888, 'current from list-things (legacy), not DCS') + t.pass() +}) diff --git a/tests/unit/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/tests/unit/handlers/spare.parts.handlers.test.js b/tests/unit/handlers/spare.parts.handlers.test.js index 534c33e..66da474 100644 --- a/tests/unit/handlers/spare.parts.handlers.test.js +++ b/tests/unit/handlers/spare.parts.handlers.test.js @@ -58,7 +58,7 @@ test('handlers: updateSparePart rejects location/status changes without workOrde () => handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, info: { location: 'site.lab' } } }), /ERR_PART_MOVE_REQUIRES_WO/ ) @@ -70,7 +70,7 @@ test('handlers: updateSparePart rejects when WO is closed', async (t) => { () => handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'site.lab' } } }), /ERR_WO_INVALID_STATUS_TRANSITION/ ) @@ -82,7 +82,7 @@ test('handlers: updateSparePart 404s when WO is missing', async (t) => { () => handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, workOrderId: 'wo-missing', info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, workOrderId: 'wo-missing', info: { location: 'site.lab' } } }), /ERR_WORK_ORDER_NOT_FOUND/ ) @@ -93,14 +93,14 @@ test('handlers: updateSparePart pushes part update + WO partsMoves append on a v const out = await handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'site.lab' } } }) t.is(pushed.length, 2, 'two actions pushed (part + WO)') const partAction = pushed.find(p => p.params[0].rackId === PART_RACK) t.is(partAction.action, 'updateThing') - t.is(partAction.params[0].info.location, 'Site Lab') + t.is(partAction.params[0].info.location, 'site.lab') t.is(partAction.params[0].info.workOrderId, 'wo-1', 'workOrderId injected into part info') t.is(partAction.params[0].info.workOrderCode, 'IVI-2-0001', 'workOrderCode injected too') @@ -110,7 +110,7 @@ test('handlers: updateSparePart pushes part update + WO partsMoves append on a v t.is(moves[0].partId, PART.id) t.is(moves[0].partCode, PART.code) t.is(moves[0].fromLocation, 'Lab') - t.is(moves[0].toLocation, 'Site Lab') + t.is(moves[0].toLocation, 'site.lab') t.is(moves[0].workOrderCode, 'IVI-2-0001') t.ok(out.move, 'response includes the move record') @@ -135,7 +135,7 @@ test('handlers: updateSparePart aborts WO append when the part pushAction return () => handlers.updateSparePart(ctx, { ...userMeta(), params: { id: PART.id }, - body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'Site Lab' } } + body: { rackId: PART_RACK, workOrderId: 'wo-1', info: { location: 'site.lab' } } }), /ERR_PART_UPDATE_PUSH_FAILED:ERR_RACK_DOWN/ ) @@ -211,7 +211,7 @@ test('handlers: registerSparePart fires part + Type-1 WO pushActions in parallel t.is(woAction.action, 'registerThing') t.is(woAction.params[0].info.type, 1, 'Type-1 WO') t.is(woAction.params[0].info.partsMoves[0].fromLocation, null) - t.is(woAction.params[0].info.partsMoves[0].toLocation, 'Site Warehouse') + t.is(woAction.params[0].info.partsMoves[0].toLocation, 'site.warehouse') t.is(woAction.params[0].info.partsMoves[0].partId, out.partId, 'WO partsMoves entry links to the pre-generated partId') t.ok(out.partId, 'returns partId') @@ -231,6 +231,58 @@ test('handlers: registerSparePart surfaces ork-side errors in the response', asy t.is(out.partActionId, null) }) +test('handlers: registerSparePartsBatch creates one shared register WO carrying every part and attaches the note', async (t) => { + const { ctx, pushed } = buildRegisterCtx() + const out = await handlers.registerSparePartsBatch(ctx, { + ...userMeta(), + body: { + rackId: PART_RACK, + note: 'Pallets 1-3', + parts: [ + { deviceType: 'psu', deviceModel: 'PSU-A', serialNum: 'SN-1' }, + { deviceType: 'psu', deviceModel: 'PSU-A', serialNum: 'SN-2' }, + { deviceType: 'psu', deviceModel: 'PSU-A', serialNum: 'SN-3' } + ] + } + }) + + t.is(pushed.length, 4, 'three part actions + one shared WO action') + const woAction = pushed.find(p => p.params[0].rackId === WO_RACK) + const partActions = pushed.filter(p => p.params[0].rackId === PART_RACK) + t.is(partActions.length, 3, 'one registerThing per part') + + const woInfo = woAction.params[0].info + t.is(woInfo.type, 1, 'single Type-1 register WO') + t.is(woInfo.deviceCount, 3) + t.is(woInfo.partsMoves.length, 3, 'register WO carries every part') + t.is(woInfo.note, 'Pallets 1-3', 'note recorded on the register WO') + t.alike(woInfo.partsMoves.map(m => m.partId).sort(), out.parts.map(p => p.partId).sort(), 'WO links every returned part') + + for (const pa of partActions) { + t.is(pa.params[0].info.note, 'Pallets 1-3', 'note attached to each registered part') + } + t.is(out.parts.length, 3, 'returns a result row per part') + t.alike(out.errors, [], 'no errors on happy path') +}) + +test('handlers: registerSparePartsBatch rejects the whole batch if any part is invalid', async (t) => { + const { ctx, pushed } = buildRegisterCtx() + await t.exception( + () => handlers.registerSparePartsBatch(ctx, { + ...userMeta(), + body: { + rackId: PART_RACK, + parts: [ + { deviceType: 'psu', deviceModel: 'PSU-A', serialNum: 'SN-1' }, + { deviceType: 'psu', deviceModel: 'PSU-A' } + ] + } + }), + /ERR_SERIAL_NUM_REQUIRED/ + ) + t.is(pushed.length, 0, 'nothing pushed when any part fails validation') +}) + function listFlow ({ items = [], total = 0 } = {}) { let lastList, lastCount const handler = async (_key, method, params) => { @@ -265,9 +317,9 @@ test('handlers: listSpareParts honors an explicit type filter (overrides $ne def test('handlers: listSpareParts maps location/status shortcuts to info.* mingo paths', async (t) => { const flow = listFlow() await handlers.listSpareParts(flow.ctx, { - query: { location: 'Site Lab', status: 'faulty' } + query: { location: 'site.lab', status: 'faulty' } }) - t.is(flow.lastList.query['info.location'], 'Site Lab') + t.is(flow.lastList.query['info.location'], 'site.lab') t.is(flow.lastList.query['info.status'], 'faulty') }) @@ -290,10 +342,10 @@ test('handlers: listSpareParts ?q escapes regex metacharacters', async (t) => { test('handlers: listSpareParts ANDs location/status/q in a single query payload', async (t) => { const flow = listFlow() await handlers.listSpareParts(flow.ctx, { - query: { location: 'Site Lab', status: 'faulty', q: 'PS-' } + query: { location: 'site.lab', status: 'faulty', q: 'PS-' } }) const q = flow.lastList.query - t.is(q['info.location'], 'Site Lab') + t.is(q['info.location'], 'site.lab') t.is(q['info.status'], 'faulty') t.ok(Array.isArray(q.$or) && q.$or.length === 3) }) diff --git a/tests/unit/handlers/work.orders.handlers.test.js b/tests/unit/handlers/work.orders.handlers.test.js index ba6ae76..de3215d 100644 --- a/tests/unit/handlers/work.orders.handlers.test.js +++ b/tests/unit/handlers/work.orders.handlers.test.js @@ -30,12 +30,12 @@ function buildSubmitFlow ({ rackId = RACK, parts = [] } = {}) { return { ctx, get lastPush () { return lastPush } } } -test('handlers: createWorkOrder Type 2 resolves part and forwards body as info', async (t) => { +test('handlers: createWorkOrder Type 3 resolves part and forwards body as info', async (t) => { const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'AM-1' } }] }) await handlers.createWorkOrder(flow.ctx, { ...userMeta(), body: { - type: 2, + type: 3, deviceType: 'miner', deviceModel: 'antminer-s19xp', deviceIdentifier: 'AM-1', @@ -48,6 +48,71 @@ test('handlers: createWorkOrder Type 2 resolves part and forwards body as info', t.is(flow.lastPush.params[0].info.partsMoves[0].role, 'diagnosis') }) +test('handlers: createWorkOrder Type 2 (move) seeds a move parts-move with from/to locations', async (t) => { + const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'SN-1', location: 'site.lab' } }] }) + await handlers.createWorkOrder(flow.ctx, { + ...userMeta(), + body: { + type: 2, + deviceType: 'psu', + deviceModel: 'PSU-1', + deviceIdentifier: 'SN-1', + info: { location: 'site.warehouse' } + } + }) + const move = flow.lastPush.params[0].info.partsMoves[0] + t.is(move.role, 'move') + t.is(move.partId, 'part-1') + t.is(move.fromLocation, 'site.lab') + t.is(move.toLocation, 'site.warehouse') +}) + +test('handlers: createWorkOrder Type 2 (move) relocates the part on its own rack', async (t) => { + const pushed = [] + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async (_k, method, params) => { + if (method === 'pushAction') { pushed.push(params); return { id: 'a', errors: [] } } + if (method === 'listThings') return [{ id: 'part-1', type: 'inventory-miner_part-psu', rack: 'psu-rack-1', info: { location: 'site.lab' } }] + return null + }) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = RACK + await handlers.createWorkOrder(ctx, { + ...userMeta(), + body: { type: 2, deviceType: 'psu', deviceModel: 'P', deviceIdentifier: 'SN-1', info: { location: 'site.warehouse' } } + }) + const partPush = pushed.find(p => p.action === 'updateThing') + t.is(partPush.params[0].rackId, 'psu-rack-1', 'relocation targets the part rack') + t.is(partPush.params[0].info.location, 'site.warehouse', 'part moved to the destination') +}) + +test('handlers: createWorkOrdersBatch Type 2 (move) relocates every part', async (t) => { + const pushed = [] + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async (_k, method, params) => { + if (method === 'pushAction') { pushed.push(params); return { id: 'a', errors: [] } } + if (method === 'listThings') { + const sn = (params.query?.$or || []).map(c => c['info.serialNum']).find(Boolean) + return [{ id: sn, type: 'inventory-miner_part-psu', rack: 'psu-rack-1', info: { location: 'site.warehouse' } }] + } + return null + }) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = RACK + await handlers.createWorkOrdersBatch(ctx, { + ...userMeta(), + body: { + type: 2, + devices: [ + { deviceType: 'psu', deviceModel: 'P', deviceIdentifier: 'SN-1' }, + { deviceType: 'psu', deviceModel: 'P', deviceIdentifier: 'SN-2' } + ], + info: { location: 'site.miner-room' } + } + }) + const partPushes = pushed.filter(p => p.action === 'updateThing') + t.is(partPushes.length, 2, 'one relocation per device') + t.is(partPushes[0].params[0].info.location, 'site.miner-room') +}) + test('handlers: createWorkOrder merges info.notes, info.remarks, info.site, info.location into thing info', async (t) => { const flow = buildSubmitFlow({ parts: [{ id: 'part-1', code: 'PSU-1', type: 'inventory-miner_part-psu', info: { serialNum: 'SN-1' } }] }) await handlers.createWorkOrder(flow.ctx, { @@ -61,7 +126,7 @@ test('handlers: createWorkOrder merges info.notes, info.remarks, info.site, info notes: 'batch registration', remarks: 'test remark', site: 'Ivinhema', - location: 'Site Warehouse' + location: 'site.warehouse' } } }) @@ -69,7 +134,7 @@ test('handlers: createWorkOrder merges info.notes, info.remarks, info.site, info t.is(info.notes, 'batch registration') t.is(info.remarks, 'test remark') t.is(info.site, 'Ivinhema') - t.is(info.location, 'Site Warehouse') + t.is(info.location, 'site.warehouse') t.is(info.deviceType, 'psu', 'top-level fields still present') t.ok(!info.info, 'no nested info.info') }) @@ -90,12 +155,75 @@ test('handlers: createWorkOrder 400s ERR_PART_NOT_FOUND when deviceIdentifier re await t.exception( () => handlers.createWorkOrder(flow.ctx, { ...userMeta(), - body: { type: 2, deviceType: 'psu', deviceModel: 'm', deviceIdentifier: 'unknown-sn', issue: 'i' } + body: { type: 3, deviceType: 'psu', deviceModel: 'm', deviceIdentifier: 'unknown-sn', issue: 'i' } }), /ERR_PART_NOT_FOUND/ ) }) +test('handlers: createWorkOrdersBatch builds one WO with a parts-move per device, first device as summary', async (t) => { + const parts = [ + { id: 'part-1', code: 'WMM-1', type: 'inventory-miner_part-controller', info: { serialNum: 'WMM63S-2024-04829', location: 'site.warehouse' } }, + { id: 'part-2', code: 'WMM-2', type: 'inventory-miner_part-controller', info: { serialNum: 'WMM63S-2024-04830', location: 'site.warehouse' } }, + { id: 'part-3', code: 'WMM-3', type: 'inventory-miner_part-controller', info: { serialNum: 'WMM63S-2024-04831', location: 'site.warehouse' } } + ] + let lastPush + const handler = async (_key, method, params) => { + if (method === 'pushAction') { lastPush = params; return { id: 'action-1', errors: [] } } + if (method === 'listThings') { + const or = params.query?.$or || [] + const sn = or.map(c => c.id || c.code || c['info.serialNum'] || c['info.macAddress']).find(Boolean) + return parts.filter(p => p.info.serialNum === sn) + } + return null + } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], handler) + ctx.authLib = mockAuthLib + ctx._workOrderRackId = RACK + + await handlers.createWorkOrdersBatch(ctx, { + ...userMeta(), + body: { + type: 2, + devices: [ + { deviceType: 'miner', deviceModel: 'whatsminer-m63s', deviceIdentifier: 'WMM63S-2024-04829' }, + { deviceType: 'miner', deviceModel: 'whatsminer-m63s', deviceIdentifier: 'WMM63S-2024-04830' }, + { deviceType: 'miner', deviceModel: 'whatsminer-m63s', deviceIdentifier: 'WMM63S-2024-04831' } + ], + info: { location: 'site.miner-room' } + } + }) + + const info = lastPush.params[0].info + t.is(lastPush.action, 'registerThing') + t.is(info.deviceCount, 3, 'records device count for the scope badge') + t.is(info.deviceIdentifier, 'WMM63S-2024-04829', 'first device is the summary identifier') + t.is(info.partsMoves.length, 3, 'one parts-move per device') + t.alike(info.partsMoves.map(m => m.deviceIdentifier), ['WMM63S-2024-04829', 'WMM63S-2024-04830', 'WMM63S-2024-04831']) + t.alike(info.partsMoves.map(m => m.partId), ['part-1', 'part-2', 'part-3'], 'each move resolves its own part') + t.is(info.partsMoves[0].role, 'move') + t.is(info.partsMoves[0].fromLocation, 'site.warehouse') + t.is(info.partsMoves[0].toLocation, 'site.miner-room', 'all moved to the WO target location') +}) + +test('handlers: createWorkOrdersBatch rejects the whole batch if any device type is invalid', async (t) => { + const flow = buildSubmitFlow({ parts: [{ id: 'p', code: 'c', type: 'inventory-miner_part-psu', info: { serialNum: 'SN-1' } }] }) + await t.exception( + () => handlers.createWorkOrdersBatch(flow.ctx, { + ...userMeta(), + body: { + type: 2, + devices: [ + { deviceType: 'miner', deviceModel: 'm', deviceIdentifier: 'SN-1' }, + { deviceType: 'cooling', deviceModel: 'm', deviceIdentifier: 'SN-2' } + ] + } + }), + /ERR_INVALID_DEVICE_TYPE/ + ) + t.absent(flow.lastPush, 'nothing pushed when validation fails') +}) + test('handlers: updateWorkOrder forwards warranty payload to updateThing', async (t) => { const flow = buildSubmitFlow() await handlers.updateWorkOrder(flow.ctx, { @@ -119,6 +247,7 @@ test('handlers: closeWorkOrder maps to updateThing with status=closed and finalR t.is(flow.lastPush.params[0].id, 'wo-1') t.is(flow.lastPush.params[0].info.status, 'closed') t.is(flow.lastPush.params[0].info.finalResult, 'replaced PSU') + t.ok(flow.lastPush.params[0].info.closedAt, 'stamps closedAt') }) test('handlers: cancelWorkOrder maps to updateThing with status=cancelled', async (t) => { @@ -329,6 +458,21 @@ test('handlers: exportWorkOrder csv sets text/csv content-type and attachment fi t.ok(typeof rep._body === 'string' && rep._body.startsWith('code,status,type')) }) +test('handlers: exportWorkOrdersRma returns CSV of only the MicroBT Miner WOs selected', async (t) => { + const miner = { id: 'wo-3', code: 'IVI-3-0001', info: { type: 3, deviceModel: 'M63S++_VL28', deviceIdentifier: 'MINER-SN-1', issue: 'low hashrate', finalResult: 'replaced HB', remarks: 'r', assignedTo: 'eng@test', createdAt: 1, partsMoves: [{ role: 'diagnosis', partCode: 'HB-OLD' }, { role: 'replacement', partCode: 'HB-NEW' }] } } + const move = { id: 'wo-2', code: 'IVI-2-0002', info: { type: 2, partsMoves: [] } } + const ctx = createMockCtxWithOrks([{ rpcPublicKey: 'k' }], async () => [miner, move]) + const rep = mkRep() + await handlers.exportWorkOrdersRma(ctx, { query: { ids: 'IVI-3-0001,IVI-2-0002' } }, rep) + t.is(rep._headers['content-type'], 'text/csv; charset=utf-8') + t.ok(rep._headers['content-disposition'].includes('rma.csv')) + const lines = rep._body.trim().split('\r\n') + t.is(lines.length, 2, 'header + 1 MicroBT Miner row (Move WO ignored)') + t.ok(lines[0].startsWith('Ticket,Repaired type')) + t.ok(lines[1].startsWith('IVI-3-0001,')) + t.ok(lines[1].includes('HB-OLD') && lines[1].includes('HB-NEW')) +}) + test('handlers: getWorkOrderAudit calls getHistoricalLogs filtered by id', async (t) => { let received const ctx = createMockCtxWithOrks( diff --git a/tests/unit/lib/log-downloader.test.js b/tests/unit/lib/log-downloader.test.js index 6d19b85..daf5c2e 100644 --- a/tests/unit/lib/log-downloader.test.js +++ b/tests/unit/lib/log-downloader.test.js @@ -200,6 +200,44 @@ test('LogDownloader - stream joins swarm as client only', async (t) => { t.pass() }) +test('LogDownloader - stream replicates the core onto existing swarm connections', async (t) => { + // Hyperswarm reuses peer connections across topics — no 'connection' event fires + const swarm = makeSwarm() + const existingSocket = { id: 'existing-peer-socket' } + swarm.connections = new Set([existingSocket]) + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const replicated = [] + const core = makeCore({ key: TEST_KEY }) + core.replicate = (socket) => replicated.push(socket) + + const dl = new LogDownloader({ netFac, storeFac: { getCore: () => core } }) + await dl.stream(TEST_KEY_HEX, 100) + + t.is(replicated.length, 1, 'should replicate the core onto the existing connection') + t.ok(replicated[0] === existingSocket, 'should replicate onto the existing socket') + t.pass() +}) + +test('LogDownloader - core is not replicated twice on the same socket', async (t) => { + const swarm = makeSwarm() + const existingSocket = { id: 'peer-socket' } + swarm.connections = new Set([existingSocket]) + const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } + + const replicated = [] + const core = makeCore({ key: TEST_KEY }) + core.replicate = (socket) => replicated.push(socket) + + const dl = new LogDownloader({ netFac, storeFac: { getCore: () => core } }) + await dl.stream(TEST_KEY_HEX, 100) + + swarm.emit('connection', existingSocket) + + t.is(replicated.length, 1, 'should not replicate the same core twice on one socket') + t.pass() +}) + test('LogDownloader - stream registers download entry while active', async (t) => { const swarm = makeSwarm() const netFac = { get swarm () { return swarm }, startSwarm: async () => {} } diff --git a/tests/unit/lib/routeHelpers.test.js b/tests/unit/lib/routeHelpers.test.js index 53d8615..659c385 100644 --- a/tests/unit/lib/routeHelpers.test.js +++ b/tests/unit/lib/routeHelpers.test.js @@ -109,6 +109,20 @@ test('createAuthOnRequest - calls capCheck when perms provided', async (t) => { t.pass() }) +test('createAuthOnRequest - skips capCheck when ctx.noAuth is set', async (t) => { + let permsChecked = false + const mockCtx = { + noAuth: true, + authLib: { tokenHasPerms: async () => { permsChecked = true; return false } } + } + const mockReq = { headers: {}, _info: {} } + const mockRep = { status: function () { return this }, send: function () { return this } } + + const onRequest = createAuthOnRequest(mockCtx, ['test:perm']) + await onRequest(mockReq, mockRep) + t.absent(permsChecked, 'capCheck/tokenHasPerms is not invoked under noAuth') +}) + test('createCachedHandler - uses cachedRoute', async (t) => { const mockCtx = { conf: { diff --git a/tests/unit/lib/work.order.export.test.js b/tests/unit/lib/work.order.export.test.js index b403cbd..11817f1 100644 --- a/tests/unit/lib/work.order.export.test.js +++ b/tests/unit/lib/work.order.export.test.js @@ -1,7 +1,7 @@ 'use strict' const test = require('brittle') -const { renderWorkOrderCsv } = require('../../../workers/lib/server/lib/work.order.export') +const { renderWorkOrderCsv, renderRmaCsv } = require('../../../workers/lib/server/lib/work.order.export') const WO = { id: 'wo-1', @@ -60,3 +60,42 @@ test('work.order.export: CSV escapes commas / quotes / newlines in field values' const csv = renderWorkOrderCsv(wo) t.ok(csv.includes('"fan, broken\nreplaced"'), 'value with comma/newline wrapped in quotes') }) + +test('work.order.export: RMA CSV emits the fixed RMA column header', (t) => { + t.is( + renderRmaCsv([]).trim(), + 'Ticket,Repaired type,Repaired Miner Sn,Repaired Mac/HB SN/PSU SN,Replaced Mac/HB SN/PSU SN,Repaired Analyze,Repaired Treatment,Remark,Miner Model,Repair Date,Engineer' + ) +}) + +test('work.order.export: RMA CSV maps a MicroBT Miner WO to the fixed columns', (t) => { + const wo = { + code: 'IVI-3-0001', + info: { + type: 3, + deviceModel: 'M63S++_VL28', + deviceIdentifier: 'MINER-SN-1', + issue: 'low hashrate', + finalResult: 'replaced HB', + remarks: 'tech remark', + assignedTo: 'eng@test', + createdBy: 'op@test', + closedAt: 1730764800000, + partsMoves: [ + { role: 'diagnosis', partCode: 'HB-OLD' }, + { role: 'replacement', partCode: 'HB-NEW' } + ] + } + } + const row = renderRmaCsv([wo]).trim().split('\r\n')[1].split(',') + t.is(row[0], 'IVI-3-0001', 'Ticket') + t.is(row[1], 'M63S++_VL28', 'Repaired type') + t.is(row[2], 'MINER-SN-1', 'Repaired Miner Sn') + t.is(row[3], 'HB-OLD', 'Repaired part identifier') + t.is(row[4], 'HB-NEW', 'Replaced part identifier') + t.is(row[5], 'low hashrate', 'Repaired Analyze') + t.is(row[6], 'replaced HB', 'Repaired Treatment') + t.is(row[7], 'tech remark', 'Remark') + t.is(row[9], new Date(wo.info.closedAt).toISOString().slice(0, 10), 'Repair Date from closedAt') + t.is(row[10], 'eng@test', 'Engineer') +}) diff --git a/tests/unit/routes/power.consumption.routes.test.js b/tests/unit/routes/power.consumption.routes.test.js new file mode 100644 index 0000000..d18b8ca --- /dev/null +++ b/tests/unit/routes/power.consumption.routes.test.js @@ -0,0 +1,62 @@ +'use strict' + +const test = require('brittle') +const { testModuleStructure, testHandlerFunctions } = require('../helpers/routeTestHelpers') +const { createRoutesForTest } = require('../helpers/mockHelpers') + +const ROUTES_PATH = '../../../workers/lib/server/routes/power.consumption.routes.js' + +test('power consumption routes - module structure', (t) => { + testModuleStructure(t, ROUTES_PATH, '/site/power-consumption') + t.pass() +}) + +test('power consumption routes - route definitions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const routeUrls = routes.map(route => route.url) + t.ok(routeUrls.includes('/auth/site/power-consumption'), 'should have power consumption route') + + t.pass() +}) + +test('power consumption routes - HTTP methods', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const route = routes.find(r => r.url === '/auth/site/power-consumption') + t.is(route.method, 'GET', 'power consumption route should be GET') + + t.pass() +}) + +test('power consumption routes - schema validation', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + const route = routes.find(r => r.url === '/auth/site/power-consumption') + t.ok(route.schema, 'route should have schema') + t.ok(route.schema.querystring, 'should have querystring schema') + t.is(route.schema.querystring.properties.start.type, 'integer', 'start should be integer') + t.is(route.schema.querystring.properties.end.type, 'integer', 'end should be integer') + t.is(route.schema.querystring.properties.interval.type, 'string', 'interval should be string') + t.is(route.schema.querystring.properties.tag.type, 'string', 'tag should be string') + t.is(route.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + t.alike(route.schema.querystring.required, ['start', 'end'], 'start and end should be required') + + t.pass() +}) + +test('power consumption routes - handler functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + testHandlerFunctions(t, routes, '/site/power-consumption') + t.pass() +}) + +test('power consumption routes - onRequest functions', (t) => { + const routes = createRoutesForTest(ROUTES_PATH) + + routes.forEach(route => { + t.ok(typeof route.onRequest === 'function', `route ${route.url} should have onRequest function`) + }) + + t.pass() +}) diff --git a/tests/unit/routes/spare.parts.routes.test.js b/tests/unit/routes/spare.parts.routes.test.js index 1fabd2e..4931627 100644 --- a/tests/unit/routes/spare.parts.routes.test.js +++ b/tests/unit/routes/spare.parts.routes.test.js @@ -50,12 +50,12 @@ test('spare.parts.routes: list cache key includes every filter shortcut', (t) => offset: 0, limit: 10, q: 'AB:CD', - location: 'Site Lab', + location: 'site.lab', status: 'faulty' } } const key = capturedKeyFn(req) - for (const expected of ['{"info.foo":1}', '{"code":1}', '{}', 0, 10, 'AB:CD', 'Site Lab', 'faulty']) { + for (const expected of ['{"info.foo":1}', '{"code":1}', '{}', 0, 10, 'AB:CD', 'site.lab', 'faulty']) { t.ok(key.includes(expected), `cache key includes ${JSON.stringify(expected)}`) } diff --git a/tests/unit/routes/work.orders.routes.test.js b/tests/unit/routes/work.orders.routes.test.js index e64acc0..b25b723 100644 --- a/tests/unit/routes/work.orders.routes.test.js +++ b/tests/unit/routes/work.orders.routes.test.js @@ -28,7 +28,8 @@ test('work.orders.routes: registers every WO endpoint', (t) => { { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_CANCEL }, { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_ASSIGN }, { method: HTTP_METHODS.POST, url: ENDPOINTS.WORK_ORDER_LOG }, - { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_EXPORT } + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_EXPORT }, + { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDER_EXPORT_RMA } ] for (const e of expected) { const found = routes.find(r => r.method === e.method && r.url === e.url) diff --git a/workers/http.node.wrk.js b/workers/http.node.wrk.js index 97d2307..6f9d5b4 100644 --- a/workers/http.node.wrk.js +++ b/workers/http.node.wrk.js @@ -3,6 +3,7 @@ const async = require('async') const WebsocketPlugin = require('@fastify/websocket') const MultipartPlugin = require('@fastify/multipart') +const fastifyPlugin = require('fastify-plugin') const { WORK_ORDER_FILE_MAX_BYTES_DEFAULT, MICROSOFT_AUTH_SCOPE } = require('./lib/constants') const TetherWrkBase = require('@tetherto/tether-wrk-base/workers/base.wrk.tether') const AuthLib = require('./lib/auth') @@ -113,6 +114,20 @@ class WrkServerHttp extends TetherWrkBase { limits: { fileSize: this.conf.workOrderFileMaxBytes || WORK_ORDER_FILE_MAX_BYTES_DEFAULT, files: 1 } }]) + // Tolerate empty JSON bodies (Fastify rejects them by default); + // routes that require a body still fail schema validation + httpd.addPlugin([fastifyPlugin(async (instance) => { + instance.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => { + if (body === '' || body === undefined) return done(null, undefined) + try { + done(null, JSON.parse(body)) + } catch (err) { + err.statusCode = 400 + done(err) + } + }) + }), {}]) + libServer.routes(this).forEach(r => { httpd.addRoute(r) }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 9e40d3d..85f424b 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -45,11 +45,11 @@ const AUTH_PERMISSIONS = { const WORK_ORDER_THING_TYPE = 'inventory-work_order' -const WORK_ORDER_TYPES = { REGISTER: 1, REGULAR: 2 } +const WORK_ORDER_TYPES = { REGISTER: 1, MOVE: 2, MICROBT_MINER: 3, MICROBT_NON_MINER: 4 } const WORK_ORDER_TERMINAL_STATUSES = ['closed', 'cancelled'] const WORK_ORDER_VALID_DEVICE_TYPES = ['miner', 'psu', 'hashboard', 'controller'] -const MINER_LOCATIONS = ['Site Warehouse', 'Site Lab', 'Miner Room', 'Vendor', 'Scrapped', 'Disposed'] -const SPARE_PART_INITIAL_LOCATION = 'Site Warehouse' +const MINER_LOCATIONS = ['workshop.warehouse', 'workshop.lab', 'site.warehouse', 'site.lab', 'site.container', 'miner.room', 'vendor', 'acme.container', 'scrapped', 'disposed', 'unknown'] +const SPARE_PART_INITIAL_LOCATION = 'site.warehouse' const FILE_TYPES = { WORK_ORDER: 'work_order' } const WORK_ORDER_FILE_MAX_BYTES_DEFAULT = 10 * 1024 * 1024 const WORK_ORDER_FILE_COUNT_CAP_DEFAULT = 20 @@ -152,6 +152,7 @@ const ENDPOINTS = { POOLS_CONTAINERS_STATS: '/auth/pools/stats/containers', SITE_STATUS_LIVE: '/auth/site/status/live', + SITE_POWER_CONSUMPTION: '/auth/site/power-consumption', // Generic Config endpoints (type passed as parameter) // Note: Config mutations (register, update, delete) go through pushAction endpoint @@ -169,6 +170,7 @@ const ENDPOINTS = { METRICS_POWER_MODE: '/auth/metrics/power-mode', METRICS_POWER_MODE_TIMELINE: '/auth/metrics/power-mode/timeline', METRICS_TEMPERATURE: '/auth/metrics/temperature', + METRICS_COOLING: '/auth/metrics/cooling', METRICS_CONTAINER_TELEMETRY: '/auth/metrics/containers/:id', METRICS_CONTAINER_HISTORY: '/auth/metrics/containers/:id/history', @@ -198,6 +200,7 @@ const ENDPOINTS = { ENERGY_AVAILABLE: '/auth/energy/available', // Work Order endpoints WORK_ORDERS: '/auth/work-orders', + WORK_ORDERS_BATCH: '/auth/work-orders/batch', WORK_ORDER_BY_ID: '/auth/work-orders/:id', WORK_ORDER_AUDIT: '/auth/work-orders/:id/audit', WORK_ORDER_LOG: '/auth/work-orders/:id/log', @@ -208,14 +211,30 @@ const ENDPOINTS = { WORK_ORDER_CANCEL: '/auth/work-orders/:id/cancel', // Spare Part endpoints SPARE_PARTS: '/auth/spare-parts', + SPARE_PARTS_BATCH: '/auth/spare-parts/batch', SPARE_PART_BY_ID: '/auth/spare-parts/:id', SPARE_PART_REPAIR_HISTORY: '/auth/spare-parts/:id/repair-history', // Work Order export - WORK_ORDER_EXPORT: '/auth/work-orders/:id/export' + WORK_ORDER_EXPORT: '/auth/work-orders/:id/export', + WORK_ORDER_EXPORT_RMA: '/auth/work-orders/export/rma' } const WORK_ORDER_EXPORT_FORMATS = ['pdf', 'csv', 'docx'] +const RMA_COLUMNS = [ + 'Ticket', + 'Repaired type', + 'Repaired Miner Sn', + 'Repaired Mac/HB SN/PSU SN', + 'Replaced Mac/HB SN/PSU SN', + 'Repaired Analyze', + 'Repaired Treatment', + 'Remark', + 'Miner Model', + 'Repair Date', + 'Engineer' +] + const HTTP_METHODS = { GET: 'GET', POST: 'POST', @@ -285,7 +304,25 @@ const WORKER_TYPES = { POWERMETER: 'powermeter', MINERPOOL: 'minerpool', MEMPOOL: 'mempool', - ELECTRICITY: 'electricity' + ELECTRICITY: 'electricity', + // The Siemens DCS worker registers its thing type as 'dcs-siemens' + // (WrkDCSBase 'dcs' + '-siemens'); the stat log is tailed by this type. + DCS: 'dcs-siemens' +} + +// BE-10 — historical cooling metric fields produced by the DCS worker stat spec +// (miningos-wrk-dcs-siemens/workers/lib/stats.js -> libStats.specs.dcs.ops). Each +// is a per-interval average; chiller_running is averaged over [0,1] -> uptime ratio. +const COOLING_METRICS_AGGR_FIELDS = { + miner_supply_temp_c: 1, + miner_return_temp_c: 1, + miner_flow_m3h: 1, + system_pressure_bar: 1, + hvac_supply_temp_c: 1, + hvac_return_temp_c: 1, + chiller_running: 1, + towers_running: 1, + pumps_running: 1 } const SEVERITY_LEVELS = new Set(['critical', 'high', 'medium', 'low']) @@ -294,11 +331,13 @@ const ALERTS_DEFAULT_LIMIT = 100 const ALERTS_MAX_SITE_LIMIT = 200 const ALERTS_MAX_HISTORY_LIMIT = 1000 -const SITE_ALERTS_FILTER_FIELDS = ['severity', 'type', 'container', 'deviceId'] -const SITE_ALERTS_SEARCH_FIELDS = ['id', 'code', 'container'] +// `message` carries the per-alert device/equipment tag (e.g. 'FIT-7513'), so it +// is filterable and searchable on both endpoints. +const SITE_ALERTS_FILTER_FIELDS = ['severity', 'type', 'container', 'deviceId', 'message'] +const SITE_ALERTS_SEARCH_FIELDS = ['id', 'code', 'container', 'message', 'description'] -const HISTORY_FILTER_FIELDS = ['severity', 'code', 'deviceType', 'container', 'deviceId', 'tags'] -const HISTORY_SEARCH_FIELDS = ['name', 'description', 'position', 'code'] +const HISTORY_FILTER_FIELDS = ['severity', 'code', 'deviceType', 'container', 'deviceId', 'tags', 'message'] +const HISTORY_SEARCH_FIELDS = ['name', 'description', 'position', 'code', 'message'] const POOL_ALERT_TYPES = [ 'all_pools_dead', @@ -399,6 +438,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.valves': 1, 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_switches': 1, 'last.snap.stats.dcs_specific.equipment.fans': 1, 'last.snap.config.cooling_system': 1 }, @@ -413,6 +453,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.valves': 1, 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_switches': 1, 'last.snap.stats.dcs_specific.equipment.fans': 1, 'last.snap.stats.flow': 1, 'last.snap.config.cooling_system': 1, @@ -441,6 +482,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.levels': 1, 'last.snap.stats.dcs_specific.equipment.cooling_towers': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_switches': 1, 'last.snap.config.cooling_system': 1 }, layout: { @@ -457,6 +499,7 @@ const COOLING_SYSTEM_PROJECTIONS = { 'last.snap.stats.dcs_specific.equipment.tanks': 1, 'last.snap.stats.dcs_specific.equipment.flow_switches': 1, 'last.snap.stats.dcs_specific.equipment.vibration_sensors': 1, + 'last.snap.stats.dcs_specific.equipment.vibration_switches': 1, 'last.snap.config.cooling_system': 1 }, ambient: { @@ -510,6 +553,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,11 +817,14 @@ 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, EXPLORER_RACK_DEFAULT_LIMIT, EXPLORER_RACK_MAX_LIMIT, + COOLING_METRICS_AGGR_FIELDS, LOG_FIELDS, ELECTRICITY_EXT_DATA_KEYS, WORK_ORDER_THING_TYPE, @@ -778,5 +838,6 @@ module.exports = { WORK_ORDER_FILE_COUNT_CAP_DEFAULT, WORK_ORDER_FILE_MIME_ALLOWLIST_DEFAULT, WORK_ORDER_EXPORT_FORMATS, + RMA_COLUMNS, MICROSOFT_AUTH_SCOPE } diff --git a/workers/lib/dcs.utils.js b/workers/lib/dcs.utils.js index 6e1eae0..7822b95 100644 --- a/workers/lib/dcs.utils.js +++ b/workers/lib/dcs.utils.js @@ -25,6 +25,13 @@ function extractDcsThing (rpcResults) { return null } +// Site-wide power in watts from a DCS thing's "site_main" meter (reported in kW). +function extractSiteMainMeterPowerW (dcsThing) { + const powerMeters = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.power_meters || [] + const siteMeter = powerMeters.find(pm => pm.role === 'site_main') + return (siteMeter?.power?.value || 0) * 1000 +} + function getSensorReading (sensors, sensorId, defaultConfig = null) { if (!sensorId) return defaultConfig const sensor = sensors?.find(s => s.equipment === sensorId) @@ -62,6 +69,7 @@ module.exports = { isCentralDCSEnabled, getDCSTag, extractDcsThing, + extractSiteMainMeterPowerW, getSensorReading, findEquipment, filterEquipmentBy, diff --git a/workers/lib/log-downloader.js b/workers/lib/log-downloader.js index 1a449e9..bc06ba5 100644 --- a/workers/lib/log-downloader.js +++ b/workers/lib/log-downloader.js @@ -40,11 +40,19 @@ class LogDownloader { this._netFac.swarm.on('connection', (socket) => { for (const [, entry] of this._downloads) { - entry.core.replicate(socket) + this._replicateCore(entry, socket) } }) } + // Replicates a core onto a socket exactly once. Hyperswarm reuses peer + // connections across topics, so new cores must also reach open sockets. + _replicateCore (entry, socket) { + if (entry.seenSockets.has(socket)) return + entry.seenSockets.add(socket) + entry.core.replicate(socket) + } + /** * Open a streaming read of a remote Hypercore and return it as a Node.js Readable. * The stream fetches blocks lazily from the wrk-miner peer — no full buffering. @@ -60,7 +68,13 @@ class LogDownloader { await core.ready() const discoveryKeyHex = core.discoveryKey.toString('hex') - this._downloads.set(coreKeyHex, { core, discoveryKeyHex }) + const entry = { core, discoveryKeyHex, seenSockets: new WeakSet() } + this._downloads.set(coreKeyHex, entry) + + // No 'connection' event fires for reused peer sockets — replicate explicitly + for (const socket of this._netFac.swarm.connections || []) { + this._replicateCore(entry, socket) + } const discovery = this._netFac.swarm.join(core.discoveryKey, { server: false, client: true }) const peersDone = core.findingPeers() diff --git a/workers/lib/server/handlers/alerts.handlers.js b/workers/lib/server/handlers/alerts.handlers.js index 7e864a7..9537a5a 100644 --- a/workers/lib/server/handlers/alerts.handlers.js +++ b/workers/lib/server/handlers/alerts.handlers.js @@ -22,6 +22,7 @@ function extractAlertsFromThings (things) { alerts.push({ ...alert, id: thing.id, + deviceId: thing.id, type: thing.type, code: thing.code, container: thing.info?.container @@ -79,6 +80,7 @@ function flattenHistoryAlert (entry) { severity: entry.severity, createdAt: entry.createdAt, uuid: entry.uuid, + message: entry.message, deviceId: thing.id, deviceType: thing.type, code: thing.code, diff --git a/workers/lib/server/handlers/cooling.system.handlers.js b/workers/lib/server/handlers/cooling.system.handlers.js index b20d922..fc0d328 100644 --- a/workers/lib/server/handlers/cooling.system.handlers.js +++ b/workers/lib/server/handlers/cooling.system.handlers.js @@ -1,13 +1,36 @@ 'use strict' -const { COOLING_SYSTEM_PROJECTIONS } = require('../../constants') +const { + COOLING_SYSTEM_PROJECTIONS, + LOG_KEYS, + WORKER_TYPES, + WORKER_TAGS, + EXPLORER_RACK_AGGR_FIELDS +} = require('../../constants') const { isCentralDCSEnabled, getDCSTag, extractDcsThing, getSensorReading } = require('../../dcs.utils') +const { aggregateRackStats } = require('./explorer.handlers') +/** + * BE-9 — Layout positional / equipment-id contract (FE binds by position or id): + * - circuit1.lines[0] = Line 1 / Groups 1-8, lines[1] = Line 2 / Groups 9-16. + * - pumps[] keep config order: pumps[0/1/2] = A/B/C per circuit. + * - Cooling towers are matched by `id` (TR-7501 miner loop, TR-7502 HVAC condenser). + * - Control valves / sensors are resolved by their config tag against the single + * flat `equipment.*` arrays, so every equipment `.equipment` id MUST stay + * GLOBALLY UNIQUE. Two entries sharing an id make `find()` ambiguous and cannot + * be disambiguated by circuit — keep ids unique at the source (DCS config). + * Do not reorder or reshape these arrays. + * + * BE-1 deferred renames (kept unique pending controls/DCS provisioning of a + * globally-unique tag — applying them verbatim would duplicate an existing id): + * - PCV-7503 -> PCV-7501 (collides with HVAC pressure_bypass PCV-7501). + * - LIT-7504 -> LT-7591 (collides with HVAC buffer-tank LT-7591). + */ function getFieldProjection (type, view) { const base = COOLING_SYSTEM_PROJECTIONS.base const typeProjections = COOLING_SYSTEM_PROJECTIONS[type] @@ -45,6 +68,38 @@ function getSensorWithTag (sensors, sensorId, defaultConfig) { } } +function buildVibrationSwitch (vibrationSwitches, switchTag) { + if (!switchTag) return null + const sw = (vibrationSwitches || []).find(s => s.equipment === switchTag) + return { tag: switchTag, state: sw?.state ?? null } +} + +function buildGroupDifferentialPressure (lineConfig, pressures) { + const groupSensors = lineConfig.group_pressure_sensors || {} + const supplyIds = groupSensors.supply || [] + const returnIds = groupSensors.return || [] + const count = Math.max(supplyIds.length, returnIds.length) + + const rows = [] + for (let i = 0; i < count; i++) { + const supply = getSensorWithTag(pressures, supplyIds[i]) + const ret = getSensorWithTag(pressures, returnIds[i]) + const supplyVal = supply?.reading?.value + const returnVal = ret?.reading?.value + const deltaPVal = (supplyVal != null && returnVal != null) + ? Math.round((supplyVal - returnVal) * 100) / 100 + : null + const unit = supply?.reading?.unit || ret?.reading?.unit || 'bar' + rows.push({ + group: i + 1, + supply, + return: ret, + delta_p: deltaPVal != null ? { value: deltaPVal, unit } : null + }) + } + return rows +} + function buildMinersCircuit1View (equipment, config) { const pumps = equipment.pumps const temperatures = equipment.temperatures @@ -97,6 +152,7 @@ function buildMinersCircuit1View (equipment, config) { pressure: getSensorReading(pressures, lineConfig.return_pressure_sensor), sensors: [returnTempSensor, returnPressureSensor].filter(Boolean) }, + differential_pressure: buildGroupDifferentialPressure(lineConfig, pressures), heat_exchanger: hx ? { id: hx.equipment, @@ -133,6 +189,7 @@ function buildMinersCircuit1View (equipment, config) { const allReturnTemps = lines.map(l => l.return.temperature?.value).filter(v => v != null) const allSupplyFlows = lines.map(l => l.supply.flow?.value).filter(v => v != null) const allSupplyPressures = lines.map(l => l.supply.pressure?.value).filter(v => v != null) + const allReturnPressures = lines.map(l => l.return.pressure?.value).filter(v => v != null) const avgSupplyTemp = allSupplyTemps.length > 0 ? Math.round((allSupplyTemps.reduce((a, b) => a + b, 0) / allSupplyTemps.length) * 10) / 10 @@ -150,6 +207,16 @@ function buildMinersCircuit1View (equipment, config) { ? Math.round((avgReturnTemp - avgSupplyTemp) * 10) / 10 : null + const inletPressureAvg = allSupplyPressures.length > 0 + ? Math.round((allSupplyPressures.reduce((a, b) => a + b, 0) / allSupplyPressures.length) * 100) / 100 + : null + const outletPressureAvg = allReturnPressures.length > 0 + ? Math.round((allReturnPressures.reduce((a, b) => a + b, 0) / allReturnPressures.length) * 100) / 100 + : null + const deltaPAvg = (inletPressureAvg != null && outletPressureAvg != null) + ? Math.round((inletPressureAvg - outletPressureAvg) * 100) / 100 + : null + const controlValveEntries = coolingConfig.control_valves || {} const controlValves = {} for (const [role, valveId] of Object.entries(controlValveEntries)) { @@ -177,7 +244,10 @@ function buildMinersCircuit1View (equipment, config) { delta_t: deltaT != null ? { value: deltaT, unit: tempUnit } : null, total_flow: totalFlow != null ? { value: totalFlow, unit: flowUnit } : null, rated_flow: coolingConfig.defaults?.rated_flow || null, - system_pressure: systemPressure != null ? { value: systemPressure, unit: pressureUnit } : null + system_pressure: systemPressure != null ? { value: systemPressure, unit: pressureUnit } : null, + inlet_pressure_avg: inletPressureAvg != null ? { value: inletPressureAvg, unit: pressureUnit } : null, + outlet_pressure_avg: outletPressureAvg != null ? { value: outletPressureAvg, unit: pressureUnit } : null, + delta_p_avg: deltaPAvg != null ? { value: deltaPAvg, unit: pressureUnit } : null }, pumps_config: coolingConfig.defaults?.pumps_config || null, lines, @@ -192,6 +262,7 @@ function buildMinersCircuit2View (equipment, config) { const levels = equipment.levels const heatExchangers = equipment.heat_exchangers const coolingTowers = equipment.cooling_towers + const vibrationSwitches = equipment.vibration_switches const valves = equipment.valves const tanks = equipment.tanks const towerConfig = config?.cooling_system?.cooling_tower_loop || {} @@ -269,8 +340,8 @@ function buildMinersCircuit2View (equipment, config) { const towerLevel = getSensorReading(levels, towerLevelSensor) // Cooling towers with sensor tag references - const towerVibrationSensorId = towerConfig.tower_vibration_sensor const towerFanId = towerConfig.tower_fan + const towerVibrationSwitch = buildVibrationSwitch(vibrationSwitches, towerConfig.tower_vibration_switch) const towerData = (coolingTowers || []).map(ct => ({ id: ct.equipment, @@ -282,9 +353,7 @@ function buildMinersCircuit2View (equipment, config) { fan_id: towerFanId, level: ct.level, level_sensor: towerLevelSensor, - vibration: ct.vibration, - vibration_sensor: towerVibrationSensorId, - vibration_threshold: ct.vibration_threshold || null, + vibration_switch: towerVibrationSwitch, capacity_flow: towerConfig.defaults?.tower_capacity || null, capacity_gcal: towerConfig.defaults?.tower_capacity_gcal || null })) @@ -358,7 +427,7 @@ function buildMinersCircuit2View (equipment, config) { } } -function buildMinersLayoutView (equipment, config, stats) { +function buildMinersLayoutView (equipment, config, stats, rackPowerByRack) { const circuit1 = buildMinersCircuit1View(equipment, config) const circuit2 = buildMinersCircuit2View(equipment, config) const { pumps } = equipment @@ -375,11 +444,20 @@ function buildMinersLayoutView (equipment, config, stats) { const groups = [] for (let i = 1; i <= totalGroups; i++) { - groups.push({ + const group = { id: `G${i}`, name: `G${i}`, vlan: vlanStart + (i - 1) - }) + } + if (rackPowerByRack) { + const statuses = [] + for (let r = 1; r <= racksPerGroup; r++) { + const powerW = rackPowerByRack[`group-${i}_rack-${r}`] + statuses.push(powerW != null && powerW > 0) + } + group.rack_statuses = statuses + } + groups.push(group) } return { @@ -555,19 +633,31 @@ function buildHvacCircuit1View (equipment, config) { } } + if (controlValves.pressure_bypass) { + controlValves.pressure_bypass.pressure = systemPressure + ? { tag: supplyReturnConfig.pressure_sensor || null, value: systemPressure.value, unit: systemPressure.unit } + : null + } + const returnPumps = filterPumpsByCircuit(pumps, 'HVAC_RETURN').map(formatPump) const supplyPumps = filterPumpsByCircuit(pumps, 'HVAC_SUPPLY').map(formatPump) + const fanCoilTagMap = {} + for (const fcCfg of (chilledConfig.fan_coils || [])) { + if (fcCfg.id) fanCoilTagMap[fcCfg.id] = fcCfg + } + const fanCoilsSummary = { total: (fanCoils || []).length, running: (fanCoils || []).filter(fc => fc.is_running).length, units: (fanCoils || []).map(fc => { + const fcMeta = fanCoilTagMap[fc.equipment] || {} + const valveTag = fcMeta.valve_tag || null + const temperatureTag = fcMeta.temperature_tag || null const fcNumber = fc.equipment.replace(/^FCT?-/, '') const fanId = `V-${fcNumber}` - const valveId = `PIV-${fcNumber}` - const tempSensorId = `TT-${fcNumber}` const fan = (fans || []).find(f => f.equipment === fanId) - const valve = valves?.find(v => v.equipment === valveId) + const valve = valveTag ? valves?.find(v => v.equipment === valveTag) : null return { id: fc.equipment, @@ -575,9 +665,9 @@ function buildHvacCircuit1View (equipment, config) { fan_id: fanId, fan_running: fan?.fbk_run_out || false, fan_speed: fc.fan_speed, - valve_tag: valveId, + valve_tag: valveTag, valve_position: valve?.position || fc.valve_position, - temperature_tag: tempSensorId, + temperature_tag: temperatureTag, temperature: fc.temperature } }) @@ -659,8 +749,8 @@ function buildHvacCircuit2View (equipment, config) { const towerConfigRef = condenserConfig.tower || {} const towerLevelSensorId = towerConfigRef.level_sensor const towerLevel = getSensorReading(levels, towerLevelSensorId) - const towerVibrationSensorId = towerConfigRef.vibration_sensor const towerFanId = towerConfigRef.fan + const towerVibrationSwitch = buildVibrationSwitch(equipment.vibration_switches, towerConfigRef.vibration_switch) const towerData = (coolingTowers || []).map(ct => ({ id: ct.equipment, @@ -672,9 +762,7 @@ function buildHvacCircuit2View (equipment, config) { fan_id: towerFanId || null, level: ct.level, level_sensor: towerLevelSensorId || null, - vibration: ct.vibration, - vibration_sensor: towerVibrationSensorId || null, - vibration_threshold: ct.vibration_threshold || null, + vibration_switch: towerVibrationSwitch, capacity_mcal: condenserConfig.defaults?.tower_capacity_mcal || null, capacity_flow: condenserConfig.defaults?.tower_flow || null })) @@ -772,7 +860,7 @@ function buildHvacAmbientView (equipment, config, stats) { })) const roomTemps = roomFanCoils - .filter(fc => fc.temperature?.value > 0) + .filter(fc => fc.temperature?.value != null && Number.isFinite(fc.temperature.value)) .map(fc => fc.temperature.value) const avgTemp = roomTemps.length > 0 ? Math.round((roomTemps.reduce((a, b) => a + b, 0) / roomTemps.length) * 10) / 10 @@ -832,7 +920,7 @@ function buildHvacAmbientView (equipment, config, stats) { * @param {string} view - View name (circuit1, circuit2, layout, ambient) * @returns {Object|null} */ -function buildCoolingViewData (snap, type, view) { +function buildCoolingViewData (snap, type, view, rackPowerByRack) { const equipment = snap.stats?.dcs_specific?.equipment || {} const config = snap.config || {} const stats = snap.stats || {} @@ -844,7 +932,7 @@ function buildCoolingViewData (snap, type, view) { case 'circuit2': return buildMinersCircuit2View(equipment, config) case 'layout': - return buildMinersLayoutView(equipment, config, stats) + return buildMinersLayoutView(equipment, config, stats, rackPowerByRack) default: return null } @@ -909,7 +997,22 @@ async function getCoolingSystemData (ctx, req) { fields } - const rpcResults = await ctx.dataProxy.requestDataMap('listThings', payload) + const needsRackStatuses = type === 'miners' && view === 'layout' + const rackTailLogPayload = { + keys: [ + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } + ], + limit: 1, + aggrFields: EXPLORER_RACK_AGGR_FIELDS + } + + const [rpcResults, rackTailLogResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('listThings', payload), + needsRackStatuses + ? ctx.dataProxy.requestDataMap('tailLogMulti', rackTailLogPayload) + : Promise.resolve(null) + ]) + const dcsThing = extractDcsThing(rpcResults) if (!dcsThing) { @@ -918,7 +1021,11 @@ async function getCoolingSystemData (ctx, req) { const snap = dcsThing.last.snap - const viewData = buildCoolingViewData(snap, type, view) + const rackPowerByRack = rackTailLogResults + ? aggregateRackStats(rackTailLogResults).powerByRack + : null + + const viewData = buildCoolingViewData(snap, type, view, rackPowerByRack) if (!viewData) { throw new Error('ERR_VIEW_DATA_NOT_AVAILABLE') diff --git a/workers/lib/server/handlers/energy.system.handlers.js b/workers/lib/server/handlers/energy.system.handlers.js index 7592c75..e46c824 100644 --- a/workers/lib/server/handlers/energy.system.handlers.js +++ b/workers/lib/server/handlers/energy.system.handlers.js @@ -26,7 +26,6 @@ function buildMinersView (equipment, config, stats) { } // Rack meters (role: rack) - console.log('powerMeters', powerMeters) const rackMeters = powerMeters .filter(pm => pm.role === 'rack') .sort((a, b) => { diff --git a/workers/lib/server/handlers/metrics.handlers.js b/workers/lib/server/handlers/metrics.handlers.js index 8dd23b9..35740aa 100644 --- a/workers/lib/server/handlers/metrics.handlers.js +++ b/workers/lib/server/handlers/metrics.handlers.js @@ -10,12 +10,17 @@ const { LOG_KEYS, WORKER_TAGS, DEVICE_LIST_FIELDS, - LOG_FIELDS + LOG_FIELDS, + COOLING_METRICS_AGGR_FIELDS } = require('../../constants') const { getStartOfDay, safeDiv } = require('../../utils') +const { + isCentralDCSEnabled, + getDCSTag +} = require('../../dcs.utils') const { parseEntryTs, validateStartEnd, @@ -860,6 +865,91 @@ function processContainerHistoryData (results, containerId) { return log } +const COOLING_INTERVAL_ALIASES = { hourly: '1h', daily: '1d', weekly: '1w' } + +const round1 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10) / 10) + +async function getCooling (ctx, req) { + if (!isCentralDCSEnabled(ctx)) { + throw new Error('ERR_FEATURE_NOT_ENABLED') + } + + const { start, end } = validateStartEnd(req) + + const requested = COOLING_INTERVAL_ALIASES[req.query.interval] || req.query.interval + const interval = resolveInterval(start, end, requested) + const config = getIntervalConfig(interval) + + const rpcPayload = { + key: config.key, + type: WORKER_TYPES.DCS, + tag: getDCSTag(ctx), + aggrFields: COOLING_METRICS_AGGR_FIELDS, + start, + end + } + if (config.groupRange) { + rpcPayload.groupRange = config.groupRange + } + + const results = await ctx.dataProxy.requestData(RPC_METHODS.TAIL_LOG, rpcPayload) + + const log = processCoolingData(results, config.groupRange) + const summary = calculateCoolingSummary(log) + + return { interval, log, summary } +} + +function processCoolingData (results, groupRange) { + const points = [] + for (const entry of iterateRpcEntries(results)) { + const rawTs = parseEntryTs(entry.ts || entry.timestamp) + const ts = groupRange && rawTs ? getStartOfDay(rawTs) : rawTs + if (!ts) continue + + const read = (field) => { + const v = entry[field] ?? entry.aggrFields?.[field] + return v == null || !Number.isFinite(Number(v)) ? null : Number(v) + } + + const supply = read('miner_supply_temp_c') + const ret = read('miner_return_temp_c') + const chillerRunning = read('chiller_running') + + points.push({ + ts: Number(ts), + minerSupplyTempC: round1(supply), + minerReturnTempC: round1(ret), + minerDeltaTC: (supply != null && ret != null) ? round1(ret - supply) : null, + minerFlowM3h: round1(read('miner_flow_m3h')), + systemPressureBar: round1(read('system_pressure_bar')), + hvacSupplyTempC: round1(read('hvac_supply_temp_c')), + hvacReturnTempC: round1(read('hvac_return_temp_c')), + chillerUptimePct: chillerRunning == null ? null : Math.round(chillerRunning * 1000) / 10, + towersRunning: round1(read('towers_running')), + pumpsRunning: round1(read('pumps_running')) + }) + } + return points.sort((a, b) => a.ts - b.ts) +} + +function calculateCoolingSummary (log) { + const avgOf = (key) => { + const vals = log.map(e => e[key]).filter(v => v != null) + return vals.length ? round1(vals.reduce((a, b) => a + b, 0) / vals.length) : null + } + return { + avgMinerSupplyTempC: avgOf('minerSupplyTempC'), + avgMinerReturnTempC: avgOf('minerReturnTempC'), + avgMinerDeltaTC: avgOf('minerDeltaTC'), + avgMinerFlowM3h: avgOf('minerFlowM3h'), + avgSystemPressureBar: avgOf('systemPressureBar'), + avgHvacSupplyTempC: avgOf('hvacSupplyTempC'), + avgHvacReturnTempC: avgOf('hvacReturnTempC'), + chillerUptimePct: avgOf('chillerUptimePct') + } +} + module.exports = { ...require('../../metrics.utils'), getHashrate, @@ -889,5 +979,8 @@ module.exports = { processContainerMiners, processContainerSensorSnapshot, getContainerHistory, - processContainerHistoryData + processContainerHistoryData, + getCooling, + processCoolingData, + calculateCoolingSummary } diff --git a/workers/lib/server/handlers/minerLogs.handlers.js b/workers/lib/server/handlers/minerLogs.handlers.js index b7275e8..4a9491b 100644 --- a/workers/lib/server/handlers/minerLogs.handlers.js +++ b/workers/lib/server/handlers/minerLogs.handlers.js @@ -2,6 +2,23 @@ const { downloadLogFile } = require('./actions.handlers') +// Worker error code prefixes -> human-readable messages (raw code stays in `error`) +const WORKER_ERROR_MESSAGES = [ + ['ERR_DOWNLOAD_LOGS_PARSE_FAILED', 'The miner sent a malformed response header; the download was aborted'], + ['ERR_DOWNLOAD_LOGS_FAILED', 'The miner rejected the download-logs command'], + ['ERR_DOWNLOAD_LOGS_EMPTY', 'The miner reports an empty log archive — there is nothing to download'], + ['ERR_DOWNLOAD_LOGS_TIMEOUT', 'The miner did not deliver the log within the time limit'], + ['ERR_DOWNLOAD_LOGS_INCOMPLETE', 'The connection to the miner dropped before the full log was transferred'], + ['ERR_DOWNLOAD_LOGS_CONNECT_FAILED', 'Could not connect to the miner to download logs'], + ['ERR_LOG_CORE_MANAGER_NOT_READY', 'The rack worker is not ready to serve log transfers yet'], + ['ERR_LOG_NOT_AVAILABLE', 'The action completed but produced no downloadable log'] +] + +function describeWorkerError (errorCode) { + const match = WORKER_ERROR_MESSAGES.find(([prefix]) => errorCode.startsWith(prefix)) + return match ? match[1] : 'The download-logs action failed on the rack worker' +} + /** * Miner log download — three-step REST flow for large async file transfers: * @@ -109,10 +126,12 @@ async function getMinerLogDownloadStatus (ctx, req, reply) { } if (!meta) { + const error = firstError || 'ERR_LOG_NOT_AVAILABLE' return reply.code(200).send({ status: 'failed', jobId, - error: firstError || 'ERR_LOG_NOT_AVAILABLE' + error, + message: describeWorkerError(error) }) } diff --git a/workers/lib/server/handlers/power.consumption.handlers.js b/workers/lib/server/handlers/power.consumption.handlers.js new file mode 100644 index 0000000..96b7435 --- /dev/null +++ b/workers/lib/server/handlers/power.consumption.handlers.js @@ -0,0 +1,203 @@ +'use strict' + +const { RPC_METHODS, WORKER_TAGS, DCS_POWER_METER_FIELDS } = require('../../constants') +const { validateStartEnd } = require('../../metrics.utils') +const { + isCentralDCSEnabled, + getDCSTag, + fetchDcsThing, + extractSiteMainMeterPowerW +} = require('../../dcs.utils') + +// Mirror the UI consumption chart's tail-log fetch (LIMIT 288, default interval 5m). +const DEFAULT_LIMIT = 288 +const DEFAULT_INTERVAL = '5m' +// Raw watts pass through; the UI keeps any kW/MW display scaling. +const POWER_UNIT = 'W' + +const CONSUMPTION_FIELDS = { 'last.snap.stats.power_w': 1, info: 1 } +const CONSUMPTION_AGGR_FIELDS = { + site_power_w: 1, + power_w_sum_aggr: 1, + container_power_w_aggr: 1, + transformer_power_w: 1 +} + +// Site power-meter lookup (useHeaderStats source for the live "current" value). +const SITE_POWERMETER_QUERY = { 'info.pos': { $eq: 'site' } } +const SITE_POWERMETER_FIELDS = { id: 1, 'last.snap.stats.power_w': 1, tags: 1 } +const SITE_POWERMETER_LIMIT = 100 +const SITE_POWER_W_PATH = 'last.snap.stats.power_w' + +const SITE_POWER_ATTRIBUTE = 'site_power_w' +const DCS_THING_FIELDS = { id: 1, code: 1, type: 1, tags: 1, ...DCS_POWER_METER_FIELDS } + +// getChartType / removeContainerPrefix / getPowerBEAttribute mirror the same +// helpers in the UI's ConsumptionLineChart.tsx / deviceUtils.ts. +function getChartType (tag) { + if (tag.includes('container')) return 'container' + if (tag.includes('miner')) return 'miner' + return tag.replace(/^t-/, '') +} + +function removeContainerPrefix (text) { + return text.replace(/^container-/, '') +} + +function getPowerBEAttribute (tag, totalTransformerConsumption) { + if (tag.includes('container')) { + return `container_power_w_aggr.${removeContainerPrefix(tag)}` + } + if (totalTransformerConsumption) return 'transformer_power_w' + if (tag.includes('powermeter')) return 'site_power_w' + return 'power_w_sum_aggr' +} + +// Dot-path getter (lodash _get equivalent for the simple paths used here). +function getByPath (obj, path) { + if (!obj || !path) return undefined + return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj) +} + +function buildConsumptionLog (points, powerBEAttribute) { + return points.map((entry) => ({ + ts: entry.ts, + value: getByPath(entry, powerBEAttribute) || 0, + unit: POWER_UNIT + })) +} + +// min/max/avg over raw values, replicating getConsumptionGraphData's arithmetic. +// Empty range yields null min/max/avg (clean-null convention). +function computeConsumptionSummary (points, powerBEAttribute) { + if (!points.length) { + return { + min: { value: null, unit: POWER_UNIT }, + max: { value: null, unit: POWER_UNIT }, + avg: { value: null, unit: POWER_UNIT } + } + } + + let total = 0 + let min = Number.MAX_SAFE_INTEGER + let max = Number.MIN_SAFE_INTEGER + + for (const entry of points) { + const value = getByPath(entry, powerBEAttribute) || 0 + total += value + if (value < min) min = value + if (value > max) max = value + } + + return { + min: { value: min, unit: POWER_UNIT }, + max: { value: max, unit: POWER_UNIT }, + avg: { value: total / points.length, unit: POWER_UNIT } + } +} + +// "current" value, mirroring getConsumptionGraphData: for site_power_w it's the +// live site power-meter (list-things, useHeaderStats' source); otherwise the last +// historical point. The site_power_w case is the UI's conditional second fetch. +async function resolveCurrentValue (ctx, points, powerBEAttribute) { + if (powerBEAttribute === 'site_power_w') { + const listRes = await ctx.dataProxy.requestDataMap(RPC_METHODS.LIST_THINGS, { + status: 1, + query: SITE_POWERMETER_QUERY, + fields: SITE_POWERMETER_FIELDS, + limit: SITE_POWERMETER_LIMIT + }) + + const things = Array.isArray(listRes) && Array.isArray(listRes[0]) ? listRes[0] : [] + const head = things[0] + return Number(getByPath(head, SITE_POWER_W_PATH)) || 0 + } + + const last = points[points.length - 1] + return getByPath(last, powerBEAttribute) || 0 +} + +// Central-DCS site consumption: current from the DCS site_main snapshot (kW->W), +// history from the DCS thing's site_power_w tail-log stat. Until the worker/ork +// pipeline is deployed the tail-log may error or be empty, so we degrade to +// current-only (empty log, null min/max/avg) rather than fabricating history. +async function getDCSSitePowerConsumption (ctx, { start, end, interval, limit }) { + const dcsThing = await fetchDcsThing(ctx, DCS_THING_FIELDS) + const currentValue = extractSiteMainMeterPowerW(dcsThing) + + let points = [] + try { + const res = await ctx.dataProxy.requestDataMap(RPC_METHODS.TAIL_LOG, { + key: `stat-${interval}`, + type: dcsThing?.type || 'dcs', + tag: getDCSTag(ctx), + aggrFields: { [SITE_POWER_ATTRIBUTE]: 1 }, + start, + end, + limit + }) + points = Array.isArray(res) && Array.isArray(res[0]) ? res[0] : [] + } catch (e) { + points = [] + } + + const log = buildConsumptionLog(points, SITE_POWER_ATTRIBUTE) + const summary = computeConsumptionSummary(points, SITE_POWER_ATTRIBUTE) + summary.current = { value: currentValue, unit: POWER_UNIT } + + return { log, summary } +} + +// GET /site/power-consumption — server-side reproduction of the consumption line +// chart: tail-log over a tag/interval/range, tag-appropriate power attribute, +// returning { summary: min/max/avg + current, log: timeseries } in raw watts. +async function getSitePowerConsumption (ctx, req) { + const { start, end } = validateStartEnd(req) + + const tag = req.query.tag || WORKER_TAGS.MINER + const interval = req.query.interval || DEFAULT_INTERVAL + const limit = Number(req.query.limit) || DEFAULT_LIMIT + const totalTransformerConsumption = !!req.query.totalTransformerConsumption + const powerBEAttribute = req.query.powerAttribute || + getPowerBEAttribute(tag, totalTransformerConsumption) + + // Central-DCS: the site meter lives in the DCS thing, not a powermeter worker. + if (isCentralDCSEnabled(ctx) && powerBEAttribute === SITE_POWER_ATTRIBUTE) { + return getDCSSitePowerConsumption(ctx, { start, end, interval, limit }) + } + + const res = await ctx.dataProxy.requestDataMap(RPC_METHODS.TAIL_LOG, { + key: `stat-${interval}`, + type: getChartType(tag), + tag, + fields: CONSUMPTION_FIELDS, + aggrFields: CONSUMPTION_AGGR_FIELDS, + start, + end, + limit + }) + + // The chart consumes the first site's series (`_head(tailLogData)`); mirror that. + const points = Array.isArray(res) && Array.isArray(res[0]) ? res[0] : [] + + const log = buildConsumptionLog(points, powerBEAttribute) + const summary = computeConsumptionSummary(points, powerBEAttribute) + summary.current = { + value: await resolveCurrentValue(ctx, points, powerBEAttribute), + unit: POWER_UNIT + } + + return { log, summary } +} + +module.exports = { + getSitePowerConsumption, + getDCSSitePowerConsumption, + getChartType, + removeContainerPrefix, + getPowerBEAttribute, + getByPath, + buildConsumptionLog, + computeConsumptionSummary, + resolveCurrentValue +} diff --git a/workers/lib/server/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 +} diff --git a/workers/lib/server/handlers/spare.parts.handlers.js b/workers/lib/server/handlers/spare.parts.handlers.js index 8e07209..2817f56 100644 --- a/workers/lib/server/handlers/spare.parts.handlers.js +++ b/workers/lib/server/handlers/spare.parts.handlers.js @@ -212,6 +212,98 @@ async function registerSparePart (ctx, req) { } } +// Batch sibling of registerSparePart: N parts + one shared Type-1 register WO, with an optional note. +async function registerSparePartsBatch (ctx, req) { + const { rackId, parts, note } = req.body + + for (const part of parts) { + if (!WORK_ORDER_VALID_DEVICE_TYPES.includes(part.deviceType)) { + const err = new Error('ERR_INVALID_DEVICE_TYPE') + err.statusCode = 400 + throw err + } + if (!part.deviceModel || typeof part.deviceModel !== 'string') { + const err = new Error('ERR_DEVICE_MODEL_REQUIRED') + err.statusCode = 400 + throw err + } + if (!part.serialNum || typeof part.serialNum !== 'string') { + const err = new Error('ERR_SERIAL_NUM_REQUIRED') + err.statusCode = 400 + throw err + } + } + + const workOrderRackId = await getWorkOrderRackId(ctx) + const voter = req._info.user.metadata.email + const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) + const authPerms = permissions || [] + const ts = Date.now() + + // Assign every part id up front so the single shared register WO can reference them all. + const prepared = parts.map((part) => { + const partInfo = { + ...part, + location: part.location ?? SPARE_PART_INITIAL_LOCATION + } + if (note) partInfo.note = note + return { partId: randomUUID(), part, partInfo } + }) + + const woId = randomUUID() + const [summary] = prepared + const woInfo = { + type: WORK_ORDER_TYPES.REGISTER, + deviceType: summary.part.deviceType, + deviceModel: summary.part.deviceModel, + deviceIdentifier: summary.part.serialNum, + deviceCount: prepared.length, + createdBy: voter, + createdAt: ts, + partsMoves: prepared.map(({ partId, part }) => ({ + partId, + deviceType: part.deviceType, + deviceModel: part.deviceModel, + deviceIdentifier: part.serialNum, + fromLocation: null, + toLocation: SPARE_PART_INITIAL_LOCATION, + role: 'register', + ts, + user: voter + })) + } + if (note) woInfo.note = note + + const pushSingleAction = (rack, id, info) => ctx.dataProxy.requestData('pushAction', { + action: 'registerThing', + query: { rack }, + params: [{ rackId: rack, id, info }], + voter, + authPerms + }, (res, arr) => { + if (res?.error) arr.push({ id: null, errors: [res.error] }) + else arr.push(res) + }) + + const [woResults, ...partResultsList] = await Promise.all([ + pushSingleAction(workOrderRackId, woId, woInfo), + ...prepared.map(({ partId, partInfo }) => pushSingleAction(rackId, partId, partInfo)) + ]) + + const partsOut = prepared.map(({ partId }, i) => ({ + partId, + partActionId: partResultsList[i].find(r => r?.id)?.id ?? null + })) + + return { + parts: partsOut, + workOrderId: woId, + workOrderActionId: woResults.find(r => r?.id)?.id ?? null, + errors: [..._pushErrors(woResults), ...partResultsList.flatMap(_pushErrors)], + expectedActionLatencyMs: ctx.conf?.expectedActionLatencyMs ?? 1000 + } +} + function _buildSparePartQuery (qs) { const query = qs.query ? parseJsonQueryParam(qs.query, 'ERR_QUERY_INVALID_JSON') @@ -267,4 +359,4 @@ async function getRepairHistory (ctx, req) { } } -module.exports = { registerSparePart, listSpareParts, updateSparePart, getRepairHistory } +module.exports = { registerSparePart, registerSparePartsBatch, listSpareParts, updateSparePart, getRepairHistory } diff --git a/workers/lib/server/handlers/work.orders.handlers.js b/workers/lib/server/handlers/work.orders.handlers.js index 81b7480..7e2ace9 100644 --- a/workers/lib/server/handlers/work.orders.handlers.js +++ b/workers/lib/server/handlers/work.orders.handlers.js @@ -8,7 +8,7 @@ const { WORK_ORDER_VALID_DEVICE_TYPES, SPARE_PART_INITIAL_LOCATION } = require('../../constants') -const { renderWorkOrderCsv } = require('../lib/work.order.export') +const { renderWorkOrderCsv, renderRmaCsv } = require('../lib/work.order.export') const { submitWorkOrderAction, getWorkOrderRackId } = require('../lib/work.orders') async function _resolvePartByIdentifier (ctx, identifier) { @@ -38,7 +38,7 @@ async function createWorkOrder (ctx, req) { const { info: extraInfo, ...body } = req.body const info = { ...body, ...extraInfo, createdBy: voter, createdAt: Date.now() } - if (type === WORK_ORDER_TYPES.REGULAR) { + if (type === WORK_ORDER_TYPES.MICROBT_MINER || type === WORK_ORDER_TYPES.MICROBT_NON_MINER) { const part = await _resolvePartByIdentifier(ctx, deviceIdentifier) if (!part) { const err = new Error('ERR_PART_NOT_FOUND') @@ -68,18 +68,107 @@ async function createWorkOrder (ctx, req) { ts: Date.now(), user: voter }] + } else if (type === WORK_ORDER_TYPES.MOVE) { + const part = await _resolvePartByIdentifier(ctx, deviceIdentifier) + if (!part) { + const err = new Error('ERR_PART_NOT_FOUND') + err.statusCode = 400 + throw err + } + info.partsMoves = [{ + partId: part.id, + partCode: part.code, + fromLocation: part.info?.location ?? null, + toLocation: info.location ?? null, + role: 'move', + ts: Date.now(), + user: voter + }] + // Move WOs auto-close, so the relocation has to happen here or it never will. + if (info.location != null) await submitWorkOrderAction(ctx, req, 'updateThing', { id: part.id, info: { location: info.location } }, part.rack) } return submitWorkOrderAction(ctx, req, 'registerThing', { info }) } +function _buildPartsMove (type, part, device, info, voter, ts) { + const base = { + partId: part.id, + partCode: part.code, + deviceType: device.deviceType, + deviceModel: device.deviceModel, + deviceIdentifier: device.deviceIdentifier, + ts, + user: voter + } + if (type === WORK_ORDER_TYPES.MICROBT_MINER || type === WORK_ORDER_TYPES.MICROBT_NON_MINER) { + return { ...base, role: 'diagnosis' } + } + if (type === WORK_ORDER_TYPES.REGISTER) { + return { ...base, role: 'register', fromLocation: null, toLocation: SPARE_PART_INITIAL_LOCATION } + } + if (type === WORK_ORDER_TYPES.MOVE) { + return { ...base, role: 'move', fromLocation: part.info?.location ?? null, toLocation: info.location ?? null } + } + return null +} + +// Batch sibling of createWorkOrder: one work order whose partsMoves carries every device. +async function createWorkOrdersBatch (ctx, req) { + const { type, devices, info: extraInfo, ...rest } = req.body + + for (const device of devices) { + if (!WORK_ORDER_VALID_DEVICE_TYPES.includes(device.deviceType)) { + const err = new Error('ERR_INVALID_DEVICE_TYPE') + err.statusCode = 400 + throw err + } + } + + const voter = req._info.user.metadata.email + const ts = Date.now() + const [summary] = devices + + // First device is the summary used by the thing-side validator, RMA export, and single-device views. + const info = { + type, + ...rest, + ...extraInfo, + deviceType: summary.deviceType, + deviceModel: summary.deviceModel, + deviceIdentifier: summary.deviceIdentifier, + deviceCount: devices.length, + createdBy: voter, + createdAt: ts + } + + const partsMoves = [] + for (const device of devices) { + const part = await _resolvePartByIdentifier(ctx, device.deviceIdentifier) + if (!part) { + const err = new Error('ERR_PART_NOT_FOUND') + err.statusCode = 400 + throw err + } + const move = _buildPartsMove(type, part, device, info, voter, ts) + if (move) partsMoves.push(move) + // Move WOs auto-close, so relocate each part here or it never happens. + if (type === WORK_ORDER_TYPES.MOVE && info.location != null) { + await submitWorkOrderAction(ctx, req, 'updateThing', { id: part.id, info: { location: info.location } }, part.rack) + } + } + info.partsMoves = partsMoves + + return submitWorkOrderAction(ctx, req, 'registerThing', { info }) +} + async function updateWorkOrder (ctx, req) { const { info: extraInfo, ...body } = req.body return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info: { ...body, ...extraInfo } }) } async function closeWorkOrder (ctx, req) { - const info = { status: 'closed' } + const info = { status: 'closed', closedAt: Date.now() } if (req.body?.finalResult) info.finalResult = req.body.finalResult return submitWorkOrderAction(ctx, req, 'updateThing', { id: req.params.id, info }) } @@ -206,6 +295,22 @@ async function exportWorkOrder (ctx, req, rep) { return rep.send(renderWorkOrderCsv(wo)) } +async function exportWorkOrdersRma (ctx, req, rep) { + const ids = req.query.ids.split(',').map(s => s.trim()).filter(Boolean) + const params = { + query: { + type: WORK_ORDER_THING_TYPE, + $or: [{ id: { $in: ids } }, { code: { $in: ids } }] + } + } + const results = await ctx.dataProxy.requestData('listThings', params) + const wos = flattenRpcResults(results).filter(wo => wo?.info?.type === WORK_ORDER_TYPES.MICROBT_MINER) + + rep.header('content-type', 'text/csv; charset=utf-8') + rep.header('content-disposition', 'attachment; filename="rma.csv"') + return rep.send(renderRmaCsv(wos)) +} + async function getWorkOrderAudit (ctx, req) { const payload = { logType: 'info', @@ -221,6 +326,7 @@ async function getWorkOrderAudit (ctx, req) { module.exports = { createWorkOrder, + createWorkOrdersBatch, listWorkOrders, getWorkOrder, updateWorkOrder, @@ -229,5 +335,6 @@ module.exports = { assignWorkOrder, appendWorkLogEntry, getWorkOrderAudit, - exportWorkOrder + exportWorkOrder, + exportWorkOrdersRma } diff --git a/workers/lib/server/index.js b/workers/lib/server/index.js index 5ced8d0..13de83e 100644 --- a/workers/lib/server/index.js +++ b/workers/lib/server/index.js @@ -11,6 +11,7 @@ const wsRoutes = require('./routes/ws.routes') const financeRoutes = require('./routes/finance.routes') const poolsRoutes = require('./routes/pools.routes') const siteRoutes = require('./routes/site.routes') +const powerConsumptionRoutes = require('./routes/power.consumption.routes') const configsRoutes = require('./routes/configs.routes') const devicesRoutes = require('./routes/devices.routes') const metricsRoutes = require('./routes/metrics.routes') @@ -43,6 +44,7 @@ function routes (ctx) { ...financeRoutes(ctx), ...poolsRoutes(ctx), ...siteRoutes(ctx), + ...powerConsumptionRoutes(ctx), ...configsRoutes(ctx), ...devicesRoutes(ctx), ...metricsRoutes(ctx), diff --git a/workers/lib/server/lib/routeHelpers.js b/workers/lib/server/lib/routeHelpers.js index 154ff27..a6d083f 100644 --- a/workers/lib/server/lib/routeHelpers.js +++ b/workers/lib/server/lib/routeHelpers.js @@ -26,7 +26,7 @@ function createAuthHandler (ctx, handler) { function createAuthOnRequest (ctx, perms = null) { return async (req, rep) => { await authCheck(ctx, req, rep) - if (perms) { + if (perms && !ctx.noAuth) { await capCheck(ctx, req, rep, perms) } } diff --git a/workers/lib/server/lib/work.order.export.js b/workers/lib/server/lib/work.order.export.js index df6b8ad..5acc1b0 100644 --- a/workers/lib/server/lib/work.order.export.js +++ b/workers/lib/server/lib/work.order.export.js @@ -1,6 +1,7 @@ 'use strict' const { csvEscape } = require('../../utils') +const { RMA_COLUMNS } = require('../../constants') function renderWorkOrderCsv (wo) { const { partsMoves, ...woFields } = wo.info || {} @@ -25,4 +26,30 @@ function renderWorkOrderCsv (wo) { return lines.join('\r\n') + '\r\n' } -module.exports = { renderWorkOrderCsv } +function renderRmaCsv (workOrders) { + const rows = workOrders.map((wo) => { + const info = wo.info || {} + const moves = Array.isArray(info.partsMoves) ? info.partsMoves : [] + const repaired = moves.find(m => m.role === 'repaired') || moves.find(m => m.role === 'diagnosis') || moves[0] || {} + const replaced = moves.find(m => m.role === 'replacement') || repaired + const repairTs = info.closedAt ?? info.createdAt + return [ + wo.code, + info.deviceModel, + info.deviceIdentifier, + repaired.partCode, + replaced.partCode, + info.issue, + info.finalResult, + info.remarks, + info.deviceModel, + repairTs ? new Date(repairTs).toISOString().slice(0, 10) : '', + info.assignedTo ?? info.createdBy + ] + }) + + const lines = [RMA_COLUMNS, ...rows].map(row => row.map(csvEscape).join(',')) + return lines.join('\r\n') + '\r\n' +} + +module.exports = { renderWorkOrderCsv, renderRmaCsv } diff --git a/workers/lib/server/lib/work.orders.js b/workers/lib/server/lib/work.orders.js index 6a0127a..291139b 100644 --- a/workers/lib/server/lib/work.orders.js +++ b/workers/lib/server/lib/work.orders.js @@ -14,8 +14,8 @@ async function getWorkOrderRackId (ctx) { return rack.id } -async function submitWorkOrderAction (ctx, req, action, paramObj) { - const rackId = await getWorkOrderRackId(ctx) +async function submitWorkOrderAction (ctx, req, action, paramObj, rackId) { + rackId = rackId || await getWorkOrderRackId(ctx) const { permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) return ctx.dataProxy.requestData('pushAction', { diff --git a/workers/lib/server/routes/metrics.routes.js b/workers/lib/server/routes/metrics.routes.js index bd29650..6ac935d 100644 --- a/workers/lib/server/routes/metrics.routes.js +++ b/workers/lib/server/routes/metrics.routes.js @@ -12,6 +12,7 @@ const { getPowerMode, getPowerModeTimeline, getTemperature, + getCooling, getContainerTelemetry, getContainerHistory } = require('../handlers/metrics.handlers') @@ -148,6 +149,24 @@ module.exports = (ctx) => { getTemperature ) }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.METRICS_COOLING, + schema: { + querystring: schemas.query.cooling + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'metrics/cooling', + req.query.start, + req.query.end, + req.query.interval + ], + ENDPOINTS.METRICS_COOLING, + getCooling + ) + }, { method: HTTP_METHODS.GET, url: ENDPOINTS.METRICS_CONTAINER_HISTORY, diff --git a/workers/lib/server/routes/power.consumption.routes.js b/workers/lib/server/routes/power.consumption.routes.js new file mode 100644 index 0000000..2f5ccde --- /dev/null +++ b/workers/lib/server/routes/power.consumption.routes.js @@ -0,0 +1,43 @@ +'use strict' + +const { + ENDPOINTS, + HTTP_METHODS +} = require('../../constants') +const { getSitePowerConsumption } = require('../handlers/power.consumption.handlers') +const { createCachedAuthRoute } = require('../lib/routeHelpers') + +module.exports = (ctx) => { + return [ + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.SITE_POWER_CONSUMPTION, + schema: { + querystring: { + type: 'object', + properties: { + start: { type: 'integer' }, + end: { type: 'integer' }, + interval: { type: 'string' }, + tag: { type: 'string' }, + powerAttribute: { type: 'string' }, + totalTransformerConsumption: { type: 'boolean' }, + limit: { type: 'integer' }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + } + }, + ...createCachedAuthRoute( + ctx, + (req) => [ + 'site-power-consumption', req.query.start, req.query.end, + req.query.interval, req.query.tag, req.query.powerAttribute, + req.query.totalTransformerConsumption, req.query.limit + ], + ENDPOINTS.SITE_POWER_CONSUMPTION, + getSitePowerConsumption + ) + } + ] +} diff --git a/workers/lib/server/routes/spare.parts.routes.js b/workers/lib/server/routes/spare.parts.routes.js index cc28933..3b80d27 100644 --- a/workers/lib/server/routes/spare.parts.routes.js +++ b/workers/lib/server/routes/spare.parts.routes.js @@ -6,7 +6,7 @@ const { AUTH_PERMISSIONS } = require('../../constants') const schemas = require('../schemas/spare.parts.schemas') -const { registerSparePart, listSpareParts, updateSparePart, getRepairHistory } = require('../handlers/spare.parts.handlers') +const { registerSparePart, registerSparePartsBatch, listSpareParts, updateSparePart, getRepairHistory } = require('../handlers/spare.parts.handlers') const { createAuthRoute, createCachedAuthRoute } = require('../lib/routeHelpers') const { stableJsonString } = require('../../utils') @@ -17,6 +17,12 @@ module.exports = (ctx) => [ schema: schemas.register, ...createAuthRoute(ctx, registerSparePart, [AUTH_PERMISSIONS.INVENTORY]) }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.SPARE_PARTS_BATCH, + schema: schemas.registerBatch, + ...createAuthRoute(ctx, registerSparePartsBatch, [AUTH_PERMISSIONS.INVENTORY]) + }, { method: HTTP_METHODS.GET, url: ENDPOINTS.SPARE_PARTS, diff --git a/workers/lib/server/routes/work.orders.routes.js b/workers/lib/server/routes/work.orders.routes.js index cf95210..9f73541 100644 --- a/workers/lib/server/routes/work.orders.routes.js +++ b/workers/lib/server/routes/work.orders.routes.js @@ -8,6 +8,7 @@ const { const schemas = require('../schemas/work.orders.schemas') const { createWorkOrder, + createWorkOrdersBatch, listWorkOrders, getWorkOrder, updateWorkOrder, @@ -16,7 +17,8 @@ const { assignWorkOrder, appendWorkLogEntry, getWorkOrderAudit, - exportWorkOrder + exportWorkOrder, + exportWorkOrdersRma } = require('../handlers/work.orders.handlers') const { createAuthRoute, createCachedAuthRoute } = require('../lib/routeHelpers') const { stableJsonString } = require('../../utils') @@ -28,6 +30,12 @@ module.exports = (ctx) => [ schema: schemas.create, ...createAuthRoute(ctx, createWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) }, + { + method: HTTP_METHODS.POST, + url: ENDPOINTS.WORK_ORDERS_BATCH, + schema: schemas.createBatch, + ...createAuthRoute(ctx, createWorkOrdersBatch, [AUTH_PERMISSIONS.WORK_ORDER]) + }, { method: HTTP_METHODS.GET, url: ENDPOINTS.WORK_ORDERS, @@ -114,5 +122,11 @@ module.exports = (ctx) => [ url: ENDPOINTS.WORK_ORDER_EXPORT, schema: schemas.export, ...createAuthRoute(ctx, exportWorkOrder, [AUTH_PERMISSIONS.WORK_ORDER]) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.WORK_ORDER_EXPORT_RMA, + schema: schemas.exportRma, + ...createAuthRoute(ctx, exportWorkOrdersRma, [AUTH_PERMISSIONS.WORK_ORDER]) } ] diff --git a/workers/lib/server/schemas/metrics.schemas.js b/workers/lib/server/schemas/metrics.schemas.js index 84840a8..2f80707 100644 --- a/workers/lib/server/schemas/metrics.schemas.js +++ b/workers/lib/server/schemas/metrics.schemas.js @@ -72,6 +72,16 @@ const schemas = { }, required: ['start', 'end'] }, + cooling: { + type: 'object', + properties: { + start: { type: 'integer', minimum: 0 }, + end: { type: 'integer', minimum: 0 }, + interval: { type: 'string', enum: ['1h', '1d', '1w', 'hourly', 'daily', 'weekly'] }, + overwriteCache: { type: 'boolean' } + }, + required: ['start', 'end'] + }, containerTelemetry: { type: 'object', properties: { diff --git a/workers/lib/server/schemas/spare.parts.schemas.js b/workers/lib/server/schemas/spare.parts.schemas.js index 69dea30..b5c59f1 100644 --- a/workers/lib/server/schemas/spare.parts.schemas.js +++ b/workers/lib/server/schemas/spare.parts.schemas.js @@ -17,6 +17,30 @@ const register = { } } +// Batch variant of `register`: many parts in one request, plus an optional operator note. +const registerBatch = { + body: { + type: 'object', + required: ['rackId', 'parts'], + additionalProperties: false, + properties: { + rackId: { type: 'string', minLength: 1 }, + parts: { + type: 'array', + minItems: 1, + maxItems: 100, + items: { + type: 'object', + additionalProperties: true, + minProperties: 1, + required: ['deviceType'] + } + }, + note: { type: 'string', maxLength: 4000 } + } + } +} + const update = { params: { type: 'object', @@ -74,4 +98,4 @@ const list = { } } -module.exports = { register, list, update, repairHistory } +module.exports = { register, registerBatch, list, update, repairHistory } diff --git a/workers/lib/server/schemas/work.orders.schemas.js b/workers/lib/server/schemas/work.orders.schemas.js index f0858e8..e7615e7 100644 --- a/workers/lib/server/schemas/work.orders.schemas.js +++ b/workers/lib/server/schemas/work.orders.schemas.js @@ -1,6 +1,8 @@ 'use strict' -const types = { type: 'integer', enum: [1, 2] } +const { WORK_ORDER_TYPES } = require('../../constants') + +const types = { type: 'integer', enum: Object.values(WORK_ORDER_TYPES) } const warranty = { type: ['object', 'null'], @@ -34,7 +36,49 @@ const create = { } } }, - if: { properties: { type: { const: 2 } } }, + if: { properties: { type: { enum: [WORK_ORDER_TYPES.MICROBT_MINER, WORK_ORDER_TYPES.MICROBT_NON_MINER] } } }, + then: { required: ['issue'] } + } +} + +// Batch variant of `create`: one work order carrying many devices. +const createBatch = { + body: { + type: 'object', + required: ['type', 'devices'], + additionalProperties: false, + properties: { + type: types, + devices: { + type: 'array', + minItems: 1, + maxItems: 100, + items: { + type: 'object', + required: ['deviceType', 'deviceModel', 'deviceIdentifier'], + additionalProperties: false, + properties: { + deviceType: { type: 'string', minLength: 1, maxLength: 100 }, + deviceModel: { type: 'string', minLength: 1, maxLength: 100 }, + deviceIdentifier: { type: 'string', minLength: 1, maxLength: 200 } + } + } + }, + issue: { type: 'string', minLength: 1, maxLength: 2000 }, + assignedTo: { type: ['string', 'null'], maxLength: 200 }, + warranty, + info: { + type: 'object', + additionalProperties: false, + properties: { + notes: { type: 'string', maxLength: 4000 }, + remarks: { type: 'string', maxLength: 4000 }, + site: { type: 'string', maxLength: 200 }, + location: { type: 'string', maxLength: 200 } + } + } + }, + if: { properties: { type: { enum: [WORK_ORDER_TYPES.MICROBT_MINER, WORK_ORDER_TYPES.MICROBT_NON_MINER] } } }, then: { required: ['issue'] } } } @@ -162,4 +206,15 @@ const exportRoute = { } } -module.exports = { create, list, byId, update, close, cancel, assign, audit, log, export: exportRoute } +const exportRma = { + querystring: { + type: 'object', + required: ['ids'], + additionalProperties: false, + properties: { + ids: { type: 'string', minLength: 1, maxLength: 4000 } + } + } +} + +module.exports = { create, createBatch, list, byId, update, close, cancel, assign, audit, log, export: exportRoute, exportRma }