M5-T06: add Energy device management UI and sidebar entry

This commit is contained in:
2026-06-22 13:52:33 +02:00
parent 702a1cec00
commit 2dc469274a
11 changed files with 2301 additions and 8 deletions
+396
View File
@@ -0,0 +1,396 @@
/**
* EnergyPage — device management UI for the Energy (Modbus) domain.
*
* Features:
* - 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.
*/
import { useState } from 'react'
import {
Container,
Title,
Table,
Button,
Group,
Text,
Loader,
Center,
Alert,
Stack,
Badge,
ScrollArea,
Modal,
Code,
Tooltip,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import type { ModbusDevice, ModbusTestReadResponse } from '../energy/hooks'
import { ApiError } from '../api/client'
// ---------------------------------------------------------------------------
// 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 (
<Modal
opened
onClose={onCancel}
title="Confirm Delete"
size="sm"
data-testid="device-delete-modal"
>
<Stack gap="md">
<Text data-testid="device-delete-message">
Delete device <strong>{device.friendly_name}</strong>?
</Text>
{has409Error && (
<Alert color="orange" data-testid="device-delete-409-hint">
This device has existing readings and cannot be deleted. Consider{' '}
<strong>disabling</strong> it instead (click Edit uncheck Enabled).
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
Cancel
</Button>
<Button
color="red"
loading={loading}
onClick={onConfirm}
data-testid="device-delete-confirm"
>
Delete
</Button>
</Group>
</Stack>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Test-read result modal
// ---------------------------------------------------------------------------
interface TestResultModalProps {
result: ModbusTestReadResponse
deviceName: string
onClose: () => void
}
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
return (
<Modal
opened
onClose={onClose}
title={`Test read — ${deviceName}`}
size="md"
data-testid="test-read-modal"
>
{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>
</Stack>
) : (
<Stack gap="sm">
<Badge color="red" data-testid="test-read-error-badge">Failed</Badge>
<Text c="red" data-testid="test-read-error">
{result.error ?? 'Unknown error'}
</Text>
</Stack>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Close
</Button>
</Group>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Device row action: test-read button (self-contained per row)
// ---------------------------------------------------------------------------
interface TestReadButtonProps {
device: ModbusDevice
}
function TestReadButton({ device }: TestReadButtonProps) {
const testMutation = useTestReadDevice()
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
async function handleTest() {
setResult(null)
const res = await testMutation.mutateAsync(device.uuid)
if (res.data) {
setResult(res.data)
}
}
return (
<>
<Tooltip label="Immediately read device (not saved)" position="top">
<Button
size="xs"
variant="outline"
color="blue"
loading={testMutation.isPending}
onClick={handleTest}
data-testid={`device-test-${device.uuid}`}
>
Test read
</Button>
</Tooltip>
{result && (
<TestResultModal
result={result}
deviceName={device.friendly_name}
onClose={() => setResult(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 (
<Text c="dimmed" ta="center" size="sm" data-testid="devices-empty">
No devices configured yet. Click "New Device" to add one.
</Text>
)
}
return (
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="devices-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Host</Table.Th>
<Table.Th>Port</Table.Th>
<Table.Th>Unit ID</Table.Th>
<Table.Th>Profile</Table.Th>
<Table.Th>Poll (s)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{devices.map((device) => (
<Table.Tr key={device.uuid} data-testid={`device-row-${device.uuid}`}>
<Table.Td>
<Text fw={500} size="sm">
{device.friendly_name}
</Text>
</Table.Td>
<Table.Td>{device.host}</Table.Td>
<Table.Td>{device.port}</Table.Td>
<Table.Td>{device.unit_id}</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{device.profile}
</Badge>
</Table.Td>
<Table.Td>{device.poll_interval_s}</Table.Td>
<Table.Td>
<Group gap="xs">
<Badge color={device.enabled ? 'green' : 'gray'} variant="light" size="sm">
{device.enabled ? 'enabled' : 'disabled'}
</Badge>
{device.last_poll_ok !== null && (
<Badge
color={device.last_poll_ok ? 'teal' : 'red'}
variant="dot"
size="sm"
>
{device.last_poll_ok ? 'online' : 'offline'}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<TestReadButton device={device} />
<Button
size="xs"
variant="outline"
onClick={() => onEdit(device)}
data-testid={`device-edit-${device.uuid}`}
>
Edit
</Button>
<Button
size="xs"
variant="outline"
color="red"
onClick={() => onDelete(device)}
data-testid={`device-delete-${device.uuid}`}
>
Delete
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)
}
// ---------------------------------------------------------------------------
// EnergyPage — top-level
// ---------------------------------------------------------------------------
export function EnergyPage() {
const devicesQuery = useDevices()
const deleteMutation = useDeleteDevice()
const [editDevice, setEditDevice] = useState<ModbusDevice | null>(null)
const [showCreateForm, setShowCreateForm] = useState(false)
const [deleteDevice, setDeleteDevice] = useState<ModbusDevice | null>(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 (
<Center pt="xl" data-testid="energy-loading">
<Loader />
</Center>
)
}
if (devicesQuery.isError || !devicesQuery.data) {
return (
<Container size="xl" pt="xl">
<Alert color="red" data-testid="energy-load-error">
Failed to load devices. Please refresh.
</Alert>
</Container>
)
}
const devices = devicesQuery.data.items
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
return (
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
<Stack gap="lg">
<Group justify="space-between" align="center">
<Title order={2}>Energy Devices</Title>
<Button onClick={openCreate} data-testid="device-new-button">
New Device
</Button>
</Group>
<DeviceTable
devices={devices}
onEdit={openEdit}
onDelete={openDelete}
/>
</Stack>
{/* Create form */}
{showCreateForm && (
<DeviceForm
onClose={() => setShowCreateForm(false)}
onSaved={() => setShowCreateForm(false)}
/>
)}
{/* Edit form */}
{editDevice && (
<DeviceForm
device={editDevice}
onClose={() => setEditDevice(null)}
onSaved={() => setEditDevice(null)}
/>
)}
{/* Delete confirmation */}
{deleteDevice && (
<ConfirmDeleteModal
device={deleteDevice}
onConfirm={handleDeleteConfirm}
onCancel={closeDelete}
loading={deleteMutation.isPending}
has409Error={delete409}
/>
)}
</Container>
)
}