/**
* 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).
*
* M6-T10: added Tabs layout with Devices / Contracts / Prices / Costs tabs.
*/
import { useState } from 'react'
import {
Container,
Title,
Table,
Button,
Group,
Text,
Loader,
Center,
Alert,
Stack,
Badge,
ScrollArea,
Modal,
Code,
Tooltip,
Paper,
SimpleGrid,
Divider,
Switch,
Tabs,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts'
import { ContractManager } from '../energy/ContractManager'
import { TibberPrices } from '../energy/TibberPrices'
import { CostView } from '../energy/CostView'
import { DsmrPanel } from '../energy/DsmrPanel'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format'
import { formatLocalDateTime } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Delete confirmation modal (with 409 "disable instead" hint)
// ---------------------------------------------------------------------------
interface ConfirmDeleteProps {
device: ModbusDevice
onConfirm: () => void
/** Called when the user explicitly opts into cascade (force) deletion. */
onForceDelete: () => void
onCancel: () => void
loading: boolean
/** Set when the delete failed with 409. */
has409Error: boolean
}
function ConfirmDeleteModal({
device,
onConfirm,
onForceDelete,
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).
Alternatively, you can permanently delete the device together with{' '}
all its historical readings and expose settings. This action{' '}
cannot be undone.
>
)}
{!has409Error && (
)}
{has409Error && (
)}
)
}
// ---------------------------------------------------------------------------
// 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 && (
{formatLocalDateTime(latest.recorded_at)}
)}
{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}
))}
)
}
// ---------------------------------------------------------------------------
// DevicesTab — self-contained devices management tab (extracted from original EnergyPage)
// ---------------------------------------------------------------------------
function DevicesTab() {
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({ uuid: deleteDevice.uuid })
closeDelete()
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setDelete409(true)
}
// Leave modal open so user sees the hint.
}
}
async function handleForceDelete() {
if (!deleteDevice) return
try {
await deleteMutation.mutateAsync({ uuid: deleteDevice.uuid, cascade: true })
closeDelete()
} catch {
// If cascade delete also fails for some reason, keep the modal open.
// (Should be very rare — only if the device was concurrently deleted.)
}
}
if (devicesQuery.isLoading) {
return (
)
}
if (devicesQuery.isError || !devicesQuery.data) {
return (
Failed to load devices. Please refresh.
)
}
const devices = devicesQuery.data.items
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 && (
)}
)
}
// ---------------------------------------------------------------------------
// EnergyPage — top-level with Tabs
// ---------------------------------------------------------------------------
export function EnergyPage() {
return (
Devices
Contracts
Prices
Costs
DSMR
)
}