/** * EnergyPage — device management UI for the Energy (Modbus) domain. * * 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 (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' import { Container, Title, Table, Button, Group, Text, Loader, Center, Alert, Stack, Badge, ScrollArea, Modal, Code, Tooltip, 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) // --------------------------------------------------------------------------- interface ConfirmDeleteProps { device: ModbusDevice onConfirm: () => void onCancel: () => void loading: boolean /** Set when the delete failed with 409. */ has409Error: boolean } function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) { return ( Delete device {device.friendly_name}? {has409Error && ( This device has existing readings and cannot be deleted. Consider{' '} disabling it instead (click Edit → uncheck Enabled). )} ) } // --------------------------------------------------------------------------- // Test-read result modal // --------------------------------------------------------------------------- interface TestResultModalProps { result: ModbusTestReadResponse deviceName: string metrics?: MetricInfo[] onClose: () => void } function TestResultModal({ result, deviceName, metrics, onClose }: TestResultModalProps) { return ( {result.ok ? ( Success {result.payload && typeof result.payload === 'object' ? ( {Object.entries(result.payload as Record).map(([key, val]) => { const metricMeta = metrics?.find((m) => m.key === key) return ( {metricMeta?.label ?? key} {formatMetricValue(val, metricMeta)} {metricMeta?.unit ? ( {metricMeta.unit} ) : null} ) })} ) : ( {JSON.stringify(result.payload, null, 2)} )} ) : ( Failed {result.error ?? 'Unknown error'} )} ) } // --------------------------------------------------------------------------- // 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 { device: ModbusDevice } function TestReadButton({ device }: TestReadButtonProps) { const testMutation = useTestReadDevice() const metricsQuery = useMetrics(device.uuid) const [result, setResult] = useState(null) const [testError, setTestError] = useState(null) async function handleTest() { setResult(null) 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) } } return ( <> {/* Show inline test error as a small alert (no modal needed for error path) */} {testError && ( setTestError(null)} title={`Test read error — ${device.friendly_name}`} size="sm" data-testid="test-read-error-modal" > {testError} )} {result && ( setResult(null)} /> )} ) } // --------------------------------------------------------------------------- // Latest readings card (T07) // Fetches /latest and /metrics; tolerates missing payload keys. // --------------------------------------------------------------------------- interface LatestReadingsCardProps { device: ModbusDevice autoRefresh?: { refetchIntervalMs: number } } function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) { const latestQuery = useLatestReading(device.uuid, autoRefresh) const metricsQuery = useMetrics(device.uuid) const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? [] const latest = latestQuery.data if (latestQuery.isLoading || metricsQuery.isLoading) { return ( Loading latest reading… ) } if (latestQuery.isError || metricsQuery.isError) { return ( Failed to load latest reading. ) } if (!latest?.found || !latest.payload) { return ( No readings yet. ) } const payload = latest.payload as Record return ( Latest reading {latest.recorded_at && ( {new Date(latest.recorded_at).toLocaleString()} )} {metrics.map((m) => { const raw = payload[m.key] const displayValue = formatMetricValue(raw, m) const hasValue = displayValue !== '—' return ( {m.label} {displayValue} {hasValue && m.unit ? ( {m.unit} ) : null} ) })} ) } // --------------------------------------------------------------------------- // Device list table // --------------------------------------------------------------------------- interface DeviceTableProps { devices: ModbusDevice[] onEdit: (device: ModbusDevice) => void onDelete: (device: ModbusDevice) => void } function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) { if (devices.length === 0) { return ( No devices configured yet. Click "New Device" to add one. ) } return ( Name Host Port Unit ID Profile Poll (s) Status Actions {devices.map((device) => ( {device.friendly_name} {device.host} {device.port} {device.unit_id} {device.profile} {device.poll_interval_s} {device.enabled ? 'enabled' : 'disabled'} {device.last_poll_ok !== null && ( {device.last_poll_ok ? 'online' : 'offline'} )} ))}
) } // --------------------------------------------------------------------------- // 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 ( setAutoRefreshEnabled(e.currentTarget.checked)} size="sm" data-testid="auto-refresh-switch" /> {devices.map((device) => ( {device.friendly_name} ))} ) } // --------------------------------------------------------------------------- // EnergyPage — top-level // --------------------------------------------------------------------------- export function EnergyPage() { const devicesQuery = useDevices() const deleteMutation = useDeleteDevice() const [editDevice, setEditDevice] = useState(null) const [showCreateForm, setShowCreateForm] = useState(false) const [deleteDevice, setDeleteDevice] = useState(null) const [delete409, setDelete409] = useState(false) function openCreate() { setEditDevice(null) setShowCreateForm(true) } function openEdit(device: ModbusDevice) { setShowCreateForm(false) setEditDevice(device) } function openDelete(device: ModbusDevice) { setDeleteDevice(device) setDelete409(false) } function closeDelete() { setDeleteDevice(null) setDelete409(false) } async function handleDeleteConfirm() { if (!deleteDevice) return setDelete409(false) try { await deleteMutation.mutateAsync(deleteDevice.uuid) closeDelete() } catch (err) { if (err instanceof ApiError && err.status === 409) { setDelete409(true) } // Leave modal open so user sees the hint. } } // --------------------------------------------------------------------------- // Loading / error states // --------------------------------------------------------------------------- if (devicesQuery.isLoading) { return (
) } if (devicesQuery.isError || !devicesQuery.data) { return ( Failed to load devices. Please refresh. ) } const devices = devicesQuery.data.items // --------------------------------------------------------------------------- // Main render // --------------------------------------------------------------------------- return ( Energy — Devices {/* Latest readings cards + trend charts (T07) */} {/* Create form */} {showCreateForm && ( setShowCreateForm(false)} onSaved={() => setShowCreateForm(false)} /> )} {/* Edit form */} {editDevice && ( setEditDevice(null)} onSaved={() => setEditDevice(null)} /> )} {/* Delete confirmation */} {deleteDevice && ( )} ) }