397 lines
11 KiB
TypeScript
397 lines
11 KiB
TypeScript
/**
|
|
* 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>
|
|
)
|
|
}
|