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
+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 */}