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
+66 -15
View File
@@ -33,12 +33,14 @@ import {
Paper,
SimpleGrid,
Divider,
Switch,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format'
// ---------------------------------------------------------------------------
// Delete confirmation modal (with 409 "disable instead" hint)
@@ -99,10 +101,11 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
interface TestResultModalProps {
result: ModbusTestReadResponse
deviceName: string
metrics?: MetricInfo[]
onClose: () => void
}
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
function TestResultModal({ result, deviceName, metrics, onClose }: TestResultModalProps) {
return (
<Modal
opened
@@ -114,9 +117,33 @@ function TestResultModal({ result, deviceName, onClose }: TestResultModalProps)
{result.ok ? (
<Stack gap="sm">
<Badge color="green" data-testid="test-read-ok">Success</Badge>
<Code block data-testid="test-read-payload">
{JSON.stringify(result.payload, null, 2)}
</Code>
{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">
{JSON.stringify(result.payload, null, 2)}
</Code>
)}
</Stack>
) : (
<Stack gap="sm">
@@ -147,6 +174,7 @@ interface TestReadButtonProps {
function TestReadButton({ device }: TestReadButtonProps) {
const testMutation = useTestReadDevice()
const metricsQuery = useMetrics(device.uuid)
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
const [testError, setTestError] = useState<string | null>(null)
@@ -209,6 +237,7 @@ function TestReadButton({ device }: TestReadButtonProps) {
<TestResultModal
result={result}
deviceName={device.friendly_name}
metrics={metricsQuery.data?.metrics}
onClose={() => setResult(null)}
/>
)}
@@ -223,10 +252,11 @@ function TestReadButton({ device }: TestReadButtonProps) {
interface LatestReadingsCardProps {
device: ModbusDevice
autoRefresh?: { refetchIntervalMs: number }
}
function LatestReadingsCard({ device }: LatestReadingsCardProps) {
const latestQuery = useLatestReading(device.uuid)
function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
const latestQuery = useLatestReading(device.uuid, autoRefresh)
const metricsQuery = useMetrics(device.uuid)
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
@@ -282,12 +312,8 @@ function LatestReadingsCard({ device }: LatestReadingsCardProps) {
<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)
: '—'
const displayValue = formatMetricValue(raw, m)
const hasValue = displayValue !== '—'
return (
<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)
// 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 {
devices: ModbusDevice[]
}
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
const autoRefresh = autoRefreshEnabled
? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS }
: undefined
return (
<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) => (
<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} />
<LatestReadingsCard device={device} autoRefresh={autoRefresh} />
<EnergyCharts
uuid={device.uuid}
deviceName={device.friendly_name}
autoRefresh={autoRefresh}
/>
</Stack>
))}
</Stack>