diff --git a/src/Views/Reports/EnergyReport/EnergyReportMinerView/EnergyReportMinerTypeView.tsx b/src/Views/Reports/EnergyReport/EnergyReportMinerView/EnergyReportMinerTypeView.tsx index 78a85018..b619c6f7 100644 --- a/src/Views/Reports/EnergyReport/EnergyReportMinerView/EnergyReportMinerTypeView.tsx +++ b/src/Views/Reports/EnergyReport/EnergyReportMinerView/EnergyReportMinerTypeView.tsx @@ -1,8 +1,4 @@ -import _filter from 'lodash/filter' -import _get from 'lodash/get' import _head from 'lodash/head' -import _keys from 'lodash/keys' -import _map from 'lodash/map' import { ChartHeader, @@ -10,9 +6,14 @@ import { ChartTitle, EnergyReportMinerTypeViewContainer, } from './EnergyReportMinerView.styles' -import { ENERGY_REPORT_MINER_VIEW_SLICES, sliceConfig } from './EnergyReportMinerView.utils' +import { + ENERGY_REPORT_MINER_VIEW_SLICES, + type EnergyReportMinerViewSlice, + sliceConfig, + transformToBarData, +} from './EnergyReportMinerView.utils' -import { useGetListThingsQuery, useGetTailLogQuery } from '@/app/services/api' +import { useGetListThingsQuery, useGetMetricsConsumptionGroupedQuery } from '@/app/services/api' import { formatPowerConsumption } from '@/app/utils/deviceUtils' import { formatUnit } from '@/app/utils/format' import { BarSteppedLineChart } from '@/Components/BarSteppedLineChart/BarSteppedLineChart' @@ -22,28 +23,24 @@ import ReportTimeFrameSelector, { import { Spinner } from '@/Components/Spinner/Spinner' interface EnergyReportMinerViewProps { - slice?: string + slice?: EnergyReportMinerViewSlice } const EnergyReportMinerView = ({ slice = ENERGY_REPORT_MINER_VIEW_SLICES.MINER_TYPE, }: EnergyReportMinerViewProps) => { const reportTimeFrameState = useReportTimeFrameSelectorState() - - const start = reportTimeFrameState.start - const end = reportTimeFrameState.end + const { start, end } = reportTimeFrameState + const { groupBy, title } = sliceConfig[slice] const { - data: tailLogData, - isLoading: isMinerTailLogLoading, - isFetching: isMinerTailLogFetching, - } = useGetTailLogQuery({ - key: 'stat-5m', - type: 'miner', - tag: 't-miner', - limit: 1, + data: consumptionResponse, + isLoading: isConsumptionLoading, + isFetching: isConsumptionFetching, + } = useGetMetricsConsumptionGroupedQuery({ start: start.valueOf(), end: end.valueOf(), + groupBy, }) const { data: containerListData, isLoading: isContainerListDataLoading } = useGetListThingsQuery({ @@ -59,29 +56,9 @@ const EnergyReportMinerView = ({ info?: { container?: string } }> - const { title, key: tailLogField, getLabelName, filterCategory } = sliceConfig[slice] - - const tailLogEntry = _head(_head(tailLogData as unknown[][])) as - | Record> - | undefined - - const categories = _filter(_keys(_get(tailLogEntry, [tailLogField], {})), (category) => { - if (filterCategory) { - return filterCategory(category) - } - return true - }) - - const labels = _map(categories, (category) => getLabelName(category, containers)) - const chartData = { - labels, - dataSet1: { - label: 'Power Consumption', - data: _map(categories, (label) => _get(tailLogEntry, [tailLogField, label], 0)), - }, - } + const chartData = transformToBarData(consumptionResponse, slice, containers) - const isLoading = isMinerTailLogLoading || isMinerTailLogFetching || isContainerListDataLoading + const isLoading = isConsumptionLoading || isConsumptionFetching || isContainerListDataLoading return ( diff --git a/src/Views/Reports/EnergyReport/EnergyReportMinerView/EnergyReportMinerView.utils.ts b/src/Views/Reports/EnergyReport/EnergyReportMinerView/EnergyReportMinerView.utils.ts index 5b33b99e..48144b65 100644 --- a/src/Views/Reports/EnergyReport/EnergyReportMinerView/EnergyReportMinerView.utils.ts +++ b/src/Views/Reports/EnergyReport/EnergyReportMinerView/EnergyReportMinerView.utils.ts @@ -1,15 +1,23 @@ +import _filter from 'lodash/filter' import _find from 'lodash/find' import _get from 'lodash/get' import _isNil from 'lodash/isNil' +import _last from 'lodash/last' +import _map from 'lodash/map' +import _toPairs from 'lodash/toPairs' import { getContainerName } from '@/app/utils/containerUtils' import { MINER_TYPE_NAME_MAP } from '@/constants/deviceConstants' +import type { MetricsConsumptionGroupBy, MetricsConsumptionGroupedResponse } from '@/types/api' export const ENERGY_REPORT_MINER_VIEW_SLICES = { MINER_TYPE: 'MINER_TYPE', MINER_UNIT: 'MINER_UNIT', } as const +export type EnergyReportMinerViewSlice = + (typeof ENERGY_REPORT_MINER_VIEW_SLICES)[keyof typeof ENERGY_REPORT_MINER_VIEW_SLICES] + interface Container { type?: string info?: { @@ -18,29 +26,67 @@ interface Container { } interface SliceConfigItem { - key: string + groupBy: MetricsConsumptionGroupBy title: string getLabelName: (category: string, containers?: Container[]) => string - filterCategory?: (category: string, containers?: Container[]) => boolean + filterCategory?: (category: string) => boolean } -export const sliceConfig: Record = { +// BE leaks positional rollup keys ("group-1..N", "maintenance") into the +// container-grouped response alongside real container ids. Drop them until +// BE filters at source. +const isLeakedContainerKey = (key: string): boolean => + key === 'maintenance' || /^group-\d+$/.test(key) + +export const sliceConfig: Record = { [ENERGY_REPORT_MINER_VIEW_SLICES.MINER_TYPE]: { - key: 'power_w_type_group_sum_aggr', + groupBy: 'miner', title: 'Power Consumption', - getLabelName: (category: string) => _get(MINER_TYPE_NAME_MAP, [category], category), + getLabelName: (category) => _get(MINER_TYPE_NAME_MAP, [category], category), }, [ENERGY_REPORT_MINER_VIEW_SLICES.MINER_UNIT]: { - key: 'power_w_container_group_sum_aggr', + groupBy: 'container', title: 'Power Consumption', - filterCategory: (category: string) => category !== 'maintenance', - getLabelName: (category: string, containers?: Container[]) => { + filterCategory: (category) => !isLeakedContainerKey(category), + getLabelName: (category, containers) => { const container = _find(containers, (c) => _get(c, ['info', 'container']) === category) if (_isNil(container?.type)) { return category } - return getContainerName(category, container.type) }, }, } + +interface BarChartData { + labels: string[] + dataSet1: { label: string; data: number[] } +} + +/** + * Reduce v2 grouped consumption response to a single-bar series using the + * latest log entry within the range. Selectors only expose day-or-coarser + * presets, so "latest day's per-group avg power" is the meaningful snapshot. + */ +export const transformToBarData = ( + response: MetricsConsumptionGroupedResponse | undefined, + slice: EnergyReportMinerViewSlice, + containers: Container[], +): BarChartData => { + const config = sliceConfig[slice] + const latest = _last(response?.log) + const powerW = latest?.powerW ?? {} + + const entries = _toPairs(powerW) + const filtered = config.filterCategory + ? _filter(entries, ([key]) => config.filterCategory!(key)) + : entries + + return { + labels: _map(filtered, ([key]) => config.getLabelName(key, containers)), + dataSet1: { + label: config.title, + data: _map(filtered, ([, value]) => value), + }, + } +} diff --git a/src/Views/Reports/EnergyReport/EnergyReportMinerView/specs/EnergyReportMinerView.utils.test.ts b/src/Views/Reports/EnergyReport/EnergyReportMinerView/specs/EnergyReportMinerView.utils.test.ts index daff439e..6af93f1b 100644 --- a/src/Views/Reports/EnergyReport/EnergyReportMinerView/specs/EnergyReportMinerView.utils.test.ts +++ b/src/Views/Reports/EnergyReport/EnergyReportMinerView/specs/EnergyReportMinerView.utils.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { ENERGY_REPORT_MINER_VIEW_SLICES, sliceConfig } from '../EnergyReportMinerView.utils' +import { + ENERGY_REPORT_MINER_VIEW_SLICES, + sliceConfig, + transformToBarData, +} from '../EnergyReportMinerView.utils' + +import type { MetricsConsumptionGroupedResponse } from '@/types/api' describe('EnergyReportMinerView.utils', () => { describe('ENERGY_REPORT_MINER_VIEW_SLICES', () => { @@ -11,18 +17,26 @@ describe('EnergyReportMinerView.utils', () => { }) describe('sliceConfig', () => { - it('MINER_TYPE slice has key and getLabelName', () => { + it('MINER_TYPE slice groups by miner type', () => { const config = sliceConfig[ENERGY_REPORT_MINER_VIEW_SLICES.MINER_TYPE] - expect(config.key).toBe('power_w_type_group_sum_aggr') + expect(config.groupBy).toBe('miner') expect(config.title).toBe('Power Consumption') expect(config.getLabelName('miner-wm-m56')).toBeDefined() }) - it('MINER_UNIT slice filterCategory excludes maintenance', () => { + it('MINER_UNIT slice groups by container', () => { const config = sliceConfig[ENERGY_REPORT_MINER_VIEW_SLICES.MINER_UNIT] - expect(config.filterCategory).toBeDefined() - expect(config.filterCategory!('maintenance')).toBe(false) - expect(config.filterCategory!('bitdeer-1')).toBe(true) + expect(config.groupBy).toBe('container') + expect(config.title).toBe('Power Consumption') + }) + + it('MINER_UNIT filterCategory drops leaked rollup keys', () => { + const { filterCategory } = sliceConfig[ENERGY_REPORT_MINER_VIEW_SLICES.MINER_UNIT] + expect(filterCategory).toBeDefined() + expect(filterCategory!('maintenance')).toBe(false) + expect(filterCategory!('group-1')).toBe(false) + expect(filterCategory!('group-12')).toBe(false) + expect(filterCategory!('bitdeer-1')).toBe(true) }) it('MINER_UNIT getLabelName returns category when container has no type', () => { @@ -39,4 +53,60 @@ describe('EnergyReportMinerView.utils', () => { expect(typeof label).toBe('string') }) }) + + describe('transformToBarData', () => { + const mockResponse = ( + log: MetricsConsumptionGroupedResponse['log'], + ): MetricsConsumptionGroupedResponse => ({ + log, + summary: { avgPowerW: null, totalConsumptionMWh: 0 }, + }) + + it('returns empty chart data when response is undefined', () => { + const chart = transformToBarData(undefined, ENERGY_REPORT_MINER_VIEW_SLICES.MINER_TYPE, []) + expect(chart.labels).toEqual([]) + expect(chart.dataSet1.data).toEqual([]) + }) + + it('returns empty chart data when log is empty', () => { + const chart = transformToBarData( + mockResponse([]), + ENERGY_REPORT_MINER_VIEW_SLICES.MINER_TYPE, + [], + ) + expect(chart.labels).toEqual([]) + expect(chart.dataSet1.data).toEqual([]) + }) + + it('uses the latest log entry for the bar values', () => { + const response = mockResponse([ + { ts: 1, powerW: { 'miner-wm-m56': 100 }, consumptionMWh: { 'miner-wm-m56': 0.0024 } }, + { ts: 2, powerW: { 'miner-wm-m56': 250 }, consumptionMWh: { 'miner-wm-m56': 0.006 } }, + ]) + const chart = transformToBarData(response, ENERGY_REPORT_MINER_VIEW_SLICES.MINER_TYPE, []) + expect(chart.dataSet1.data).toEqual([250]) + expect(chart.labels).toHaveLength(1) + }) + + it('drops leaked rollup keys for MINER_UNIT', () => { + const response = mockResponse([ + { + ts: 1, + powerW: { 'bitdeer-1': 100, 'group-1': 50, maintenance: 25 }, + consumptionMWh: null, + }, + ]) + const chart = transformToBarData(response, ENERGY_REPORT_MINER_VIEW_SLICES.MINER_UNIT, []) + expect(chart.dataSet1.data).toEqual([100]) + expect(chart.labels).toEqual(['bitdeer-1']) + }) + + it('keeps all keys for MINER_TYPE (no filter)', () => { + const response = mockResponse([ + { ts: 1, powerW: { 'miner-a': 100, 'miner-b': 200 }, consumptionMWh: null }, + ]) + const chart = transformToBarData(response, ENERGY_REPORT_MINER_VIEW_SLICES.MINER_TYPE, []) + expect(chart.dataSet1.data).toEqual([100, 200]) + }) + }) }) diff --git a/src/Views/Reports/EnergyReport/hooks/specs/energyReportHooks.test.ts b/src/Views/Reports/EnergyReport/hooks/specs/energyReportHooks.test.ts index be427ea8..3ac251b5 100644 --- a/src/Views/Reports/EnergyReport/hooks/specs/energyReportHooks.test.ts +++ b/src/Views/Reports/EnergyReport/hooks/specs/energyReportHooks.test.ts @@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' const mockFns = vi.hoisted(() => ({ - tailLogRangeAggr: vi.fn(() => ({ + metricsConsumption: vi.fn(() => ({ data: undefined as unknown, isLoading: false, isFetching: false, @@ -14,7 +14,7 @@ const mockFns = vi.hoisted(() => ({ })) vi.mock('@/app/services/api', () => ({ - useGetTailLogRangeAggrQuery: mockFns.tailLogRangeAggr, + useGetMetricsConsumptionQuery: mockFns.metricsConsumption, useGetGlobalConfigQuery: mockFns.globalConfig, useGetListThingsQuery: mockFns.listThings, useGetTailLogQuery: mockFns.tailLog, @@ -42,46 +42,28 @@ describe('useEnergyReportData', () => { expect(result.current.nominalValue).toBeNull() }) - it('processes array response with powermeter data', () => { - const powermeterData = { - type: 'powermeter', - data: [{ ts: 1000, val: { site_power_w: 5000 } }], - } - mockFns.tailLogRangeAggr.mockReturnValueOnce({ - data: [[powermeterData]], - isLoading: false, - isFetching: false, - error: null, - }) - const { result } = renderHook(() => useEnergyReportData(defaultDateRange)) - expect(result.current.data).toHaveLength(1) - expect(result.current.data[0].consumption).toBe(5000) - }) - - it('handles missing site_power_w in val (falls back to 0)', () => { - const powermeterData = { - type: 'powermeter', - data: [{ ts: 1000, val: {} }], - } - mockFns.tailLogRangeAggr.mockReturnValueOnce({ - data: [[powermeterData]], + it('maps v2 consumption log entries to chart data', () => { + mockFns.metricsConsumption.mockReturnValueOnce({ + data: { + log: [{ ts: 1000, powerW: 5000, consumptionMWh: 0.12 }], + summary: { avgPowerW: 5000, totalConsumptionMWh: 0.12 }, + }, isLoading: false, isFetching: false, error: null, }) const { result } = renderHook(() => useEnergyReportData(defaultDateRange)) - expect(result.current.data[0].consumption).toBe(0) + expect(result.current.data).toEqual([{ ts: 1000, consumption: 5000 }]) }) - it('handles non-array consumptionResponse', () => { - mockFns.tailLogRangeAggr.mockReturnValueOnce({ - data: { type: 'powermeter', data: [] }, + it('returns empty data when v2 response has no log', () => { + mockFns.metricsConsumption.mockReturnValueOnce({ + data: { log: [], summary: {} }, isLoading: false, isFetching: false, error: null, }) const { result } = renderHook(() => useEnergyReportData(defaultDateRange)) - // Non-array responseData → empty data expect(result.current.data).toEqual([]) }) @@ -95,8 +77,8 @@ describe('useEnergyReportData', () => { expect(result.current.nominalValue).toBe(2000000) }) - it('shows isLoading=true when tailLog is fetching', () => { - mockFns.tailLogRangeAggr.mockReturnValueOnce({ + it('shows isLoading=true when consumption query is fetching', () => { + mockFns.metricsConsumption.mockReturnValueOnce({ data: undefined as unknown, isLoading: false, isFetching: true, diff --git a/src/Views/Reports/EnergyReport/hooks/useEnergyReportData.ts b/src/Views/Reports/EnergyReport/hooks/useEnergyReportData.ts index 81d1964d..2d5bc904 100644 --- a/src/Views/Reports/EnergyReport/hooks/useEnergyReportData.ts +++ b/src/Views/Reports/EnergyReport/hooks/useEnergyReportData.ts @@ -1,6 +1,7 @@ import _head from 'lodash/head' +import _map from 'lodash/map' -import { useGetTailLogRangeAggrQuery, useGetGlobalConfigQuery } from '@/app/services/api' +import { useGetGlobalConfigQuery, useGetMetricsConsumptionQuery } from '@/app/services/api' interface DateRange { start: number @@ -11,14 +12,6 @@ interface GlobalConfig { nominalPowerAvailability_MW?: number } -interface RangeAggrResponse { - type: string - data: Array<{ - ts: number - val: Record - }> -} - interface PowerConsumptionData { data: Array<{ ts: number; consumption: number }> nominalValue?: number | null @@ -27,65 +20,29 @@ interface PowerConsumptionData { } /** - * Custom hook to fetch power consumption data for the Energy Report - * @param dateRange - Date range object with start and end timestamps - * @returns Power consumption chart data and loading states + * Fetches site power consumption for the Energy Report site chart from the + * v2 /auth/metrics/consumption endpoint. */ export const useEnergyReportData = (dateRange: DateRange): PowerConsumptionData => { - // Convert timestamps to ISO date strings - const startDate = new Date(dateRange.start).toISOString() - const endDate = new Date(dateRange.end).toISOString() - - // Fetch global config for nominal values const { data: globalConfig, isLoading: isLoadingNominalValues } = useGetGlobalConfigQuery({}) - // Fetch site power consumption data - daily aggregation from backend const { data: consumptionResponse, isLoading: isLoadingConsumption, isFetching: isFetchingConsumption, error: consumptionError, - } = useGetTailLogRangeAggrQuery({ - keys: JSON.stringify([ - { - type: 'powermeter', - startDate, - endDate, - fields: { - site_power_w: 1, - }, - shouldReturnDailyData: 1, - }, - ]), - }) + } = useGetMetricsConsumptionQuery({ start: dateRange.start, end: dateRange.end }) - // Process consumption data - let consumptionChartData: Array<{ ts: number; consumption: number }> = [] - if (consumptionResponse) { - // Response is wrapped in an array, get the first element - const responseData = Array.isArray(consumptionResponse) - ? consumptionResponse[0] - : consumptionResponse - if (Array.isArray(responseData)) { - const powermeterData = responseData.find( - (item: RangeAggrResponse) => item.type === 'powermeter', - ) - if (powermeterData?.data) { - consumptionChartData = powermeterData.data.map( - (item: { ts: number; val: Record }) => ({ - ts: item.ts, - consumption: item.val.site_power_w || 0, - }), - ) - } - } - } + const data = _map(consumptionResponse?.log ?? [], ({ ts, powerW }) => ({ + ts, + consumption: powerW, + })) return { - data: consumptionChartData, + data, nominalValue: isLoadingNominalValues ? null - : (_head(globalConfig as GlobalConfig[])?.nominalPowerAvailability_MW ?? 0) * 1000000, // Convert MW to W + : (_head(globalConfig as GlobalConfig[])?.nominalPowerAvailability_MW ?? 0) * 1_000_000, // MW → W isLoading: isLoadingConsumption || isFetchingConsumption, error: consumptionError, } diff --git a/src/Views/Reports/Hashrate/Hashrate.types.ts b/src/Views/Reports/Hashrate/Hashrate.types.ts index 70006a7b..1d27c564 100644 --- a/src/Views/Reports/Hashrate/Hashrate.types.ts +++ b/src/Views/Reports/Hashrate/Hashrate.types.ts @@ -91,15 +91,3 @@ export interface MinerTypeViewFilters { export interface MiningUnitViewFilters { minerType: string[] } - -// API response types -export interface HashrateApiDataPoint { - hashrate_mhs_5m_type_group_sum_aggr: Record - hashrate_mhs_5m_container_group_sum_aggr: Record - ts: string // Format: "startTs-endTs" - aggrTsRange: string - aggrCount: number - aggrIntervals: number -} - -export type HashrateApiResponse = HashrateApiDataPoint[] diff --git a/src/Views/Reports/Hashrate/Hashrate.utils.test.ts b/src/Views/Reports/Hashrate/Hashrate.utils.test.ts index 1799eba7..703647eb 100644 --- a/src/Views/Reports/Hashrate/Hashrate.utils.test.ts +++ b/src/Views/Reports/Hashrate/Hashrate.utils.test.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest' + import { getMinerTypeOptionsFromApi, getMiningUnitOptionsFromApi, @@ -6,258 +8,121 @@ import { transformToSiteViewData, } from './Hashrate.utils' -// Mock API data -const createMockApiDataPoint = ( - ts: string, - typeData: Record, - containerData: Record, -) => ({ - hashrate_mhs_5m_type_group_sum_aggr: typeData, - hashrate_mhs_5m_container_group_sum_aggr: containerData, - ts, - aggrTsRange: '1D', - aggrCount: 1, - aggrIntervals: 288, -}) +import type { MetricsHashrateGroupedResponse } from '@/types/api' -const mockApiData = [ - createMockApiDataPoint( - '1701388800000-1701475199999', - { - 'miner-am-s19xp': 5000000, // 5 TH/s - 'miner-wm-m56s': 3000000, // 3 TH/s - 'miner-av-a1346': 0, // Zero value - should be filtered - }, - { - 'bitdeer-1a': 4000000, // 4 TH/s - 'bitdeer-4a': 2000000, // 2 TH/s - maintenance: 0, // Zero value - }, - ), - createMockApiDataPoint( - '1701475200000-1701561599999', - { - 'miner-am-s19xp': 5500000, // 5.5 TH/s - 'miner-wm-m56s': 3200000, // 3.2 TH/s - 'miner-av-a1346': 0, +type Log = MetricsHashrateGroupedResponse['log'] + +const minerLog: Log = [ + { + ts: 1701388800000, + hashrateMhs: { 'miner-am-s19xp': 5_000_000, 'miner-wm-m56s': 3_000_000, 'miner-av-a1346': 0 }, + }, + { + ts: 1701475200000, + hashrateMhs: { 'miner-am-s19xp': 5_500_000, 'miner-wm-m56s': 3_200_000, 'miner-av-a1346': 0 }, + }, +] + +const containerLog: Log = [ + { + ts: 1701388800000, + hashrateMhs: { + 'bitdeer-1a': 4_000_000, + 'bitdeer-4a': 2_000_000, + maintenance: 0, + 'group-1': 1_111_111, + 'group-7': 99_999_999, }, - { - 'bitdeer-1a': 4200000, // 4.2 TH/s - 'bitdeer-4a': 2100000, // 2.1 TH/s + }, + { + ts: 1701475200000, + hashrateMhs: { + 'bitdeer-1a': 4_200_000, + 'bitdeer-4a': 2_100_000, maintenance: 0, + 'group-1': 1_111_111, + 'group-7': 99_999_999, }, - ), + }, ] -describe('Hashrate utils', () => { +describe('Hashrate utils (v2)', () => { describe('transformToSiteViewData', () => { - it('should return empty series when data is undefined', () => { - const result = transformToSiteViewData(undefined) - expect(result).toEqual({ series: [] }) - }) - - it('should return empty series when data is empty array', () => { - const result = transformToSiteViewData([]) - expect(result).toEqual({ series: [] }) + it('returns empty series when log is missing or empty', () => { + expect(transformToSiteViewData(undefined)).toEqual({ series: [] }) + expect(transformToSiteViewData([])).toEqual({ series: [] }) }) - it('should transform API data to site view chart data', () => { - const result = transformToSiteViewData(mockApiData) - - // Should return a single aggregated series + it('sums all miner types per timestamp and converts MH/s → TH/s', () => { + const result = transformToSiteViewData(minerLog) expect(result.series).toHaveLength(1) expect(result.series[0].label).toBe('Site Hashrate') - expect(result.series[0].points).toHaveLength(2) // Two time points - }) - - it('should convert MH/s to TH/s correctly', () => { - const result = transformToSiteViewData(mockApiData) - - // Aggregated series should sum all miner types (5 + 3 = 8 TH/s, 5.5 + 3.2 = 8.7 TH/s) - expect(result.series[0].points[0].value).toBe(8) // 5000000 + 3000000 MH/s = 8 TH/s - expect(result.series[0].points[1].value).toBe(8.7) // 5500000 + 3200000 MH/s = 8.7 TH/s + expect(result.series[0].points[0].value).toBe(8) // 5 + 3 TH/s + expect(result.series[0].points[1].value).toBe(8.7) // 5.5 + 3.2 TH/s }) - it('should filter by selected miner types', () => { - const result = transformToSiteViewData(mockApiData, ['miner-am-s19xp']) - - // Should still return a single aggregated series, but only with selected miner type - expect(result.series).toHaveLength(1) - expect(result.series[0].label).toBe('Site Hashrate') - // Values should only include miner-am-s19xp (5 TH/s and 5.5 TH/s) + it('filters by selected miner types when provided', () => { + const result = transformToSiteViewData(minerLog, ['miner-am-s19xp']) expect(result.series[0].points[0].value).toBe(5) expect(result.series[0].points[1].value).toBe(5.5) }) - it('should deduplicate data points with same timestamp', () => { - const duplicateData = [ - ...mockApiData, - createMockApiDataPoint( - '1701388800000-1701475199999', // Same ts as first point - { 'miner-am-s19xp': 9999999 }, - { 'bitdeer-1a': 9999999 }, - ), - ] - - const result = transformToSiteViewData(duplicateData) - - // Should only have 2 time points, not 3 - expect(result.series[0].points).toHaveLength(2) - }) - - it('should sort data points by timestamp', () => { - const unsortedData = [mockApiData[1], mockApiData[0]] // Reversed order - - const result = transformToSiteViewData(unsortedData) - - // First point should have earlier timestamp - const firstTs = new Date(result.series[0].points[0].ts).getTime() - const secondTs = new Date(result.series[0].points[1].ts).getTime() - expect(firstTs).toBeLessThan(secondTs) + it('sorts points by timestamp ascending', () => { + const reversed = [...minerLog].reverse() + const result = transformToSiteViewData(reversed) + const t1 = new Date(result.series[0].points[0].ts).getTime() + const t2 = new Date(result.series[0].points[1].ts).getTime() + expect(t1).toBeLessThan(t2) }) }) describe('transformToMinerTypeBarData', () => { - it('should return empty data when API data is undefined', () => { - const result = transformToMinerTypeBarData(undefined) - expect(result).toEqual({ labels: [], series: [] }) - }) - - it('should return empty data when API data is empty', () => { - const result = transformToMinerTypeBarData([]) - expect(result).toEqual({ labels: [], series: [] }) - }) - - it('should transform API data to bar chart format', () => { - const result = transformToMinerTypeBarData(mockApiData) - - expect(result.labels).toHaveLength(2) - expect(result.series).toHaveLength(1) - expect(result.series[0].label).toBe('Hashrate') + it('returns empty data when log is missing or empty', () => { + expect(transformToMinerTypeBarData(undefined)).toEqual({ labels: [], series: [] }) + expect(transformToMinerTypeBarData([])).toEqual({ labels: [], series: [] }) }) - it('should use latest data point', () => { - const result = transformToMinerTypeBarData(mockApiData) - - // Latest point has 5.5 TH/s for miner-am-s19xp - expect(result.series[0].values).toContain(5.5) + it('uses the latest log entry, sorts desc, drops zero values, and maps display names', () => { + const result = transformToMinerTypeBarData(minerLog) + expect(result.labels).toEqual(['Antminer S19XP', 'WhatsMiner M56S']) + expect(result.series[0].values).toEqual([5.5, 3.2]) }) - it('should sort by value descending', () => { - const result = transformToMinerTypeBarData(mockApiData) - - // First value should be highest - expect(result.series[0].values[0]).toBeGreaterThan(result.series[0].values[1]) - }) - - it('should map miner type IDs to display names', () => { - const result = transformToMinerTypeBarData(mockApiData) - - expect(result.labels).toContain('Antminer S19XP') - expect(result.labels).toContain('WhatsMiner M56S') - }) - - it('should filter out zero values', () => { - const result = transformToMinerTypeBarData(mockApiData) - - // miner-av-a1346 has 0 value, should not be included - expect(result.labels).not.toContain('Avalon A1346') - expect(result.labels).not.toContain('miner-av-a1346') + it('honours the selected miner types filter', () => { + const result = transformToMinerTypeBarData(minerLog, ['miner-wm-m56s']) + expect(result.labels).toEqual(['WhatsMiner M56S']) + expect(result.series[0].values).toEqual([3.2]) }) }) describe('transformToMiningUnitBarData', () => { - it('should return empty data when API data is undefined', () => { - const result = transformToMiningUnitBarData(undefined) - expect(result).toEqual({ labels: [], series: [] }) - }) - - it('should return empty data when API data is empty', () => { - const result = transformToMiningUnitBarData([]) - expect(result).toEqual({ labels: [], series: [] }) - }) - - it('should transform API data to bar chart format', () => { - const result = transformToMiningUnitBarData(mockApiData) - - expect(result.labels).toHaveLength(2) - expect(result.series).toHaveLength(1) - }) - - it('should map container IDs to display names', () => { - const result = transformToMiningUnitBarData(mockApiData) - - expect(result.labels).toContain('Bitdeer 1A') - expect(result.labels).toContain('Bitdeer 4A') + it('returns empty data when log is missing or empty', () => { + expect(transformToMiningUnitBarData(undefined)).toEqual({ labels: [], series: [] }) + expect(transformToMiningUnitBarData([])).toEqual({ labels: [], series: [] }) }) - it('should filter out zero values', () => { - const result = transformToMiningUnitBarData(mockApiData) - - expect(result.labels).not.toContain('Maintenance') - expect(result.labels).not.toContain('maintenance') - }) - - it('should sort by value descending', () => { - const result = transformToMiningUnitBarData(mockApiData) - - expect(result.series[0].values[0]).toBeGreaterThan(result.series[0].values[1]) + it('drops BE-leaked rollup keys (group-N, maintenance)', () => { + const result = transformToMiningUnitBarData(containerLog) + expect(result.labels).toEqual(['Bitdeer 1A', 'Bitdeer 4A']) + expect(result.series[0].values).toEqual([4.2, 2.1]) }) }) - describe('getMinerTypeOptionsFromApi', () => { - it('should return empty array when data is undefined', () => { - const result = getMinerTypeOptionsFromApi(undefined) - - expect(result).toEqual([]) - }) - - it('should return empty array when data is empty', () => { - const result = getMinerTypeOptionsFromApi([]) - - expect(result).toEqual([]) + describe('option helpers', () => { + it('getMinerTypeOptionsFromApi returns non-zero miner types with display labels', () => { + const options = getMinerTypeOptionsFromApi(minerLog) + expect(options).toContainEqual({ value: 'miner-am-s19xp', label: 'Antminer S19XP' }) + expect(options).toContainEqual({ value: 'miner-wm-m56s', label: 'WhatsMiner M56S' }) + expect(options.map((o) => o.value)).not.toContain('miner-av-a1346') }) - it('should include non-zero miner types', () => { - const result = getMinerTypeOptionsFromApi(mockApiData) - - expect(result).toContainEqual({ value: 'miner-am-s19xp', label: 'Antminer S19XP' }) - expect(result).toContainEqual({ value: 'miner-wm-m56s', label: 'WhatsMiner M56S' }) - }) - - it('should exclude zero-value miner types', () => { - const result = getMinerTypeOptionsFromApi(mockApiData) - - const values = result.map((o) => o.value) - expect(values).not.toContain('miner-av-a1346') - }) - }) - - describe('getMiningUnitOptionsFromApi', () => { - it('should return empty array when data is undefined', () => { - const result = getMiningUnitOptionsFromApi(undefined) - - expect(result).toEqual([]) - }) - - it('should return empty array when data is empty', () => { - const result = getMiningUnitOptionsFromApi([]) - - expect(result).toEqual([]) - }) - - it('should include non-zero containers', () => { - const result = getMiningUnitOptionsFromApi(mockApiData) - - expect(result).toContainEqual({ value: 'bitdeer-1a', label: 'Bitdeer 1A' }) - expect(result).toContainEqual({ value: 'bitdeer-4a', label: 'Bitdeer 4A' }) - }) - - it('should exclude zero-value containers', () => { - const result = getMiningUnitOptionsFromApi(mockApiData) - - const values = result.map((o) => o.value) + it('getMiningUnitOptionsFromApi excludes leaked rollup keys', () => { + const options = getMiningUnitOptionsFromApi(containerLog) + const values = options.map((o) => o.value) + expect(values).toEqual(['bitdeer-1a', 'bitdeer-4a']) expect(values).not.toContain('maintenance') + expect(values).not.toContain('group-1') + expect(values).not.toContain('group-7') }) }) }) diff --git a/src/Views/Reports/Hashrate/Hashrate.utils.ts b/src/Views/Reports/Hashrate/Hashrate.utils.ts index a36c11f3..8e2d3ba8 100644 --- a/src/Views/Reports/Hashrate/Hashrate.utils.ts +++ b/src/Views/Reports/Hashrate/Hashrate.utils.ts @@ -1,26 +1,21 @@ /** * Hashrate Page Utility Functions - * Transform API responses to chart data formats + * Transform v2 /auth/metrics/hashrate (grouped) responses into chart data shapes. */ import _filter from 'lodash/filter' -import _head from 'lodash/head' import _includes from 'lodash/includes' import _isEmpty from 'lodash/isEmpty' -import _keys from 'lodash/keys' import _last from 'lodash/last' import _map from 'lodash/map' import _orderBy from 'lodash/orderBy' -import _some from 'lodash/some' -import _sumBy from 'lodash/sumBy' import _toPairs from 'lodash/toPairs' -import _uniqBy from 'lodash/uniqBy' -import type { BarChartData, HashrateApiDataPoint, SiteViewChartData } from './Hashrate.types' +import type { BarChartData, SiteViewChartData } from './Hashrate.types' import { CHART_COLORS } from '@/constants/colors' +import type { MetricsHashrateGroupedResponse } from '@/types/api' -// Miner type ID to display name mapping const MINER_TYPE_LABELS: Record = { 'miner-am-s19xp': 'Antminer S19XP', 'miner-am-s19xp_h': 'Antminer S19XP Hyd', @@ -31,7 +26,6 @@ const MINER_TYPE_LABELS: Record = { 'miner-acme-m1': 'Acme M1', } -// Container ID to display name mapping const CONTAINER_LABELS: Record = { 'bitdeer-1a': 'Bitdeer 1A', 'bitdeer-4a': 'Bitdeer 4A', @@ -48,63 +42,52 @@ const CONTAINER_LABELS: Record = { 'bitmain-imm-2': 'Bitmain IMM 2', 'bitmain-hydro-1': 'Bitmain Hydro 1', 'bitmain-hydro-2': 'Bitmain Hydro 2', - maintenance: 'Maintenance', } -// Convert MH/s to TH/s const mhsToThs = (mhs: number): number => mhs / 1_000_000 -// Parse timestamp range from API format "startTs-endTs" -const parseTimestamp = (tsRange: string): Date => { - const [startTs] = tsRange.split('-') - return new Date(parseInt(startTs, 10)) +// BE leaks positional rollup keys ("group-1..N", "maintenance") into the +// container-grouped response alongside real container ids. Drop them here +// until BE filters at source. +const isLeakedContainerKey = (key: string): boolean => + key === 'maintenance' || /^group-\d+$/.test(key) + +type GroupedLog = MetricsHashrateGroupedResponse['log'] + +const getCleanGroupedEntries = ( + hashrateMhs: Record, + isContainer: boolean, +): [string, number][] => { + const entries = _toPairs(hashrateMhs) + if (!isContainer) return entries + return _filter(entries, ([key]) => !isLeakedContainerKey(key)) } /** - * Transform API response to Site View line chart data - * Aggregates hashrate into a single line showing total site hashrate + * Site View line chart — sums hashrate across all (or selected) miner types + * for each timestamp. Uses groupBy=miner so users can filter by type. */ export const transformToSiteViewData = ( - apiData: HashrateApiDataPoint[] | undefined, + log: GroupedLog | undefined, selectedMinerTypes: string[] = [], ): SiteViewChartData => { - if (_isEmpty(apiData)) { - return { series: [] } - } + if (_isEmpty(log)) return { series: [] } + + const sortedLog = _orderBy(log, ['ts'], ['asc']) - // Deduplicate by timestamp and sort (API sometimes returns duplicates) - const dataPoints = _orderBy( - _uniqBy(apiData, 'ts'), - [(dp) => parseTimestamp(dp.ts).getTime()], - ['asc'], - ) - - // Get all miner types from first data point - const firstPoint = _head(dataPoints) as HashrateApiDataPoint - const allMinerTypes = _keys(firstPoint.hashrate_mhs_5m_type_group_sum_aggr) - - // Filter miner types if selection provided - const minerTypesToInclude = _isEmpty(selectedMinerTypes) - ? _filter(allMinerTypes, (type) => - // Only include types with non-zero values - _some(dataPoints, (dp) => dp.hashrate_mhs_5m_type_group_sum_aggr[type] > 0), - ) - : _filter(selectedMinerTypes, (type) => _includes(allMinerTypes, type)) - - // Build single aggregated series by summing hashrate from all selected miner types at each timestamp const aggregatedSeries = { label: 'Site Hashrate', color: CHART_COLORS.METALLIC_BLUE, - points: _map(dataPoints, (dp) => { - // Sum hashrate from all selected miner types at this timestamp - const totalHashrate = _sumBy( - minerTypesToInclude, - (minerType) => dp.hashrate_mhs_5m_type_group_sum_aggr[minerType] || 0, - ) + points: _map(sortedLog, ({ ts, hashrateMhs }) => { + const entries = _toPairs(hashrateMhs) + const includedEntries = _isEmpty(selectedMinerTypes) + ? entries + : _filter(entries, ([key]) => _includes(selectedMinerTypes, key)) + const total = includedEntries.reduce((sum, [, value]) => sum + (value || 0), 0) return { - ts: parseTimestamp(dp.ts).toISOString(), - value: mhsToThs(totalHashrate), + ts: new Date(ts).toISOString(), + value: mhsToThs(total), } }), } @@ -112,131 +95,75 @@ export const transformToSiteViewData = ( return { series: [aggregatedSeries] } } -/** - * Transform API response to Miner Type bar chart data - * Aggregates hashrate by miner type - */ -export const transformToMinerTypeBarData = ( - apiData: HashrateApiDataPoint[] | undefined, - selectedMinerTypes: string[] = [], +const transformToBarData = ( + log: GroupedLog | undefined, + selectedKeys: string[], + labels: Record, + isContainer: boolean, ): BarChartData => { - if (_isEmpty(apiData)) { - return { labels: [], series: [] } - } - - // Use latest data point for bar chart - const latestPoint = _last(apiData) as HashrateApiDataPoint - const typeData = latestPoint.hashrate_mhs_5m_type_group_sum_aggr + if (_isEmpty(log)) return { labels: [], series: [] } - // Get all miner types with non-zero values - const allEntries = _filter(_toPairs(typeData), ([, value]) => value > 0) + const latest = _last(log) + if (!latest) return { labels: [], series: [] } - // Filter by selected miner types if any are selected - const filteredEntries = _isEmpty(selectedMinerTypes) - ? allEntries - : _filter(allEntries, ([key]) => _includes(selectedMinerTypes, key)) + const cleanEntries = getCleanGroupedEntries(latest.hashrateMhs, isContainer) + const nonZeroEntries = _filter(cleanEntries, ([, value]) => value > 0) + const filteredEntries = _isEmpty(selectedKeys) + ? nonZeroEntries + : _filter(nonZeroEntries, ([key]) => _includes(selectedKeys, key)) - // Transform and sort - const transformedEntries = _map(filteredEntries, ([key, value]) => ({ - label: MINER_TYPE_LABELS[key] || key, + const transformed = _map(filteredEntries, ([key, value]) => ({ + label: labels[key] ?? key, value: mhsToThs(value), })) - const entries = _orderBy(transformedEntries, ['value'], ['desc']) + const sorted = _orderBy(transformed, ['value'], ['desc']) return { - labels: _map(entries, 'label'), + labels: _map(sorted, 'label'), series: [ { label: 'Hashrate', - values: _map(entries, 'value'), + values: _map(sorted, 'value'), color: CHART_COLORS.yellow, }, ], } } -/** - * Transform API response to Mining Unit bar chart data - * Aggregates hashrate by container/mining unit - */ +export const transformToMinerTypeBarData = ( + log: GroupedLog | undefined, + selectedMinerTypes: string[] = [], +): BarChartData => transformToBarData(log, selectedMinerTypes, MINER_TYPE_LABELS, false) + export const transformToMiningUnitBarData = ( - apiData: HashrateApiDataPoint[] | undefined, + log: GroupedLog | undefined, selectedMiningUnits: string[] = [], -): BarChartData => { - if (_isEmpty(apiData)) { - return { labels: [], series: [] } - } - - // Use latest data point for bar chart - const latestPoint = _last(apiData) as HashrateApiDataPoint - const containerData = latestPoint.hashrate_mhs_5m_container_group_sum_aggr +): BarChartData => transformToBarData(log, selectedMiningUnits, CONTAINER_LABELS, true) - // Get all containers with non-zero values - const allEntries = _filter(_toPairs(containerData), ([, value]) => value > 0) +const getOptionsFromLog = ( + log: GroupedLog | undefined, + labels: Record, + isContainer: boolean, +): { value: string; label: string }[] => { + if (!log || _isEmpty(log)) return [] - // Filter by selected mining units if any are selected - const filteredEntries = _isEmpty(selectedMiningUnits) - ? allEntries - : _filter(allEntries, ([key]) => _includes(selectedMiningUnits, key)) + const seen = new Set() + for (const { hashrateMhs } of log) { + for (const [key, value] of getCleanGroupedEntries(hashrateMhs, isContainer)) { + if (value > 0) seen.add(key) + } + } - // Transform and sort - const transformedEntries = _map(filteredEntries, ([key, value]) => ({ - label: CONTAINER_LABELS[key] || key, - value: mhsToThs(value), + return _map([...seen], (key) => ({ + value: key, + label: labels[key] ?? key, })) - const entries = _orderBy(transformedEntries, ['value'], ['desc']) - - return { - labels: _map(entries, 'label'), - series: [ - { - label: 'Hashrate', - values: _map(entries, 'value'), - color: CHART_COLORS.yellow, - }, - ], - } } -/** - * Get filter options from API data - */ export const getMinerTypeOptionsFromApi = ( - apiData: HashrateApiDataPoint[] | undefined, -): { value: string; label: string }[] => { - if (_isEmpty(apiData)) { - return [] - } - - const firstPoint = _head(apiData) as HashrateApiDataPoint - const types = _keys(firstPoint.hashrate_mhs_5m_type_group_sum_aggr) - - const filteredTypes = _filter(types, (type) => - _some(apiData, (dp) => dp.hashrate_mhs_5m_type_group_sum_aggr[type] > 0), - ) - - return _map(filteredTypes, (type) => ({ - value: type, - label: MINER_TYPE_LABELS[type] || type, - })) -} + log: GroupedLog | undefined, +): { value: string; label: string }[] => getOptionsFromLog(log, MINER_TYPE_LABELS, false) export const getMiningUnitOptionsFromApi = ( - apiData: HashrateApiDataPoint[] | undefined, -): { value: string; label: string }[] => { - if (_isEmpty(apiData)) { - return [] - } - - const firstPoint = _head(apiData) as HashrateApiDataPoint - const containers = _keys(firstPoint.hashrate_mhs_5m_container_group_sum_aggr) - - const filteredContainers = _filter(containers, (c) => - _some(apiData, (dp) => dp.hashrate_mhs_5m_container_group_sum_aggr[c] > 0), - ) - - return _map(filteredContainers, (container) => ({ - value: container, - label: CONTAINER_LABELS[container] || container, - })) -} + log: GroupedLog | undefined, +): { value: string; label: string }[] => getOptionsFromLog(log, CONTAINER_LABELS, true) diff --git a/src/Views/Reports/Hashrate/components/MinerTypeView.tsx b/src/Views/Reports/Hashrate/components/MinerTypeView.tsx index c1a698fc..a2adcc17 100644 --- a/src/Views/Reports/Hashrate/components/MinerTypeView.tsx +++ b/src/Views/Reports/Hashrate/components/MinerTypeView.tsx @@ -12,7 +12,6 @@ import { TabContent, UnitLabel, } from '../Hashrate.styles' -import type { HashrateApiDataPoint } from '../Hashrate.types' import { getMinerTypeOptionsFromApi, transformToMinerTypeBarData } from '../Hashrate.utils' import { useHashrateData } from '../hooks/useHashrateData' @@ -37,24 +36,18 @@ const MinerTypeView = () => { const reportTimeFrameState = useReportTimeFrameSelectorState() const { start, end } = reportTimeFrameState - // Fetch hashrate data from API with date range const { data: apiData, isLoading } = useHashrateData({ dateRange: { start: start.getTime(), end: end.getTime(), }, + groupBy: 'miner', }) - // Generate filter options from API data - const minerTypeOptions = useMemo( - () => getMinerTypeOptionsFromApi(apiData as HashrateApiDataPoint[] | undefined), - [apiData], - ) + const minerTypeOptions = useMemo(() => getMinerTypeOptionsFromApi(apiData?.log), [apiData]) - // Transform API data to chart format with filters applied const chartData = useMemo( - () => - transformToMinerTypeBarData(apiData as HashrateApiDataPoint[] | undefined, filters.minerType), + () => transformToMinerTypeBarData(apiData?.log, filters.minerType), [apiData, filters.minerType], ) diff --git a/src/Views/Reports/Hashrate/components/MiningUnitView.tsx b/src/Views/Reports/Hashrate/components/MiningUnitView.tsx index 4ea4e9f6..36c77dd0 100644 --- a/src/Views/Reports/Hashrate/components/MiningUnitView.tsx +++ b/src/Views/Reports/Hashrate/components/MiningUnitView.tsx @@ -12,7 +12,6 @@ import { TabContent, UnitLabel, } from '../Hashrate.styles' -import type { HashrateApiDataPoint } from '../Hashrate.types' import { getMiningUnitOptionsFromApi, transformToMiningUnitBarData } from '../Hashrate.utils' import { useHashrateData } from '../hooks/useHashrateData' @@ -37,27 +36,18 @@ const MiningUnitView = () => { const reportTimeFrameState = useReportTimeFrameSelectorState() const { start, end } = reportTimeFrameState - // Fetch hashrate data from API with date range const { data: apiData, isLoading } = useHashrateData({ dateRange: { start: start.getTime(), end: end.getTime(), }, + groupBy: 'container', }) - // Generate filter options from API data - const miningUnitOptions = useMemo( - () => getMiningUnitOptionsFromApi(apiData as HashrateApiDataPoint[] | undefined), - [apiData], - ) + const miningUnitOptions = useMemo(() => getMiningUnitOptionsFromApi(apiData?.log), [apiData]) - // Transform API data to chart format with filters applied const chartData = useMemo( - () => - transformToMiningUnitBarData( - apiData as HashrateApiDataPoint[] | undefined, - filters.miningUnit, - ), + () => transformToMiningUnitBarData(apiData?.log, filters.miningUnit), [apiData, filters.miningUnit], ) diff --git a/src/Views/Reports/Hashrate/components/SiteView.tsx b/src/Views/Reports/Hashrate/components/SiteView.tsx index 2e8e4c9d..4b598df7 100644 --- a/src/Views/Reports/Hashrate/components/SiteView.tsx +++ b/src/Views/Reports/Hashrate/components/SiteView.tsx @@ -11,7 +11,6 @@ import { FiltersRow, TabContent, } from '../Hashrate.styles' -import type { HashrateApiDataPoint } from '../Hashrate.types' import { getMinerTypeOptionsFromApi, transformToSiteViewData } from '../Hashrate.utils' import { useHashrateData } from '../hooks/useHashrateData' @@ -49,18 +48,14 @@ const SiteView = () => { defaultRange: defaultDateRange, }) - // Fetch hashrate data from API - const { data: apiData, isLoading } = useHashrateData({ dateRange }) + // Fetch hashrate grouped by miner type — needed for the filter dropdown + // and for summing across selected types. + const { data: apiData, isLoading } = useHashrateData({ dateRange, groupBy: 'miner' }) - // Generate filter options from API data - const minerTypeOptions = useMemo( - () => getMinerTypeOptionsFromApi(apiData as HashrateApiDataPoint[] | undefined), - [apiData], - ) + const minerTypeOptions = useMemo(() => getMinerTypeOptionsFromApi(apiData?.log), [apiData]) - // Transform API data to chart format with filters applied const chartData = useMemo( - () => transformToSiteViewData(apiData as HashrateApiDataPoint[] | undefined, filters.minerType), + () => transformToSiteViewData(apiData?.log, filters.minerType), [apiData, filters.minerType], ) diff --git a/src/Views/Reports/Hashrate/hooks/specs/useHashrateData.test.ts b/src/Views/Reports/Hashrate/hooks/specs/useHashrateData.test.ts index 0ec471d4..69220bc5 100644 --- a/src/Views/Reports/Hashrate/hooks/specs/useHashrateData.test.ts +++ b/src/Views/Reports/Hashrate/hooks/specs/useHashrateData.test.ts @@ -1,156 +1,61 @@ import { renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, type Mock } from 'vitest' -import { - getGroupRangeFromDateRange, - getStatKeyFromDateRange, - useHashrateData, -} from '../useHashrateData' +import { useHashrateData } from '../useHashrateData' -import { DATE_RANGE } from '@/constants' -import { - STAT_3_HOURS, - STAT_5_MINUTES, - STAT_KEY_THRESHOLD_DAYS, -} from '@/constants/tailLogStatKeys.constants' - -const mockTailLogQuery = vi.fn() +import { useGetMetricsHashrateGroupedQuery } from '@/app/services/api' vi.mock('@/app/services/api', () => ({ - useGetTailLogQuery: (...args: unknown[]) => mockTailLogQuery(...args), + useGetMetricsHashrateGroupedQuery: vi.fn(), })) -describe('useHashrateData', () => { - describe('getGroupRangeFromDateRange', () => { - it('returns H1 for range <= 1 day', () => { - const start = new Date('2025-01-01T00:00:00') - const end = new Date('2025-01-01T12:00:00') - expect(getGroupRangeFromDateRange(start, end)).toBe(DATE_RANGE.H1) - }) +const mockedQuery = vi.mocked(useGetMetricsHashrateGroupedQuery) as unknown as Mock - it('returns D1 for range <= 30 days', () => { - const start = new Date('2025-01-01') - const end = new Date('2025-01-15') - expect(getGroupRangeFromDateRange(start, end)).toBe(DATE_RANGE.D1) - }) +const okResponse = { + data: { + log: [{ ts: 1, hashrateMhs: { 'miner-am-s19xp': 5_000_000 } }], + summary: {}, + }, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), +} - it('returns W1 for range > 30 days', () => { - const start = new Date('2025-01-01') - const end = new Date('2025-02-15') - expect(getGroupRangeFromDateRange(start, end)).toBe(DATE_RANGE.W1) - }) +describe('useHashrateData', () => { + it('passes start/end/groupBy to the v2 grouped query and returns its result', () => { + mockedQuery.mockReturnValue(okResponse) + const { result } = renderHook(() => + useHashrateData({ dateRange: { start: 1, end: 2 }, groupBy: 'miner' }), + ) + expect(mockedQuery).toHaveBeenCalledWith( + { start: 1, end: 2, groupBy: 'miner' }, + { skip: false }, + ) + expect(result.current.data).toBe(okResponse.data) }) - describe('getStatKeyFromDateRange', () => { - it('returns STAT_5_MINUTES for short range', () => { - const start = new Date('2025-01-01') - const end = new Date('2025-01-02') - expect(getStatKeyFromDateRange(start, end)).toBe(STAT_5_MINUTES) - }) - - it('returns STAT_3_HOURS for range > threshold days', () => { - const start = new Date('2025-01-01') - const end = new Date( - new Date('2025-01-01').getTime() + (STAT_KEY_THRESHOLD_DAYS + 2) * 86400000, - ) - expect(getStatKeyFromDateRange(start, end)).toBe(STAT_3_HOURS) - }) + it('isLoading is true when isFetching is true', () => { + mockedQuery.mockReturnValue({ ...okResponse, isLoading: false, isFetching: true }) + const { result } = renderHook(() => + useHashrateData({ dateRange: { start: 1, end: 2 }, groupBy: 'container' }), + ) + expect(result.current.isLoading).toBe(true) }) - describe('useHashrateData hook', () => { - it('returns data, isLoading, error, refetch, queryParams', () => { - mockTailLogQuery.mockReturnValue({ - data: [[{ ts: 1, val: 100 }]], - isLoading: false, - isFetching: false, - error: null, - refetch: vi.fn(), - }) - const start = new Date('2025-01-01').getTime() - const end = new Date('2025-01-07').getTime() - const { result } = renderHook(() => useHashrateData({ dateRange: { start, end } })) - expect(result.current).toHaveProperty('data') - expect(result.current).toHaveProperty('isLoading') - expect(result.current).toHaveProperty('error') - expect(result.current).toHaveProperty('refetch') - expect(result.current).toHaveProperty('queryParams') - }) - - it('flattens nested array data (first ORK)', () => { - const innerData = [{ ts: 1, val: 100 }] - mockTailLogQuery.mockReturnValue({ - data: [innerData], - isLoading: false, - isFetching: false, - error: null, - refetch: vi.fn(), - }) - const { result } = renderHook(() => - useHashrateData({ dateRange: { start: Date.now() - 86400000, end: Date.now() } }), - ) - expect(result.current.data).toBe(innerData) - }) - - it('returns raw data when not nested array', () => { - const rawData = [{ ts: 1, val: 100 }] - mockTailLogQuery.mockReturnValue({ - data: rawData, - isLoading: false, - isFetching: false, - error: null, - refetch: vi.fn(), - }) - const { result } = renderHook(() => - useHashrateData({ dateRange: { start: Date.now() - 86400000, end: Date.now() } }), - ) - expect(result.current.data).toBe(rawData) - }) - - it('uses default date range when no dateRange param provided', () => { - mockTailLogQuery.mockReturnValue({ - data: undefined as unknown, - isLoading: false, - isFetching: false, - error: null, - refetch: vi.fn(), - }) - const { result } = renderHook(() => useHashrateData()) - expect(result.current.queryParams).toHaveProperty('start') - expect(result.current.queryParams).toHaveProperty('end') - }) - - it('sets isLoading true when isFetching is true', () => { - mockTailLogQuery.mockReturnValue({ - data: undefined as unknown, - isLoading: false, - isFetching: true, - error: null, - refetch: vi.fn(), - }) - const { result } = renderHook(() => - useHashrateData({ dateRange: { start: Date.now() - 86400000, end: Date.now() } }), - ) - expect(result.current.isLoading).toBe(true) - }) + it('forwards skip=true to the underlying query', () => { + mockedQuery.mockReturnValue(okResponse) + renderHook(() => + useHashrateData({ dateRange: { start: 1, end: 2 }, groupBy: 'miner', skip: true }), + ) + expect(mockedQuery).toHaveBeenCalledWith(expect.any(Object), { skip: true }) + }) - it('passes skip=true to query when skip param is true', () => { - mockTailLogQuery.mockReturnValue({ - data: undefined as unknown, - isLoading: false, - isFetching: false, - error: null, - refetch: vi.fn(), - }) - renderHook(() => - useHashrateData({ - dateRange: { start: Date.now() - 86400000, end: Date.now() }, - skip: true, - }), - ) - expect(mockTailLogQuery).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ skip: true }), - ) - }) + it('uses default 7-day-ending-yesterday range when no dateRange provided', () => { + mockedQuery.mockReturnValue(okResponse) + const { result } = renderHook(() => useHashrateData({ groupBy: 'miner' })) + expect(typeof result.current.queryParams.start).toBe('number') + expect(typeof result.current.queryParams.end).toBe('number') + expect(result.current.queryParams.end).toBeGreaterThan(result.current.queryParams.start) }) }) diff --git a/src/Views/Reports/Hashrate/hooks/useHashrateData.ts b/src/Views/Reports/Hashrate/hooks/useHashrateData.ts index ebf67376..6d35fe5a 100644 --- a/src/Views/Reports/Hashrate/hooks/useHashrateData.ts +++ b/src/Views/Reports/Hashrate/hooks/useHashrateData.ts @@ -1,47 +1,10 @@ -import { differenceInDays } from 'date-fns/differenceInDays' import { endOfDay } from 'date-fns/endOfDay' import { startOfDay } from 'date-fns/startOfDay' import { subDays } from 'date-fns/subDays' -import _head from 'lodash/head' -import _isArray from 'lodash/isArray' import { useMemo } from 'react' -import { useGetTailLogQuery } from '@/app/services/api' -import { DATE_RANGE } from '@/constants' -import { - STAT_5_MINUTES, - STAT_3_HOURS, - STAT_KEY_THRESHOLD_DAYS, -} from '@/constants/tailLogStatKeys.constants' - -// Calculate appropriate groupRange based on date range span -export const getGroupRangeFromDateRange = ( - start: number | Date, - end: number | Date, -): (typeof DATE_RANGE)[keyof typeof DATE_RANGE] => { - const diffDays = differenceInDays(end, start) - - if (diffDays <= 1) return DATE_RANGE.H1 - if (diffDays <= 30) return DATE_RANGE.D1 - return DATE_RANGE.W1 -} - -// Get appropriate stat key based on date range span -export const getStatKeyFromDateRange = (start: number | Date, end: number | Date): string => { - const diffDays = differenceInDays(end, start) - return diffDays > STAT_KEY_THRESHOLD_DAYS ? STAT_3_HOURS : STAT_5_MINUTES -} - -// Default date range: 7 days ending yesterday -const getDefaultDateRange = () => { - const yesterday = subDays(new Date(), 1) - const endDate = endOfDay(yesterday) - const startDate = startOfDay(subDays(yesterday, 6)) - return { - start: startDate.getTime(), - end: endDate.getTime(), - } -} +import { useGetMetricsHashrateGroupedQuery } from '@/app/services/api' +import type { MetricsHashrateGroupBy } from '@/types/api' interface DateRange { start: number @@ -50,42 +13,28 @@ interface DateRange { interface UseHashrateDataParams { dateRange?: DateRange + groupBy: MetricsHashrateGroupBy skip?: boolean } -export const useHashrateData = (params: UseHashrateDataParams = {}) => { - const { dateRange, skip = false } = params - const defaultRange = useMemo(() => getDefaultDateRange(), []) - const { start, end } = dateRange || defaultRange - const groupRange = useMemo(() => getGroupRangeFromDateRange(start, end), [start, end]) - const statKey = getStatKeyFromDateRange(start, end) +const getDefaultDateRange = (): DateRange => { + const yesterday = subDays(new Date(), 1) + return { + start: startOfDay(subDays(yesterday, 6)).getTime(), + end: endOfDay(yesterday).getTime(), + } +} - const queryParams = useMemo( - () => ({ - key: statKey, - type: 'miner', - tag: 't-miner', - aggrFields: JSON.stringify({ - hashrate_mhs_5m_type_group_sum_aggr: 1, - hashrate_mhs_5m_container_group_sum_aggr: 1, - }), - groupRange, - start, - end, - }), - [statKey, groupRange, start, end], - ) +export const useHashrateData = ({ dateRange, groupBy, skip = false }: UseHashrateDataParams) => { + const defaultRange = useMemo(() => getDefaultDateRange(), []) + const { start, end } = dateRange ?? defaultRange - const { - data: rawData, - isLoading, - isFetching, - error, - refetch, - } = useGetTailLogQuery(queryParams, { skip }) + const queryParams = useMemo(() => ({ start, end, groupBy }), [start, end, groupBy]) - // API returns nested arrays (one per ORK), flatten to get first ORK's data - const data = _isArray(rawData) && _isArray(_head(rawData)) ? _head(rawData) : rawData + const { data, isLoading, isFetching, error, refetch } = useGetMetricsHashrateGroupedQuery( + queryParams, + { skip }, + ) return { data, diff --git a/src/Views/Reports/OperationsEfficiency/hooks/useEfficiencyMinerType.ts b/src/Views/Reports/OperationsEfficiency/hooks/useEfficiencyMinerType.ts index f28ca110..1fdbc3ef 100644 --- a/src/Views/Reports/OperationsEfficiency/hooks/useEfficiencyMinerType.ts +++ b/src/Views/Reports/OperationsEfficiency/hooks/useEfficiencyMinerType.ts @@ -9,6 +9,7 @@ import { TAIL_LOG_MINER_TYPE_KEY } from '../constants' import { useGetTailLogQuery } from '@/app/services/api' import { MINER_TYPE_NAME_MAP } from '@/constants/deviceConstants' +// TODO: migrate to /auth/metrics/efficiency once BE ships groupBy=miner. export const useEfficiencyMinerType = ({ start, end }: { start: Date; end: Date }) => { const { data: tailLogData, diff --git a/src/Views/Reports/OperationsEfficiency/hooks/useEfficiencyMinerUnit.ts b/src/Views/Reports/OperationsEfficiency/hooks/useEfficiencyMinerUnit.ts index 6cb165cc..c4dc27e6 100644 --- a/src/Views/Reports/OperationsEfficiency/hooks/useEfficiencyMinerUnit.ts +++ b/src/Views/Reports/OperationsEfficiency/hooks/useEfficiencyMinerUnit.ts @@ -23,6 +23,7 @@ const getLabelName = (category: string, containers?: Container[]) => { return _isNil(type) ? category : getContainerName(category, type) } +// TODO: migrate to /auth/metrics/efficiency once BE ships groupBy=container. export const useEfficiencyMinerUnit = ({ start, end }: { start: Date; end: Date }) => { const { data: tailLogData, diff --git a/src/Views/Reports/OperationsEfficiency/tabs/EfficiencySiteView.tsx b/src/Views/Reports/OperationsEfficiency/tabs/EfficiencySiteView.tsx index 07c1f6b0..6044f8a5 100644 --- a/src/Views/Reports/OperationsEfficiency/tabs/EfficiencySiteView.tsx +++ b/src/Views/Reports/OperationsEfficiency/tabs/EfficiencySiteView.tsx @@ -2,28 +2,32 @@ import Button from 'antd/es/button' import { endOfDay } from 'date-fns/endOfDay' import { startOfDay } from 'date-fns/startOfDay' import { subDays } from 'date-fns/subDays' -import { useCallback } from 'react' +import _head from 'lodash/head' +import _map from 'lodash/map' +import { useCallback, useMemo } from 'react' import { SiteEfficiencyChart } from '../../OperationsDashboard/components/SiteEfficiencyChart' import { AverageEfficiencyValue, DatePickerContainer } from '../OperationsEfficiency.styles' +import { useGetGlobalConfigQuery, useGetMetricsEfficiencyQuery } from '@/app/services/api' import { formatUnit } from '@/app/utils/format' import { Spinner } from '@/Components/Spinner/Spinner' import { UNITS } from '@/constants/units' import { useDateRangePicker } from '@/hooks/useDatePicker' -import { useOperationsDashboardData } from '@/hooks/useOperationsDashboardData' import { Label, Value } from '@/MultiSiteViews/Common.style' -import { getLogSummary } from '@/Views/Financial/HashBalance/utils/hashRevenueCost.utils' + +interface GlobalConfig { + nominalSiteWeightedAvgEfficiency?: number +} const EfficiencySiteView = () => { - // Calculate default date range: 7 days ending yesterday + // Default range: 7 days ending yesterday const yesterday = subDays(new Date(), 1) const defaultDateRange = { - start: startOfDay(subDays(yesterday, 6)).getTime(), // 7 days total including yesterday + start: startOfDay(subDays(yesterday, 6)).getTime(), end: endOfDay(yesterday).getTime(), } - // Date range picker - default range is last 7 days (excluding today) const { dateRange, datePicker, onTableDateRangeChange } = useDateRangePicker({ start: defaultDateRange.start, end: defaultDateRange.end, @@ -31,16 +35,34 @@ const EfficiencySiteView = () => { defaultRange: defaultDateRange, }) - // Reset to default 7 days range const handleReset = useCallback(() => { onTableDateRangeChange(null) }, [onTableDateRangeChange]) - const { efficiency } = useOperationsDashboardData({ - start: dateRange.start, - end: dateRange.end, - }) - const { avg } = getLogSummary(efficiency.data) + const { data: globalConfig, isLoading: isLoadingNominal } = useGetGlobalConfigQuery({}) + + const { + data: efficiencyResponse, + isLoading, + isFetching, + error, + } = useGetMetricsEfficiencyQuery({ start: dateRange.start, end: dateRange.end }) + + const chartData = useMemo( + () => + _map(efficiencyResponse?.log ?? [], ({ ts, efficiencyWThs }) => ({ + ts, + efficiency: efficiencyWThs, + })), + [efficiencyResponse], + ) + + const avgEfficiency = efficiencyResponse?.summary?.avgEfficiencyWThs ?? null + const nominalValue = isLoadingNominal + ? null + : (_head(globalConfig as GlobalConfig[])?.nominalSiteWeightedAvgEfficiency ?? null) + + const isAnyLoading = isLoading || isFetching return ( <> @@ -49,14 +71,14 @@ const EfficiencySiteView = () => { - {efficiency.isLoading && } + {isAnyLoading && } {}} @@ -64,11 +86,7 @@ const EfficiencySiteView = () => { <> - - {formatUnit({ - value: avg.efficiency, - })} - + {formatUnit({ value: avgEfficiency ?? 0 })} {UNITS.EFFICIENCY_W_PER_TH_S} diff --git a/src/Views/specs/financialHooks.test.ts b/src/Views/specs/financialHooks.test.ts index 6801336f..67822b5b 100644 --- a/src/Views/specs/financialHooks.test.ts +++ b/src/Views/specs/financialHooks.test.ts @@ -8,6 +8,12 @@ vi.mock('@/app/services/api', () => ({ useGetExtDataQuery: vi.fn(() => ({ data: undefined as unknown, isLoading: false })), useGetTailLogRangeAggrQuery: vi.fn(() => ({ data: undefined as unknown, isLoading: false })), useGetGlobalConfigQuery: vi.fn(() => ({ data: undefined as unknown, isLoading: false })), + useGetMetricsConsumptionQuery: vi.fn(() => ({ + data: { log: [] }, + isLoading: false, + isFetching: false, + error: null, + })), useGetListThingsQuery: vi.fn(() => ({ data: undefined as unknown, isLoading: false })), useGetTailLogQuery: vi.fn(() => ({ data: undefined as unknown, diff --git a/src/app/services/api/endpoints/index.ts b/src/app/services/api/endpoints/index.ts index 89304ea1..242827c8 100644 --- a/src/app/services/api/endpoints/index.ts +++ b/src/app/services/api/endpoints/index.ts @@ -7,6 +7,7 @@ import { downtimeEndpoints } from './downtime' import { financialEndpoints } from './financial' import { globalEndpoints } from './global' import { logsEndpoints } from './logs' +import { metricsEndpoints } from './metrics' import { minersEndpoints } from './miners' import { operationsEndpoints } from './operations' import { poolsEndpoints } from './pools' @@ -22,6 +23,7 @@ export const createEndpoints = (builder: EndpointBuilder) => ({ + getMetricsHashrate: builder.query({ + query: (payload) => `metrics/hashrate?${qs.stringify(payload)}`, + }), + + getMetricsHashrateGrouped: builder.query< + MetricsHashrateGroupedResponse, + MetricsHashrateQueryParams & { groupBy: NonNullable } + >({ + query: (payload) => `metrics/hashrate?${qs.stringify(payload)}`, + }), + + getMetricsConsumption: builder.query({ + query: (payload) => `metrics/consumption?${qs.stringify(payload)}`, + }), + + getMetricsConsumptionGrouped: builder.query< + MetricsConsumptionGroupedResponse, + MetricsConsumptionQueryParams & { + groupBy: NonNullable + } + >({ + query: (payload) => `metrics/consumption?${qs.stringify(payload)}`, + }), + + getMetricsEfficiency: builder.query({ + query: (payload) => `metrics/efficiency?${qs.stringify(payload)}`, + }), + + getMetricsMinerStatus: builder.query({ + query: (payload) => `metrics/miner-status?${qs.stringify(payload)}`, + }), + + getMetricsPowerMode: builder.query({ + query: (payload) => `metrics/power-mode?${qs.stringify(payload)}`, + }), + + getMetricsPowerModeTimeline: builder.query< + MetricsPowerModeTimelineResponse, + MetricsPowerModeTimelineQueryParams + >({ + query: (payload) => `metrics/power-mode/timeline?${qs.stringify(payload)}`, + }), + + getMetricsTemperature: builder.query({ + query: (payload) => `metrics/temperature?${qs.stringify(payload)}`, + }), +}) diff --git a/src/app/services/api/index.ts b/src/app/services/api/index.ts index a492bfee..c6a18685 100644 --- a/src/app/services/api/index.ts +++ b/src/app/services/api/index.ts @@ -92,6 +92,15 @@ export const { useUpdateHeaderControlsMutation, useGetExportSettingsQuery, useImportSettingsMutation, + useGetMetricsHashrateQuery, + useGetMetricsHashrateGroupedQuery, + useGetMetricsConsumptionQuery, + useGetMetricsConsumptionGroupedQuery, + useGetMetricsEfficiencyQuery, + useGetMetricsMinerStatusQuery, + useGetMetricsPowerModeQuery, + useGetMetricsPowerModeTimelineQuery, + useGetMetricsTemperatureQuery, useGetFinanceRevenueSummaryQuery, useGetFinanceEbitdaQuery, useGetFinanceEnergyBalanceQuery, diff --git a/src/hooks/specs/useOperationsDashboardData.test.ts b/src/hooks/specs/useOperationsDashboardData.test.ts index 99ba2f79..5f256d99 100644 --- a/src/hooks/specs/useOperationsDashboardData.test.ts +++ b/src/hooks/specs/useOperationsDashboardData.test.ts @@ -1,21 +1,25 @@ import { renderHook } from '@testing-library/react' -import { describe, expect, it, Mock, vi } from 'vitest' +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest' import { useOperationsDashboardData } from '../useOperationsDashboardData' import { useGetGlobalConfigQuery, + useGetMetricsConsumptionQuery, + useGetMetricsEfficiencyQuery, + useGetMetricsHashrateQuery, useGetTailLogQuery, - useGetTailLogRangeAggrQuery, } from '@/app/services/api' vi.mock('@/app/services/api', () => ({ useGetGlobalConfigQuery: vi.fn(), - useGetTailLogRangeAggrQuery: vi.fn(), + useGetMetricsHashrateQuery: vi.fn(), + useGetMetricsConsumptionQuery: vi.fn(), + useGetMetricsEfficiencyQuery: vi.fn(), useGetTailLogQuery: vi.fn(), })) -vi.mock('@/Views/ReportingTool/OperationsDashboard/utils', () => ({ +vi.mock('@/Views/Reports/OperationsDashboard/utils', () => ({ sumObjectValues: (obj: Record) => Object.values(obj || {}).reduce((a, b) => a + b, 0), @@ -32,39 +36,27 @@ const mockGlobalConfig = [ nominalSiteWeightedAvgEfficiency: 70, }, ] -const mockHashrateData = [ - [ - { - type: 'miner', - data: [ - { ts: 1, val: { hashrate_mhs_5m_sum_aggr: 123 } }, - { ts: 2, val: { hashrate_mhs_5m_sum_aggr: 456 } }, - ], - }, +const mockHashrateResponse = { + log: [ + { ts: 1, hashrateMhs: 123 }, + { ts: 2, hashrateMhs: 456 }, ], -] -const mockEfficiencyData = [ - [ - { - type: 'miner', - data: [ - { ts: 1, val: { efficiency_w_ths_avg_aggr: 11 } }, - { ts: 2, val: { efficiency_w_ths_avg_aggr: 22 } }, - ], - }, + summary: { avgHashrateMhs: 289.5, totalHashrateMhs: 579 }, +} +const mockEfficiencyResponse = { + log: [ + { ts: 1, efficiencyWThs: 11 }, + { ts: 2, efficiencyWThs: 22 }, ], -] -const mockConsumptionData = [ - [ - { - type: 'powermeter', - data: [ - { ts: 1, val: { site_power_w: 1000 } }, - { ts: 2, val: { site_power_w: 2000 } }, - ], - }, + summary: { avgEfficiencyWThs: 16.5 }, +} +const mockConsumptionResponse = { + log: [ + { ts: 1, powerW: 1000, consumptionMWh: 24 }, + { ts: 2, powerW: 2000, consumptionMWh: 48 }, ], -] + summary: { avgPowerW: 1500, totalConsumptionMWh: 72 }, +} const mockMinersData = [ { ts: 1, @@ -75,45 +67,43 @@ const mockMinersData = [ }, ] -const mockedUseGetTailLogRangeAggrQuery = vi.mocked(useGetTailLogRangeAggrQuery) as unknown as Mock const mockedUseGetGlobalConfigQuery = vi.mocked(useGetGlobalConfigQuery) as unknown as Mock +const mockedUseGetMetricsHashrateQuery = vi.mocked(useGetMetricsHashrateQuery) as unknown as Mock +const mockedUseGetMetricsEfficiencyQuery = vi.mocked( + useGetMetricsEfficiencyQuery, +) as unknown as Mock +const mockedUseGetMetricsConsumptionQuery = vi.mocked( + useGetMetricsConsumptionQuery, +) as unknown as Mock const mockedUseGetTailLogQuery = vi.mocked(useGetTailLogQuery) as unknown as Mock +const idleQuery = { data: undefined, isLoading: false, isFetching: false, error: null } + describe('useOperationsDashboardData', () => { beforeEach(() => { vi.resetAllMocks() }) + it('should correctly map all chart data', () => { mockedUseGetGlobalConfigQuery.mockReturnValue({ data: mockGlobalConfig, isLoading: false, }) - - mockedUseGetTailLogRangeAggrQuery - .mockReturnValueOnce({ - data: mockHashrateData, - isLoading: false, - isFetching: false, - error: null, - }) - .mockReturnValueOnce({ - data: mockEfficiencyData, - isLoading: false, - isFetching: false, - error: null, - }) - .mockReturnValueOnce({ - data: mockConsumptionData, - isLoading: false, - isFetching: false, - error: null, - }) - + mockedUseGetMetricsHashrateQuery.mockReturnValue({ + ...idleQuery, + data: mockHashrateResponse, + }) + mockedUseGetMetricsEfficiencyQuery.mockReturnValue({ + ...idleQuery, + data: mockEfficiencyResponse, + }) + mockedUseGetMetricsConsumptionQuery.mockReturnValue({ + ...idleQuery, + data: mockConsumptionResponse, + }) mockedUseGetTailLogQuery.mockReturnValue({ + ...idleQuery, data: mockMinersData, - isLoading: false, - isFetching: false, - error: null, }) const { result } = renderHook(() => useOperationsDashboardData(mockDateRange)) @@ -137,84 +127,13 @@ describe('useOperationsDashboardData', () => { expect(data.consumption.nominalValue).toBe(5_000_000) // MW → Watts expect(data.miners.data?.dataset).toEqual([ { - '01-01': { - style: { - backgroundColor: ['#03C04A4d', '#03C04A1a'], - borderColor: '#03C04A', - borderWidth: { - top: 2, - }, - legendColor: '#03C04A', - }, - value: 10, - }, - label: 'Online', - legendColor: '#03C04A', - stackGroup: 'miners', - }, - { - '01-01': { - style: { - backgroundColor: ['#EF44444d', '#EF44441a'], - borderColor: '#EF4444', - borderWidth: { - top: 2, - }, - legendColor: '#EF4444', - }, - value: 0, - }, - label: 'Error', - legendColor: '#EF4444', - stackGroup: 'miners', - }, - { - '01-01': { - style: { - backgroundColor: ['#FFFFFF4d', '#FFFFFF1a'], - borderColor: '#FFFFFF', - borderWidth: { - top: 2, - }, - legendColor: '#FFFFFF', - }, - value: 2, - }, - label: 'Offline', - legendColor: '#FFFFFF', - stackGroup: 'miners', - }, - { - '01-01': { - style: { - backgroundColor: ['#3B82F64d', '#3B82F61a'], - borderColor: '#3B82F6', - borderWidth: { - top: 2, - }, - legendColor: '#3B82F6', - }, - value: 0, - }, - label: 'Sleep', - legendColor: '#3B82F6', - stackGroup: 'miners', - }, - { - '01-01': { - style: { - backgroundColor: ['#F59E0B4d', '#F59E0B1a'], - borderColor: '#F59E0B', - borderWidth: { - top: 2, - }, - legendColor: '#F59E0B', - }, - value: 4, - }, - label: 'Maintenance', - legendColor: '#F59E0B', - stackGroup: 'miners', + ts: 1, + online: 10, + error: 0, + notMining: 5, + offline: 2, + sleep: 0, + maintenance: 4, }, ]) @@ -223,18 +142,10 @@ describe('useOperationsDashboardData', () => { it('should return empty arrays when APIs return nothing', () => { mockedUseGetGlobalConfigQuery.mockReturnValue({ data: [], isLoading: false }) - mockedUseGetTailLogRangeAggrQuery.mockReturnValue({ - data: [], - isLoading: false, - isFetching: false, - error: null, - }) - mockedUseGetTailLogQuery.mockReturnValue({ - data: [], - isLoading: false, - isFetching: false, - error: null, - }) + mockedUseGetMetricsHashrateQuery.mockReturnValue(idleQuery) + mockedUseGetMetricsEfficiencyQuery.mockReturnValue(idleQuery) + mockedUseGetMetricsConsumptionQuery.mockReturnValue(idleQuery) + mockedUseGetTailLogQuery.mockReturnValue({ ...idleQuery, data: [] }) const { result } = renderHook(() => useOperationsDashboardData({ start: 0, end: 0 })) @@ -247,18 +158,10 @@ describe('useOperationsDashboardData', () => { it('should return loading=true if any API is loading', () => { mockedUseGetGlobalConfigQuery.mockReturnValue({ data: [], isLoading: true }) - mockedUseGetTailLogRangeAggrQuery.mockReturnValue({ - data: [], - isLoading: true, - isFetching: false, - error: null, - }) - mockedUseGetTailLogQuery.mockReturnValue({ - data: [], - isLoading: true, - isFetching: false, - error: null, - }) + mockedUseGetMetricsHashrateQuery.mockReturnValue({ ...idleQuery, isLoading: true }) + mockedUseGetMetricsEfficiencyQuery.mockReturnValue({ ...idleQuery, isLoading: true }) + mockedUseGetMetricsConsumptionQuery.mockReturnValue({ ...idleQuery, isLoading: true }) + mockedUseGetTailLogQuery.mockReturnValue({ ...idleQuery, isLoading: true }) const { result } = renderHook(() => useOperationsDashboardData(mockDateRange)) diff --git a/src/hooks/useOperationsDashboardData.ts b/src/hooks/useOperationsDashboardData.ts index a4e98375..e5e78df6 100644 --- a/src/hooks/useOperationsDashboardData.ts +++ b/src/hooks/useOperationsDashboardData.ts @@ -1,4 +1,3 @@ -import _find from 'lodash/find' import _get from 'lodash/get' import _head from 'lodash/head' import _isArray from 'lodash/isArray' @@ -7,8 +6,10 @@ import _map from 'lodash/map' import { useGetGlobalConfigQuery, + useGetMetricsConsumptionQuery, + useGetMetricsEfficiencyQuery, + useGetMetricsHashrateQuery, useGetTailLogQuery, - useGetTailLogRangeAggrQuery, } from '@/app/services/api' import { isDemoMode } from '@/app/services/api.utils' import { @@ -41,14 +42,6 @@ interface GlobalConfig { nominalSiteMinerCapacity?: number } -interface RangeAggrResponse { - type: string - data: Array<{ - ts: number - val: Record - }> -} - interface ChartData { data: T nominalValue?: number | null @@ -73,87 +66,44 @@ interface OperationsDashboardData { miners: Omit, 'nominalValue'> } -interface DataPoint { - ts: number - val: Record -} - -/** - * Custom hook to fetch and process all operations dashboard data - * Uses backend daily aggregation APIs for better performance - */ export const useOperationsDashboardData = (dateRange: DateRange): OperationsDashboardData => { // In demo mode, always use the fixed date range from when mock data was captured // This ensures charts display data regardless of the selected date range const fixedDateRange = isDemoMode ? { - start: 1769025600000, // Jan 21, 2026 20:00:00 UTC (Jan 22 00:00 UTC+4) - end: 1769630399999, // Jan 28, 2026 19:59:59 UTC (Jan 28 23:59:59 UTC+4) + start: 1769025600000, + end: 1769630399999, } : dateRange - // Convert timestamps to ISO date strings - const startDate = new Date(fixedDateRange.start).toISOString() - const endDate = new Date(fixedDateRange.end).toISOString() + const { start, end } = fixedDateRange - // Fetch global config for nominal values const { data: globalConfig, isLoading: isLoadingNominalValues } = useGetGlobalConfigQuery({}) - // Fetch hashrate data - daily aggregation from backend const { data: hashrateResponse, isLoading: isLoadingHashrate, isFetching: isFetchingHashrate, error: hashrateError, - } = useGetTailLogRangeAggrQuery({ - keys: JSON.stringify([ - { - type: 'miner', - startDate, - endDate, - fields: { hashrate_mhs_5m_sum_aggr: 1 }, - shouldReturnDailyData: 1, - }, - ]), - }) + } = useGetMetricsHashrateQuery({ start, end }) - // Fetch miner efficiency data - daily aggregation from backend const { data: efficiencyResponse, isLoading: isLoadingEfficiency, isFetching: isFetchingEfficiency, error: efficiencyError, - } = useGetTailLogRangeAggrQuery({ - keys: JSON.stringify([ - { - type: 'miner', - startDate, - endDate, - fields: { efficiency_w_ths_avg_aggr: 1 }, - shouldReturnDailyData: 1, - }, - ]), - }) + } = useGetMetricsEfficiencyQuery({ start, end }) - // Fetch site power consumption data - daily aggregation from backend const { data: consumptionResponse, isLoading: isLoadingConsumption, isFetching: isFetchingConsumption, error: consumptionError, - } = useGetTailLogRangeAggrQuery({ - keys: JSON.stringify([ - { - type: 'powermeter', - startDate, - endDate, - fields: { site_power_w: 1 }, - shouldReturnDailyData: 1, - }, - ]), - }) + } = useGetMetricsConsumptionQuery({ start, end }) - // Fetch miners count data - daily aggregation with average from backend + // TODO: migrate to /auth/metrics/miner-status once BE adds `error` + `notMining` + // counts. Current handler folds those into "online", which would collapse the + // chart's Error stack. const { data: rawMinersData, isLoading: isLoadingMiners, @@ -163,8 +113,8 @@ export const useOperationsDashboardData = (dateRange: DateRange): OperationsDash key: 'stat-3h', type: 'miner', tag: 't-miner', - start: fixedDateRange.start, - end: fixedDateRange.end, + start, + end, aggrFields: JSON.stringify({ online_or_minor_error_miners_amount_aggr: 1, error_miners_amount_aggr: 1, @@ -177,65 +127,23 @@ export const useOperationsDashboardData = (dateRange: DateRange): OperationsDash shouldCalculateAvg: true, }) - // ----- hashrate ----- - let hashrateChartData: { ts: number; hashrate: number }[] = [] - - if (hashrateResponse) { - const arr = _isArray(hashrateResponse) ? _head(hashrateResponse) : hashrateResponse - - if (_isArray(arr) && !_isEmpty(arr)) { - const minerData = _find(arr, (item: RangeAggrResponse) => item.type === 'miner') - - if (minerData?.data) { - hashrateChartData = _map(minerData.data, ({ ts, val }: DataPoint) => ({ - ts, - hashrate: _get(val, 'hashrate_mhs_5m_sum_aggr', 0), - })) - } - } - } + const hashrateChartData = _map(hashrateResponse?.log ?? [], ({ ts, hashrateMhs }) => ({ + ts, + hashrate: hashrateMhs, + })) - // ----- efficiency ----- - let efficiencyChartData: { ts: number; efficiency: number }[] = [] + const efficiencyChartData = _map(efficiencyResponse?.log ?? [], ({ ts, efficiencyWThs }) => ({ + ts, + efficiency: efficiencyWThs, + })) - if (efficiencyResponse) { - const arr = _isArray(efficiencyResponse) ? _head(efficiencyResponse) : efficiencyResponse + const consumptionChartData = _map(consumptionResponse?.log ?? [], ({ ts, powerW }) => ({ + ts, + consumption: powerW, + })) - if (_isArray(arr) && !_isEmpty(arr)) { - const minerData = _find(arr, (item: RangeAggrResponse) => item.type === 'miner') - - if (minerData?.data) { - efficiencyChartData = _map(minerData.data, ({ ts, val }: DataPoint) => ({ - ts, - efficiency: _get(val, 'efficiency_w_ths_avg_aggr', 0), - })) - } - } - } - - // ----- consumption ----- - let consumptionChartData: { ts: number; consumption: number }[] = [] - - if (consumptionResponse) { - const arr = _isArray(consumptionResponse) ? _head(consumptionResponse) : consumptionResponse - - if (_isArray(arr) && !_isEmpty(arr)) { - const powermeter = _find(arr, (item: RangeAggrResponse) => item.type === 'powermeter') - - if (powermeter?.data) { - consumptionChartData = _map(powermeter.data, ({ ts, val }: DataPoint) => ({ - ts, - consumption: _get(val, 'site_power_w', 0), - })) - } - } - } - - // ----- miners ----- let minersChartData: MinersChartData | null = null - const minersDataHead = _head(rawMinersData as unknown[]) - const minersData = _isArray(minersDataHead) ? minersDataHead : (rawMinersData as unknown[]) if (_isArray(minersData) && !_isEmpty(minersData)) { @@ -277,7 +185,7 @@ export const useOperationsDashboardData = (dateRange: DateRange): OperationsDash data: consumptionChartData, nominalValue: isLoadingNominalValues ? null - : (_head(globalConfig as GlobalConfig[])?.nominalPowerAvailability_MW ?? 0) * 1_000_000, // Convert MW to W + : (_head(globalConfig as GlobalConfig[])?.nominalPowerAvailability_MW ?? 0) * 1_000_000, isLoading: isLoadingConsumption || isFetchingConsumption, error: consumptionError, }, diff --git a/src/types/api.d.ts b/src/types/api.d.ts index fc5194b0..065a9378 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -854,6 +854,229 @@ export interface FinanceResponse { summary: Summary } +// ============================================================================ +// v2 Operational Metrics Types — /auth/metrics/* +// ============================================================================ + +export interface MetricsQueryParams { + start: number + end: number + overwriteCache?: boolean +} + +export interface MetricsResponse { + log: Log[] + summary: Summary +} + +// /auth/metrics/hashrate +export type MetricsHashrateGroupBy = 'container' | 'miner' + +export interface MetricsHashrateQueryParams extends MetricsQueryParams { + groupBy?: MetricsHashrateGroupBy +} + +export interface MetricsHashrateLogEntry { + ts: number + hashrateMhs: number +} + +export interface MetricsHashrateGroupedLogEntry { + ts: number + hashrateMhs: Record +} + +export interface MetricsHashrateSummary { + avgHashrateMhs: number | null + totalHashrateMhs: number +} + +export type MetricsHashrateResponse = MetricsResponse< + MetricsHashrateLogEntry, + MetricsHashrateSummary +> + +export interface MetricsHashrateGroupedSummary { + avgHashrateMhs: number | null + totalHashrateMhs: number + groupedBy?: Record +} + +export type MetricsHashrateGroupedResponse = MetricsResponse< + MetricsHashrateGroupedLogEntry, + MetricsHashrateGroupedSummary +> + +// /auth/metrics/consumption +export type MetricsConsumptionGroupBy = 'container' | 'miner' + +export interface MetricsConsumptionQueryParams extends MetricsQueryParams { + groupBy?: MetricsConsumptionGroupBy +} + +export interface MetricsConsumptionLogEntry { + ts: number + powerW: number + consumptionMWh: number +} + +export interface MetricsConsumptionGroupedLogEntry { + ts: number + powerW: Record + consumptionMWh: Record | null +} + +export interface MetricsConsumptionSummary { + avgPowerW: number | null + totalConsumptionMWh: number +} + +export interface MetricsConsumptionGroupSummary { + avgPowerW: number | null + totalConsumptionMWh: number +} + +export interface MetricsConsumptionGroupedSummary { + avgPowerW: number | null + totalConsumptionMWh: number + groupedBy?: Record +} + +export type MetricsConsumptionResponse = MetricsResponse< + MetricsConsumptionLogEntry, + MetricsConsumptionSummary +> + +export type MetricsConsumptionGroupedResponse = MetricsResponse< + MetricsConsumptionGroupedLogEntry, + MetricsConsumptionGroupedSummary +> + +// /auth/metrics/efficiency +export interface MetricsEfficiencyLogEntry { + ts: number + efficiencyWThs: number +} + +export interface MetricsEfficiencySummary { + avgEfficiencyWThs: number | null +} + +export type MetricsEfficiencyResponse = MetricsResponse< + MetricsEfficiencyLogEntry, + MetricsEfficiencySummary +> + +// /auth/metrics/miner-status +export interface MetricsMinerStatusLogEntry { + ts: number + online: number + offline: number + sleep: number + maintenance: number +} + +export interface MetricsMinerStatusSummary { + avgOnline: number | null + avgOffline: number | null + avgSleep: number | null + avgMaintenance: number | null +} + +export type MetricsMinerStatusResponse = MetricsResponse< + MetricsMinerStatusLogEntry, + MetricsMinerStatusSummary +> + +// /auth/metrics/power-mode +export type MetricsInterval = '1h' | '1d' | '1w' + +export interface MetricsPowerModeQueryParams extends MetricsQueryParams { + interval?: MetricsInterval +} + +export interface MetricsPowerModeLogEntry { + ts: number + low: number + normal: number + high: number + sleep: number + offline: number + notMining: number + maintenance: number + error: number +} + +export interface MetricsPowerModeSummary { + avgLow: number | null + avgNormal: number | null + avgHigh: number | null + avgSleep: number | null + avgOffline: number | null + avgNotMining: number | null + avgMaintenance: number | null + avgError: number | null +} + +export type MetricsPowerModeResponse = MetricsResponse< + MetricsPowerModeLogEntry, + MetricsPowerModeSummary +> + +// /auth/metrics/power-mode/timeline +export interface MetricsPowerModeTimelineQueryParams { + start?: number + end?: number + container?: string + overwriteCache?: boolean +} + +export interface MetricsPowerModeTimelineSegment { + from: number + to: number + powerMode: string + status: string +} + +export interface MetricsPowerModeTimelineLogEntry { + minerId: string + container: string + segments: MetricsPowerModeTimelineSegment[] +} + +export interface MetricsPowerModeTimelineResponse { + log: MetricsPowerModeTimelineLogEntry[] +} + +// /auth/metrics/temperature +export interface MetricsTemperatureQueryParams extends MetricsQueryParams { + interval?: MetricsInterval + container?: string +} + +export interface MetricsTemperatureContainerStats { + maxC: number + avgC: number +} + +export interface MetricsTemperatureLogEntry { + ts: number + containers: Record + siteMaxC: number | null + siteAvgC: number | null +} + +export interface MetricsTemperatureSummary { + avgMaxTemp: number | null + avgAvgTemp: number | null + peakTemp: number | null +} + +export type MetricsTemperatureResponse = MetricsResponse< + MetricsTemperatureLogEntry, + MetricsTemperatureSummary +> + // Revenue Summary: /auth/finance/revenue-summary export interface RevenueSummaryLogEntry { ts: number