M5: Energy view — per-metric decimal formatting and chart auto-refresh toggle

This commit is contained in:
2026-06-22 19:18:29 +02:00
parent 4767a7af60
commit 935e68846c
7 changed files with 386 additions and 26 deletions
+9 -6
View File
@@ -41,7 +41,8 @@ import {
} from '@mantine/core' } from '@mantine/core'
import { useReadings, useMetrics } from './hooks' import { useReadings, useMetrics } from './hooks'
import type { MetricInfo } from './hooks' import type { MetricInfo, AutoRefreshOptions } from './hooks'
import { formatMetricValue } from './format'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -59,6 +60,8 @@ interface EnergyChartsProps {
uuid: string uuid: string
/** Friendly name for the chart title. */ /** Friendly name for the chart title. */
deviceName: string deviceName: string
/** Auto-refresh options forwarded to useReadings. */
autoRefresh?: AutoRefreshOptions
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -138,7 +141,7 @@ function safePayloadValue(
// EnergyCharts component // EnergyCharts component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) { export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProps) {
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET) const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0] const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
@@ -150,7 +153,7 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
[activePreset], [activePreset],
) )
const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }) const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }, autoRefresh)
const metricsQuery = useMetrics(uuid) const metricsQuery = useMetrics(uuid)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -295,10 +298,10 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
/> />
<RechartsTooltip <RechartsTooltip
formatter={(value) => { formatter={(value) => {
const n = value as number | null | undefined const formatted = formatMetricValue(value, metric)
if (n == null) return ['', metric.label] as [string, string] if (formatted === '—') return ['', metric.label] as [string, string]
return [ return [
`${n}${metric.unit ? ' ' + metric.unit : ''}`, `${formatted}${metric.unit ? ' ' + metric.unit : ''}`,
metric.label, metric.label,
] as [string, string] ] as [string, string]
}} }}
+120
View File
@@ -0,0 +1,120 @@
/**
* Tests for energy/format.ts — formatMetricValue helper.
*
* Coverage:
* 1. energy device_class → 3 decimal places.
* 2. non-energy device_class → 2 decimal places.
* 3. null value → "—" placeholder.
* 4. undefined value → "—" placeholder.
* 5. NaN value → "—" placeholder.
* 6. Missing / null metric metadata → default 2 decimal places.
* 7. Integer value still rendered with correct decimal places.
* 8. String numeric value is coerced and formatted.
*/
import { describe, it, expect } from 'vitest'
import { formatMetricValue, VALUE_PLACEHOLDER } from './format'
import type { MetricInfo } from './hooks'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeMetric(overrides: Partial<MetricInfo> = {}): MetricInfo {
return {
key: 'voltage',
label: 'Voltage',
unit: 'V',
device_class: 'voltage',
...overrides,
}
}
const energyMetric = makeMetric({ key: 'import_energy', label: 'Import Energy', unit: 'kWh', device_class: 'energy' })
const voltageMetric = makeMetric()
const powerMetric = makeMetric({ key: 'active_power', label: 'Active Power', unit: 'W', device_class: 'power' })
const powerFactorMetric = makeMetric({ key: 'power_factor', label: 'Power Factor', unit: '', device_class: 'power_factor' })
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('formatMetricValue — energy device_class → 3 decimal places', () => {
it('formats kWh energy value to 3 decimal places', () => {
expect(formatMetricValue(123.4567, energyMetric)).toBe('123.457')
})
it('formats a whole-number energy value to 3 decimal places', () => {
expect(formatMetricValue(123, energyMetric)).toBe('123.000')
})
it('formats zero energy value to 3 decimal places', () => {
expect(formatMetricValue(0, energyMetric)).toBe('0.000')
})
})
describe('formatMetricValue — non-energy device_class → 2 decimal places', () => {
it('formats voltage to 2 decimal places', () => {
expect(formatMetricValue(230.2567, voltageMetric)).toBe('230.26')
})
it('formats power to 2 decimal places', () => {
expect(formatMetricValue(295.0, powerMetric)).toBe('295.00')
})
it('formats power factor to 2 decimal places', () => {
expect(formatMetricValue(0.98, powerFactorMetric)).toBe('0.98')
})
it('formats current to 2 decimal places', () => {
const currentMetric = makeMetric({ key: 'current', label: 'Current', unit: 'A', device_class: 'current' })
expect(formatMetricValue(1.3456, currentMetric)).toBe('1.35')
})
})
describe('formatMetricValue — null/undefined/NaN → placeholder', () => {
it('returns placeholder for null', () => {
expect(formatMetricValue(null, voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
it('returns placeholder for undefined', () => {
expect(formatMetricValue(undefined, voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
it('returns placeholder for NaN', () => {
expect(formatMetricValue(NaN, voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
it('returns placeholder for non-numeric string', () => {
expect(formatMetricValue('not-a-number', voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
it('returns placeholder for Infinity', () => {
expect(formatMetricValue(Infinity, voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
})
describe('formatMetricValue — missing metric metadata → default 2 decimal places', () => {
it('falls back to 2 decimal places when metric is undefined', () => {
expect(formatMetricValue(230.2567)).toBe('230.26')
})
it('falls back to 2 decimal places when metric is null', () => {
expect(formatMetricValue(230.2567, null)).toBe('230.26')
})
it('returns placeholder for null value even without metric', () => {
expect(formatMetricValue(null)).toBe(VALUE_PLACEHOLDER)
})
})
describe('formatMetricValue — numeric string coercion', () => {
it('coerces a numeric string to number and formats it', () => {
// API could theoretically return strings; we handle them gracefully.
expect(formatMetricValue('230.2567', voltageMetric)).toBe('230.26')
})
it('coerces a numeric string for energy metric → 3 decimals', () => {
expect(formatMetricValue('123.4567', energyMetric)).toBe('123.457')
})
})
+40
View File
@@ -0,0 +1,40 @@
/**
* format.ts — shared metric value formatting helpers for the Energy view.
*
* Rules (from M5 polish requirements):
* - device_class === "energy" → 3 decimal places (kWh values need precision)
* - everything else → 2 decimal places
* - null / undefined / NaN → placeholder "—"
* - missing metric metadata → default 2 decimal places
*/
import type { MetricInfo } from './hooks'
/** Placeholder shown when a value is unavailable. */
export const VALUE_PLACEHOLDER = '—'
/**
* Format a numeric metric value for display.
*
* @param value The raw value from the API payload (any type; non-numeric → "—").
* @param metric Optional metric metadata from /metrics. When absent, falls back
* to 2 decimal places.
* @returns A formatted string ready for display (e.g. "230.25" or "—").
*/
export function formatMetricValue(
value: unknown,
metric?: MetricInfo | null,
): string {
// Guard: null / undefined
if (value === null || value === undefined) return VALUE_PLACEHOLDER
const n = Number(value)
// Guard: NaN or non-finite
if (!Number.isFinite(n)) return VALUE_PLACEHOLDER
// Determine decimal places from device_class.
const decimals = metric?.device_class === 'energy' ? 3 : 2
return n.toFixed(decimals)
}
+74
View File
@@ -274,6 +274,80 @@ describe('useMetrics', () => {
}) })
}) })
describe('useLatestReading — auto-refresh option', () => {
beforeEach(() => vi.clearAllMocks())
it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
mockGet.mockResolvedValue({
data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } },
})
const { Wrapper } = makeWrapper()
const { useLatestReading } = await import('./hooks')
const { result } = renderHook(
() => useLatestReading('test-uuid-1', { refetchIntervalMs: 5_000 }),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Query succeeds — refetchIntervalMs is wired through without error.
expect(result.current.data?.found).toBe(true)
})
it('works without options (no auto-refresh)', async () => {
mockGet.mockResolvedValue({
data: { found: false, recorded_at: null, payload: null },
})
const { Wrapper } = makeWrapper()
const { useLatestReading } = await import('./hooks')
const { result } = renderHook(
() => useLatestReading('test-uuid-2'),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.found).toBe(false)
})
})
describe('useReadings — auto-refresh option', () => {
beforeEach(() => vi.clearAllMocks())
it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
mockGet.mockResolvedValue({
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
})
const { Wrapper } = makeWrapper()
const { useReadings } = await import('./hooks')
const { result } = renderHook(
() => useReadings('test-uuid-1', {}, { refetchIntervalMs: 5_000 }),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Query succeeds — refetchIntervalMs is wired through without error.
expect(result.current.data?.items).toHaveLength(1)
})
it('enforces minimum refetchInterval of 2000ms', async () => {
mockGet.mockResolvedValue({ data: { items: [] } })
const { Wrapper } = makeWrapper()
const { useReadings } = await import('./hooks')
// Pass a very small value — should be clamped to 2000ms internally.
const { result } = renderHook(
() => useReadings('test-uuid-1', {}, { refetchIntervalMs: 100 }),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// No crash; the hook accepted the option and clamped it internally.
expect(result.current.isSuccess).toBe(true)
})
})
describe('useReadings', () => { describe('useReadings', () => {
beforeEach(() => vi.clearAllMocks()) beforeEach(() => vi.clearAllMocks())
+29 -4
View File
@@ -132,11 +132,28 @@ export function useTestReadDevice() {
}) })
} }
// ---------------------------------------------------------------------------
// Query options shared by auto-refreshcapable queries
// ---------------------------------------------------------------------------
export interface AutoRefreshOptions {
/**
* When provided, TanStack Query will automatically re-fetch this query at
* this interval (milliseconds). Pass `undefined` to disable auto-refresh.
* Minimum enforced value: 2 000 ms.
*/
refetchIntervalMs?: number
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Query: latest reading for a device // Query: latest reading for a device
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function useLatestReading(uuid: string) { export function useLatestReading(uuid: string, options?: AutoRefreshOptions) {
const refetchInterval = options?.refetchIntervalMs != null
? Math.max(2_000, options.refetchIntervalMs)
: undefined
return useQuery({ return useQuery({
queryKey: ['modbus-latest', uuid], queryKey: ['modbus-latest', uuid],
queryFn: async () => { queryFn: async () => {
@@ -145,8 +162,7 @@ export function useLatestReading(uuid: string) {
}) })
return res.data return res.data
}, },
// Refresh every 10 s to show up-to-date readings. refetchInterval,
refetchInterval: 10_000,
}) })
} }
@@ -175,10 +191,18 @@ export function useMetrics(uuid: string) {
/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */ /** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */
const READINGS_MAX_LIMIT = 1000 const READINGS_MAX_LIMIT = 1000
export function useReadings(uuid: string, params: ReadingsQueryParams) { export function useReadings(
uuid: string,
params: ReadingsQueryParams,
options?: AutoRefreshOptions,
) {
const { start, end, limit } = params const { start, end, limit } = params
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT) const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
const refetchInterval = options?.refetchIntervalMs != null
? Math.max(2_000, options.refetchIntervalMs)
: undefined
return useQuery({ return useQuery({
queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }], queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }],
queryFn: async () => { queryFn: async () => {
@@ -196,5 +220,6 @@ export function useReadings(uuid: string, params: ReadingsQueryParams) {
}, },
// Enabled only when we have valid uuid; start/end may be null (full window). // Enabled only when we have valid uuid; start/end may be null (full window).
enabled: !!uuid, enabled: !!uuid,
refetchInterval,
}) })
} }
+48 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Tests for EnergyPage (M5-T06). * Tests for EnergyPage (M5-T06 + M5-polish).
* *
* Coverage: * Coverage:
* 1. Renders device list from GET /api/modbus/devices. * 1. Renders device list from GET /api/modbus/devices.
@@ -10,6 +10,7 @@
* 6. Delete 409 error shows friendly hint in confirmation modal. * 6. Delete 409 error shows friendly hint in confirmation modal.
* 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result. * 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
* 8. Test-read failure shows error in modal. * 8. Test-read failure shows error in modal.
* 9. Auto-refresh switch is rendered (default on) and can be toggled.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -321,6 +322,7 @@ describe('EnergyPage — test-read', () => {
}) })
expect(screen.getByTestId('test-read-ok')).toBeInTheDocument() expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
// Values rendered via formatMetricValue — voltage 230.2 → "230.20"
expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2') expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2')
}) })
@@ -345,3 +347,48 @@ describe('EnergyPage — test-read', () => {
expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out') expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out')
}) })
}) })
describe('EnergyPage — auto-refresh switch', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders auto-refresh switch when devices exist', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
})
})
it('auto-refresh switch is checked by default (default on)', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
})
// Mantine Switch uses role="switch" and places data-testid directly on the
// <input> element; query it by the testid or by role.
const switchInput = screen.getByRole('switch', { name: /自动刷新/i })
expect(switchInput).toBeChecked()
})
it('toggling auto-refresh switch changes its checked state', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
})
const switchInput = screen.getByRole('switch', { name: /自动刷新/i })
expect(switchInput).toBeChecked()
fireEvent.click(switchInput)
await waitFor(() => {
expect(switchInput).not.toBeChecked()
})
})
})
+63 -12
View File
@@ -33,12 +33,14 @@ import {
Paper, Paper,
SimpleGrid, SimpleGrid,
Divider, Divider,
Switch,
} from '@mantine/core' } from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks' import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm' import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts' import { EnergyCharts } from '../energy/EnergyCharts'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks' import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Delete confirmation modal (with 409 "disable instead" hint) // Delete confirmation modal (with 409 "disable instead" hint)
@@ -99,10 +101,11 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
interface TestResultModalProps { interface TestResultModalProps {
result: ModbusTestReadResponse result: ModbusTestReadResponse
deviceName: string deviceName: string
metrics?: MetricInfo[]
onClose: () => void onClose: () => void
} }
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) { function TestResultModal({ result, deviceName, metrics, onClose }: TestResultModalProps) {
return ( return (
<Modal <Modal
opened opened
@@ -114,9 +117,33 @@ function TestResultModal({ result, deviceName, onClose }: TestResultModalProps)
{result.ok ? ( {result.ok ? (
<Stack gap="sm"> <Stack gap="sm">
<Badge color="green" data-testid="test-read-ok">Success</Badge> <Badge color="green" data-testid="test-read-ok">Success</Badge>
{result.payload && typeof result.payload === 'object' ? (
<SimpleGrid cols={2} spacing="xs" data-testid="test-read-payload">
{Object.entries(result.payload as Record<string, unknown>).map(([key, val]) => {
const metricMeta = metrics?.find((m) => m.key === key)
return (
<Group key={key} justify="space-between" align="baseline" gap={4}
data-testid={`test-read-value-${key}`}>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{metricMeta?.label ?? key}
</Text>
<Text size="sm" fw={500}>
{formatMetricValue(val, metricMeta)}
{metricMeta?.unit ? (
<Text component="span" size="xs" c="dimmed" ml={2}>
{metricMeta.unit}
</Text>
) : null}
</Text>
</Group>
)
})}
</SimpleGrid>
) : (
<Code block data-testid="test-read-payload"> <Code block data-testid="test-read-payload">
{JSON.stringify(result.payload, null, 2)} {JSON.stringify(result.payload, null, 2)}
</Code> </Code>
)}
</Stack> </Stack>
) : ( ) : (
<Stack gap="sm"> <Stack gap="sm">
@@ -147,6 +174,7 @@ interface TestReadButtonProps {
function TestReadButton({ device }: TestReadButtonProps) { function TestReadButton({ device }: TestReadButtonProps) {
const testMutation = useTestReadDevice() const testMutation = useTestReadDevice()
const metricsQuery = useMetrics(device.uuid)
const [result, setResult] = useState<ModbusTestReadResponse | null>(null) const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
const [testError, setTestError] = useState<string | null>(null) const [testError, setTestError] = useState<string | null>(null)
@@ -209,6 +237,7 @@ function TestReadButton({ device }: TestReadButtonProps) {
<TestResultModal <TestResultModal
result={result} result={result}
deviceName={device.friendly_name} deviceName={device.friendly_name}
metrics={metricsQuery.data?.metrics}
onClose={() => setResult(null)} onClose={() => setResult(null)}
/> />
)} )}
@@ -223,10 +252,11 @@ function TestReadButton({ device }: TestReadButtonProps) {
interface LatestReadingsCardProps { interface LatestReadingsCardProps {
device: ModbusDevice device: ModbusDevice
autoRefresh?: { refetchIntervalMs: number }
} }
function LatestReadingsCard({ device }: LatestReadingsCardProps) { function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
const latestQuery = useLatestReading(device.uuid) const latestQuery = useLatestReading(device.uuid, autoRefresh)
const metricsQuery = useMetrics(device.uuid) const metricsQuery = useMetrics(device.uuid)
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? [] const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
@@ -282,12 +312,8 @@ function LatestReadingsCard({ device }: LatestReadingsCardProps) {
<SimpleGrid cols={2} spacing="xs"> <SimpleGrid cols={2} spacing="xs">
{metrics.map((m) => { {metrics.map((m) => {
const raw = payload[m.key] const raw = payload[m.key]
const hasValue = raw !== null && raw !== undefined const displayValue = formatMetricValue(raw, m)
const displayValue = hasValue const hasValue = displayValue !== '—'
? typeof raw === 'number'
? raw.toFixed(raw % 1 === 0 ? 0 : 2)
: String(raw)
: '—'
return ( return (
<Group key={m.key} justify="space-between" align="baseline" gap={4} <Group key={m.key} justify="space-between" align="baseline" gap={4}
@@ -411,25 +437,50 @@ function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Device readings section — latest card + trend chart per device (T07) // Device readings section — latest card + trend chart per device (T07)
// Auto-refresh switch controls TanStack Query refetchInterval for all
// readings and latest queries in this section.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Default auto-refresh interval in milliseconds (matches backend ~5s poll). */
const AUTO_REFRESH_INTERVAL_MS = 5_000
interface DeviceReadingsSectionProps { interface DeviceReadingsSectionProps {
devices: ModbusDevice[] devices: ModbusDevice[]
} }
function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) { function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
// Default on — user wants charts to auto-update without manual refresh.
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
if (devices.length === 0) return null if (devices.length === 0) return null
const autoRefresh = autoRefreshEnabled
? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS }
: undefined
return ( return (
<Stack gap="xl" data-testid="device-readings-section"> <Stack gap="xl" data-testid="device-readings-section">
<Divider label="Readings & Trends" labelPosition="left" /> <Group justify="space-between" align="center">
<Divider label="Readings & Trends" labelPosition="left" style={{ flex: 1 }} />
<Switch
label="自动刷新"
checked={autoRefreshEnabled}
onChange={(e) => setAutoRefreshEnabled(e.currentTarget.checked)}
size="sm"
data-testid="auto-refresh-switch"
/>
</Group>
{devices.map((device) => ( {devices.map((device) => (
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}> <Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{device.friendly_name} {device.friendly_name}
</Text> </Text>
<LatestReadingsCard device={device} /> <LatestReadingsCard device={device} autoRefresh={autoRefresh} />
<EnergyCharts uuid={device.uuid} deviceName={device.friendly_name} /> <EnergyCharts
uuid={device.uuid}
deviceName={device.friendly_name}
autoRefresh={autoRefresh}
/>
</Stack> </Stack>
))} ))}
</Stack> </Stack>