M5-T07: add latest-reading cards and Recharts trend charts; readings return most-recent rows

This commit is contained in:
2026-06-22 14:17:59 +02:00
parent 2dc469274a
commit 68165f0e01
13 changed files with 1454 additions and 23 deletions
+256
View File
@@ -0,0 +1,256 @@
/**
* Tests for energy/EnergyCharts.tsx
*
* Coverage:
* 1. Renders chart title with device name.
* 2. Shows loading state while readings/metrics are loading.
* 3. Shows error state when either query fails.
* 4. Shows empty state when no readings are in the time window.
* 5. Renders chart container when data is available.
* 6. Tolerates missing keys in payload (no crash when key absent).
* 7. Time-range segmented control is rendered.
*
* NOTE: Recharts itself is NOT mocked — we let it render (jsdom-compatible
* rendering), but we only assert on data-testid wrappers, not Recharts internals.
* Recharts SVG rendering may produce warnings in jsdom; these are expected and
* benign (jsdom lacks ResizeObserver / SVG layout).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { EnergyCharts } from './EnergyCharts'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const UUID = 'device-uuid-test'
const DEVICE_NAME = 'SDM120 Main'
const METRICS_RESP = {
profile: 'sdm120',
metrics: [
{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' },
{ key: 'current', label: 'Current', unit: 'A', device_class: 'current' },
],
}
const READING = {
recorded_at: '2026-06-22T10:00:00Z',
payload: { voltage: 230.2, current: 1.3 },
}
const READINGS_RESP = {
items: [READING],
}
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: vi.fn(),
PATCH: vi.fn(),
DELETE: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function setupDefaultMocks() {
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/devices/{uuid}/metrics') {
return Promise.resolve({ data: METRICS_RESP })
}
if (path === '/api/modbus/devices/{uuid}/readings') {
return Promise.resolve({ data: READINGS_RESP })
}
return Promise.resolve({ data: null })
})
}
function renderChart() {
return renderWithProviders(
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
{ initialPath: '/energy' },
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('EnergyCharts — rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders chart title with device name', async () => {
renderChart()
await waitFor(() => {
expect(screen.getByTestId(`energy-charts-title-${UUID}`)).toBeInTheDocument()
})
expect(screen.getByTestId(`energy-charts-title-${UUID}`).textContent).toBe(DEVICE_NAME)
})
it('renders time-range segmented control', async () => {
renderChart()
await waitFor(() => {
expect(screen.getByTestId(`energy-charts-preset-${UUID}`)).toBeInTheDocument()
})
})
it('shows loading state while data is being fetched', () => {
// Never resolve
mockGet.mockReturnValue(new Promise(() => {}))
renderChart()
expect(screen.getByTestId(`energy-charts-loading-${UUID}`)).toBeInTheDocument()
})
it('shows error state when readings query fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
renderChart()
await waitFor(() => {
expect(screen.getByTestId(`energy-charts-error-${UUID}`)).toBeInTheDocument()
})
})
it('shows empty state when no readings in time window', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/devices/{uuid}/metrics') {
return Promise.resolve({ data: METRICS_RESP })
}
if (path === '/api/modbus/devices/{uuid}/readings') {
return Promise.resolve({ data: { items: [] } })
}
return Promise.resolve({ data: null })
})
renderChart()
await waitFor(() => {
expect(screen.getByTestId(`energy-charts-empty-${UUID}`)).toBeInTheDocument()
})
})
it('renders chart container when data is available', async () => {
renderChart()
await waitFor(() => {
expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument()
})
})
})
describe('EnergyCharts — truncation notice', () => {
beforeEach(() => vi.clearAllMocks())
it('shows truncation notice when readings count equals the limit cap (1000)', async () => {
// Build exactly 1000 synthetic readings to trigger the truncation hint.
const truncatedItems = Array.from({ length: 1000 }, (_, i) => ({
recorded_at: new Date(Date.now() - (999 - i) * 5000).toISOString(),
payload: { voltage: 230 + i * 0.01 },
}))
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/devices/{uuid}/metrics') {
return Promise.resolve({ data: METRICS_RESP })
}
if (path === '/api/modbus/devices/{uuid}/readings') {
return Promise.resolve({ data: { items: truncatedItems } })
}
return Promise.resolve({ data: null })
})
renderChart()
await waitFor(() => {
expect(
screen.getByTestId(`energy-charts-truncated-${UUID}`),
).toBeInTheDocument()
})
})
it('does NOT show truncation notice when readings count is below limit', async () => {
// Default mock has exactly 1 item — well below 1000.
setupDefaultMocks()
renderChart()
await waitFor(() => {
expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument()
})
expect(screen.queryByTestId(`energy-charts-truncated-${UUID}`)).not.toBeInTheDocument()
})
})
describe('EnergyCharts — payload key tolerance', () => {
beforeEach(() => vi.clearAllMocks())
it('does not crash when payload is missing a metric key', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/devices/{uuid}/metrics') {
return Promise.resolve({ data: METRICS_RESP })
}
if (path === '/api/modbus/devices/{uuid}/readings') {
// payload is missing 'current' key
return Promise.resolve({
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
})
}
return Promise.resolve({ data: null })
})
// Should render without throwing
expect(() => renderChart()).not.toThrow()
await waitFor(() => {
expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument()
})
})
it('does not crash when payload is entirely empty', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/devices/{uuid}/metrics') {
return Promise.resolve({ data: METRICS_RESP })
}
if (path === '/api/modbus/devices/{uuid}/readings') {
return Promise.resolve({
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: {} }] },
})
}
return Promise.resolve({ data: null })
})
expect(() => renderChart()).not.toThrow()
// All values null → empty state (no chart) or chart with no data points shown.
await waitFor(() => {
expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument()
})
})
})
+333
View File
@@ -0,0 +1,333 @@
/**
* EnergyCharts — self-contained Recharts wrapper for Modbus device trend charts.
*
* Design decisions (M5 decision 11):
* - Recharts is imported ONLY in this file (isolation, easy to swap later).
* - The component is fully self-contained: it owns its own data fetching via
* useReadings / useMetrics hooks and renders loading/error/empty states.
* - Time-range selection is internal; readings are always fetched with a window
* + limit cap — never a full-table pull.
* - Metric labels and units come from GET /metrics; missing keys in payload are
* tolerated (a null data point is emitted so the line simply has a gap).
*/
// ---------------------------------------------------------------------------
// Recharts imports — keep ALL recharts imports inside this file.
// ---------------------------------------------------------------------------
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { useState, useMemo } from 'react'
import {
Stack,
Group,
Text,
Loader,
Alert,
SegmentedControl,
Paper,
Title,
Badge,
Box,
} from '@mantine/core'
import { useReadings, useMetrics } from './hooks'
import type { MetricInfo } from './hooks'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Must match the limit passed to useReadings below. */
const CHART_READINGS_LIMIT = 1000
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface EnergyChartsProps {
/** UUID of the Modbus device to chart. */
uuid: string
/** Friendly name for the chart title. */
deviceName: string
}
// ---------------------------------------------------------------------------
// Time-range presets
// ---------------------------------------------------------------------------
interface TimePreset {
label: string
value: string
/** How many milliseconds of history to show. */
spanMs: number
}
const TIME_PRESETS: TimePreset[] = [
{ label: '1 h', value: '1h', spanMs: 60 * 60 * 1000 },
{ label: '6 h', value: '6h', spanMs: 6 * 60 * 60 * 1000 },
{ label: '24 h', value: '24h', spanMs: 24 * 60 * 60 * 1000 },
]
const DEFAULT_PRESET = '1h'
/** Derive ISO start/end strings for a given preset. */
function presetWindow(spanMs: number): { start: string; end: string } {
const end = new Date()
const start = new Date(end.getTime() - spanMs)
return { start: start.toISOString(), end: end.toISOString() }
}
// ---------------------------------------------------------------------------
// Metric colour palette — cycles through a fixed set
// ---------------------------------------------------------------------------
const LINE_COLORS = [
'#4c9cdb',
'#f59f00',
'#51cf66',
'#f03e3e',
'#cc5de8',
'#20c997',
'#fd7e14',
'#74c0fc',
]
function lineColor(index: number): string {
return LINE_COLORS[index % LINE_COLORS.length]
}
// ---------------------------------------------------------------------------
// Helper: format a recorded_at timestamp for the X-axis tick
// ---------------------------------------------------------------------------
function formatTimeTick(isoString: string): string {
try {
const d = new Date(isoString)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} catch {
return isoString
}
}
// ---------------------------------------------------------------------------
// Helper: safe numeric read from payload — returns null if key absent/non-numeric
// ---------------------------------------------------------------------------
function safePayloadValue(
payload: Record<string, unknown> | null | undefined,
key: string,
): number | null {
if (!payload) return null
const raw = payload[key]
if (raw === null || raw === undefined) return null
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
// ---------------------------------------------------------------------------
// EnergyCharts component
// ---------------------------------------------------------------------------
export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
const { start, end } = useMemo(
() => presetWindow(selectedPreset.spanMs),
// Recompute whenever the preset changes; intentionally excludes Date.now()
// so the window doesn't drift on every render — it only resets on tab change.
// eslint-disable-next-line react-hooks/exhaustive-deps
[activePreset],
)
const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT })
const metricsQuery = useMetrics(uuid)
// -------------------------------------------------------------------------
// Derive chart data: [{recorded_at, voltage: 230.2, current: 1.3, ...}, ...]
// -------------------------------------------------------------------------
const metricsData = metricsQuery.data?.metrics
const chartData = useMemo(() => {
const metrics: MetricInfo[] = metricsData ?? []
const readings = readingsQuery.data?.items ?? []
return readings.map((row) => {
const point: Record<string, string | number | null> = {
recorded_at: row.recorded_at,
}
for (const m of metrics) {
// Tolerate missing keys — null produces a gap in the line, not a crash.
point[m.key] = safePayloadValue(row.payload as Record<string, unknown>, m.key)
}
return point
})
}, [readingsQuery.data, metricsData])
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
// -------------------------------------------------------------------------
// States: loading / error / empty
// -------------------------------------------------------------------------
const isLoading = readingsQuery.isLoading || metricsQuery.isLoading
const isError = readingsQuery.isError || metricsQuery.isError
/**
* True when the returned row count equals the limit cap, meaning the window
* contains more data than was fetched. The backend returns the most-recent N
* rows in this case, so the chart shows the latest segment — but a hint is
* shown so the user knows the full window is not displayed.
*/
const isTruncated =
!isLoading &&
!isError &&
(readingsQuery.data?.items.length ?? 0) >= CHART_READINGS_LIMIT
return (
<Paper withBorder p="md" data-testid={`energy-charts-${uuid}`}>
<Stack gap="sm">
{/* Header row */}
<Group justify="space-between" align="center" wrap="nowrap">
<Title order={4} data-testid={`energy-charts-title-${uuid}`}>
{deviceName}
</Title>
<SegmentedControl
size="xs"
value={activePreset}
onChange={setActivePreset}
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
data-testid={`energy-charts-preset-${uuid}`}
/>
</Group>
{/* Loading */}
{isLoading && (
<Group justify="center" py="lg" data-testid={`energy-charts-loading-${uuid}`}>
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading readings
</Text>
</Group>
)}
{/* Error */}
{!isLoading && isError && (
<Alert color="red" data-testid={`energy-charts-error-${uuid}`}>
Failed to load readings or metrics. Please try again.
</Alert>
)}
{/* Empty */}
{!isLoading && !isError && chartData.length === 0 && (
<Text
size="sm"
c="dimmed"
ta="center"
py="lg"
data-testid={`energy-charts-empty-${uuid}`}
>
No readings in this time window.
</Text>
)}
{/* Truncation notice — shown when window has more data than the fetch limit */}
{isTruncated && (
<Text
size="xs"
c="dimmed"
ta="right"
data-testid={`energy-charts-truncated-${uuid}`}
>
{CHART_READINGS_LIMIT}
</Text>
)}
{/* Chart */}
{!isLoading && !isError && chartData.length > 0 && (
<Box data-testid={`energy-charts-chart-${uuid}`}>
{/* Render one chart per metric for clarity (avoids mixed Y-axis units) */}
{metrics.map((metric, idx) => {
// Check if this metric has any non-null data points; skip if all null.
const hasData = chartData.some((pt) => pt[metric.key] !== null)
if (!hasData) return null
return (
<Box key={metric.key} mb="md">
<Group gap="xs" mb={4}>
<Text size="xs" fw={600} c="dimmed">
{metric.label}
</Text>
{metric.unit && (
<Badge size="xs" variant="outline" color="gray">
{metric.unit}
</Badge>
)}
</Group>
<ResponsiveContainer width="100%" height={160}>
<LineChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" opacity={0.4} />
<XAxis
dataKey="recorded_at"
tickFormatter={formatTimeTick}
tick={{ fontSize: 10 }}
minTickGap={40}
/>
<YAxis
tick={{ fontSize: 10 }}
width={50}
tickFormatter={(v: number) =>
metric.unit ? `${v} ${metric.unit}` : String(v)
}
/>
<RechartsTooltip
formatter={(value) => {
const n = value as number | null | undefined
if (n == null) return ['', metric.label] as [string, string]
return [
`${n}${metric.unit ? ' ' + metric.unit : ''}`,
metric.label,
] as [string, string]
}}
labelFormatter={(label) => {
try {
return new Date(String(label)).toLocaleString()
} catch {
return String(label)
}
}}
/>
<Legend />
<Line
type="monotone"
dataKey={metric.key}
name={metric.label}
stroke={lineColor(idx)}
dot={false}
isAnimationActive={false}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</Box>
)
})}
</Box>
)}
</Stack>
</Paper>
)
}
+133
View File
@@ -212,3 +212,136 @@ describe('useTestReadDevice', () => {
})
})
})
describe('useLatestReading', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/modbus/devices/{uuid}/latest with path param', 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'), { wrapper: Wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/latest', {
params: { path: { uuid: 'test-uuid-1' } },
})
expect(result.current.data?.found).toBe(true)
expect(result.current.data?.payload).toEqual({ voltage: 230.2 })
})
it('returns found=false when device has no readings', 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)
expect(result.current.data?.payload).toBeNull()
})
})
describe('useMetrics', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/modbus/devices/{uuid}/metrics with path param', async () => {
mockGet.mockResolvedValue({
data: {
profile: 'sdm120',
metrics: [{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' }],
},
})
const { Wrapper } = makeWrapper()
const { useMetrics } = await import('./hooks')
const { result } = renderHook(() => useMetrics('test-uuid-1'), { wrapper: Wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/metrics', {
params: { path: { uuid: 'test-uuid-1' } },
})
expect(result.current.data?.metrics).toHaveLength(1)
expect(result.current.data?.metrics[0].key).toBe('voltage')
})
})
describe('useReadings', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/modbus/devices/{uuid}/readings with window and limit', 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 params = { start: '2026-06-22T09:00:00Z', end: '2026-06-22T10:00:00Z', limit: 500 }
const { result } = renderHook(() => useReadings('test-uuid-1', params), { wrapper: Wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', {
params: {
path: { uuid: 'test-uuid-1' },
query: {
start: '2026-06-22T09:00:00Z',
end: '2026-06-22T10:00:00Z',
limit: 500,
},
},
})
expect(result.current.data?.items).toHaveLength(1)
})
it('caps limit at READINGS_MAX_LIMIT (1000) even if caller requests more', async () => {
mockGet.mockResolvedValue({ data: { items: [] } })
const { Wrapper } = makeWrapper()
const { useReadings } = await import('./hooks')
const { result } = renderHook(
() => useReadings('test-uuid-1', { limit: 99999 }),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// The query param limit should be capped at 1000.
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ limit: 1000 }),
}),
}),
)
})
it('omits start/end from query params when not provided', async () => {
mockGet.mockResolvedValue({ data: { items: [] } })
const { Wrapper } = makeWrapper()
const { useReadings } = await import('./hooks')
const { result } = renderHook(
() => useReadings('test-uuid-1', {}),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings',
expect.objectContaining({
params: expect.objectContaining({
query: expect.not.objectContaining({ start: expect.anything() }),
}),
}),
)
})
})
+86
View File
@@ -8,6 +8,9 @@
* ['modbus-devices'] — device list
* ['modbus-device', uuid] — single device
* ['modbus-profiles'] — profile list (rarely changes)
* ['modbus-latest', uuid] — latest reading per device
* ['modbus-metrics', uuid] — profile metric metadata per device
* ['modbus-readings', uuid, params] — time-range readings per device
*
* On success, mutations invalidate the device list so the UI refreshes.
*/
@@ -25,6 +28,22 @@ export type ModbusDeviceCreate = components['schemas']['ModbusDeviceCreate']
export type ModbusDeviceUpdate = components['schemas']['ModbusDeviceUpdate']
export type ProfileSummary = components['schemas']['ProfileSummary']
export type ModbusTestReadResponse = components['schemas']['ModbusTestReadResponse']
export type ModbusLatestResponse = components['schemas']['ModbusLatestResponse']
export type ModbusMetricsResponse = components['schemas']['ModbusMetricsResponse']
export type MetricInfo = components['schemas']['MetricInfo']
export type ModbusReadingResponse = components['schemas']['ModbusReadingResponse']
export type ModbusReadingsResponse = components['schemas']['ModbusReadingsResponse']
// ---------------------------------------------------------------------------
// Reading query params
// ---------------------------------------------------------------------------
export interface ReadingsQueryParams {
start?: string | null
end?: string | null
/** Capped server-side; default max is 1000 to avoid pulling full history. */
limit?: number
}
// ---------------------------------------------------------------------------
// Query: list all devices
@@ -112,3 +131,70 @@ export function useTestReadDevice() {
}),
})
}
// ---------------------------------------------------------------------------
// Query: latest reading for a device
// ---------------------------------------------------------------------------
export function useLatestReading(uuid: string) {
return useQuery({
queryKey: ['modbus-latest', uuid],
queryFn: async () => {
const res = await apiClient.GET('/api/modbus/devices/{uuid}/latest', {
params: { path: { uuid } },
})
return res.data
},
// Refresh every 10 s to show up-to-date readings.
refetchInterval: 10_000,
})
}
// ---------------------------------------------------------------------------
// Query: profile metric metadata for a device (label/unit per key)
// ---------------------------------------------------------------------------
export function useMetrics(uuid: string) {
return useQuery({
queryKey: ['modbus-metrics', uuid],
queryFn: async () => {
const res = await apiClient.GET('/api/modbus/devices/{uuid}/metrics', {
params: { path: { uuid } },
})
return res.data
},
// Metrics are static (tied to profile version); 5 min stale time.
staleTime: 5 * 60 * 1000,
})
}
// ---------------------------------------------------------------------------
// Query: time-range readings for a device (window + limit — never full-table)
// ---------------------------------------------------------------------------
/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */
const READINGS_MAX_LIMIT = 1000
export function useReadings(uuid: string, params: ReadingsQueryParams) {
const { start, end, limit } = params
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
return useQuery({
queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }],
queryFn: async () => {
const res = await apiClient.GET('/api/modbus/devices/{uuid}/readings', {
params: {
path: { uuid },
query: {
...(start ? { start } : {}),
...(end ? { end } : {}),
limit: effectiveLimit,
},
},
})
return res.data
},
// Enabled only when we have valid uuid; start/end may be null (full window).
enabled: !!uuid,
})
}
+6 -2
View File
@@ -107,9 +107,13 @@ describe('EnergyPage — device list', () => {
expect(screen.getByTestId('devices-table')).toBeInTheDocument()
})
expect(screen.getByText('SDM120 Main')).toBeInTheDocument()
// Device name appears in multiple places now (table row + readings section + chart title).
const nameElements = screen.getAllByText('SDM120 Main')
expect(nameElements.length).toBeGreaterThan(0)
expect(screen.getByText('192.168.1.100')).toBeInTheDocument()
expect(screen.getByText('sdm120')).toBeInTheDocument()
// 'sdm120' may also appear in multiple places (table badge + chart).
const profileElements = screen.getAllByText('sdm120')
expect(profileElements.length).toBeGreaterThan(0)
})
it('shows empty state when no devices', async () => {
+176 -9
View File
@@ -1,14 +1,16 @@
/**
* EnergyPage — device management UI for the Energy (Modbus) domain.
*
* Features:
* Features (T06 + T07):
* - Device list with status indicators (enabled, last poll).
* - Create / edit via DeviceForm modal.
* - Delete with二次确认; 409 (has readings) → friendly prompt to disable instead.
* - "Test read" button per device — calls POST /devices/{uuid}/test, shows
* decoded payload or error without persisting to the database.
*
* Out of scope (T07): readings cards, trend charts, Recharts import.
* decoded payload or error without persisting to the database (T06 OBS-1: error
* now caught and displayed rather than producing an unhandled rejection).
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
* missing payload keys.
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
*/
import { useState } from 'react'
@@ -28,10 +30,14 @@ import {
Modal,
Code,
Tooltip,
Paper,
SimpleGrid,
Divider,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice } from '../energy/hooks'
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import type { ModbusDevice, ModbusTestReadResponse } from '../energy/hooks'
import { EnergyCharts } from '../energy/EnergyCharts'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client'
// ---------------------------------------------------------------------------
@@ -131,6 +137,8 @@ function TestResultModal({ result, deviceName, onClose }: TestResultModalProps)
// ---------------------------------------------------------------------------
// Device row action: test-read button (self-contained per row)
// T06 OBS-1 fix: catch errors from mutateAsync so they don't produce unhandled
// promise rejections (e.g. 422 when profile cannot load).
// ---------------------------------------------------------------------------
interface TestReadButtonProps {
@@ -140,12 +148,25 @@ interface TestReadButtonProps {
function TestReadButton({ device }: TestReadButtonProps) {
const testMutation = useTestReadDevice()
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
const [testError, setTestError] = useState<string | null>(null)
async function handleTest() {
setResult(null)
const res = await testMutation.mutateAsync(device.uuid)
if (res.data) {
setResult(res.data)
setTestError(null)
try {
const res = await testMutation.mutateAsync(device.uuid)
if (res.data) {
setResult(res.data)
}
} catch (err) {
// Network or API errors (e.g. 422 profile load failure) — surface inline.
const message =
err instanceof ApiError
? `Error ${err.status}: ${JSON.stringify(err.body?.detail ?? err.body ?? 'unknown error')}`
: err instanceof Error
? err.message
: 'Unexpected error during test read.'
setTestError(message)
}
}
@@ -164,6 +185,26 @@ function TestReadButton({ device }: TestReadButtonProps) {
</Button>
</Tooltip>
{/* Show inline test error as a small alert (no modal needed for error path) */}
{testError && (
<Modal
opened
onClose={() => setTestError(null)}
title={`Test read error — ${device.friendly_name}`}
size="sm"
data-testid="test-read-error-modal"
>
<Text c="red" data-testid="test-read-inline-error">
{testError}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setTestError(null)}>
Close
</Button>
</Group>
</Modal>
)}
{result && (
<TestResultModal
result={result}
@@ -175,6 +216,102 @@ function TestReadButton({ device }: TestReadButtonProps) {
)
}
// ---------------------------------------------------------------------------
// Latest readings card (T07)
// Fetches /latest and /metrics; tolerates missing payload keys.
// ---------------------------------------------------------------------------
interface LatestReadingsCardProps {
device: ModbusDevice
}
function LatestReadingsCard({ device }: LatestReadingsCardProps) {
const latestQuery = useLatestReading(device.uuid)
const metricsQuery = useMetrics(device.uuid)
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
const latest = latestQuery.data
if (latestQuery.isLoading || metricsQuery.isLoading) {
return (
<Paper withBorder p="sm" data-testid={`latest-card-loading-${device.uuid}`}>
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading latest reading
</Text>
</Group>
</Paper>
)
}
if (latestQuery.isError || metricsQuery.isError) {
return (
<Alert color="red" data-testid={`latest-card-error-${device.uuid}`}>
Failed to load latest reading.
</Alert>
)
}
if (!latest?.found || !latest.payload) {
return (
<Paper withBorder p="sm" data-testid={`latest-card-empty-${device.uuid}`}>
<Text size="sm" c="dimmed">
No readings yet.
</Text>
</Paper>
)
}
const payload = latest.payload as Record<string, unknown>
return (
<Paper withBorder p="sm" data-testid={`latest-card-${device.uuid}`}>
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={600} c="dimmed">
Latest reading
</Text>
{latest.recorded_at && (
<Text size="xs" c="dimmed">
{new Date(latest.recorded_at).toLocaleString()}
</Text>
)}
</Group>
<Divider />
<SimpleGrid cols={2} spacing="xs">
{metrics.map((m) => {
const raw = payload[m.key]
const hasValue = raw !== null && raw !== undefined
const displayValue = hasValue
? typeof raw === 'number'
? raw.toFixed(raw % 1 === 0 ? 0 : 2)
: String(raw)
: '—'
return (
<Group key={m.key} justify="space-between" align="baseline" gap={4}
data-testid={`latest-value-${device.uuid}-${m.key}`}>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{m.label}
</Text>
<Text size="sm" fw={500}>
{displayValue}
{hasValue && m.unit ? (
<Text component="span" size="xs" c="dimmed" ml={2}>
{m.unit}
</Text>
) : null}
</Text>
</Group>
)
})}
</SimpleGrid>
</Stack>
</Paper>
)
}
// ---------------------------------------------------------------------------
// Device list table
// ---------------------------------------------------------------------------
@@ -272,6 +409,33 @@ function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
)
}
// ---------------------------------------------------------------------------
// Device readings section — latest card + trend chart per device (T07)
// ---------------------------------------------------------------------------
interface DeviceReadingsSectionProps {
devices: ModbusDevice[]
}
function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
if (devices.length === 0) return null
return (
<Stack gap="xl" data-testid="device-readings-section">
<Divider label="Readings & Trends" labelPosition="left" />
{devices.map((device) => (
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
<Text fw={500} size="sm">
{device.friendly_name}
</Text>
<LatestReadingsCard device={device} />
<EnergyCharts uuid={device.uuid} deviceName={device.friendly_name} />
</Stack>
))}
</Stack>
)
}
// ---------------------------------------------------------------------------
// EnergyPage — top-level
// ---------------------------------------------------------------------------
@@ -362,6 +526,9 @@ export function EnergyPage() {
onEdit={openEdit}
onDelete={openDelete}
/>
{/* Latest readings cards + trend charts (T07) */}
<DeviceReadingsSection devices={devices} />
</Stack>
{/* Create form */}