Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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,
ChartHeaderActions,
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'
Expand All @@ -22,28 +26,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({
Expand All @@ -59,29 +59,9 @@ const EnergyReportMinerView = ({
info?: { container?: string }
}>

const { title, key: tailLogField, getLabelName, filterCategory } = sliceConfig[slice]

const tailLogEntry = _head(_head(tailLogData as unknown[][])) as
| Record<string, Record<string, number>>
| 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 (
<EnergyReportMinerTypeViewContainer>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
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?: {
Expand All @@ -18,29 +29,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<string, SliceConfigItem> = {
// 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<EnergyReportMinerViewSlice, SliceConfigItem> = {
[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),
},
}
}
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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])
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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([])
})

Expand All @@ -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,
Expand Down
Loading
Loading