M5-T07: add latest-reading cards and Recharts trend charts; readings return most-recent rows
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user