modbus: add explicit cascade delete (device + readings + toggles + HA cleanup)
A disabled device with historical readings still cannot be deleted by default
(409 guard stays, to prevent accidental data loss). A new explicit opt-in lets
the user remove it for real:
- DELETE /api/modbus/devices/{uuid}?cascade=true removes the device's readings
and exposed_entity_toggle rows, then the device, in one transaction (FK order:
readings before device). Best-effort clears the HA discovery configs first
(MQTT not connected -> no-op, never blocks the delete). Default (no cascade)
unchanged. Returns 200 with deletion counts.
- Frontend: a 'Force Delete (all data)' button appears only after the 409, with
a red irreversible warning; cascade is sent only on that explicit action.
- Also corrected a stale comment: FK RESTRICT IS enforced at runtime now.
This commit is contained in:
Vendored
+17
-4
@@ -686,12 +686,23 @@ export interface paths {
|
||||
* Delete Device
|
||||
* @description Delete a Modbus device.
|
||||
*
|
||||
* **Default behaviour (cascade=false)**:
|
||||
* Returns 409 Conflict if the device has any associated readings; use
|
||||
* ``enabled=false`` to disable it instead.
|
||||
*
|
||||
* **Application-layer safety**: the check is performed via an explicit
|
||||
* SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,
|
||||
* which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).
|
||||
* **Cascade delete (cascade=true)**:
|
||||
* Permanently deletes the device together with all its readings and any
|
||||
* ``ExposedEntityToggle`` rows whose key matches ``modbus.<uuid>.*``.
|
||||
* Also makes a best-effort attempt to clear the device's HA Discovery
|
||||
* config topics from MQTT (empty retained payload) before the DB rows
|
||||
* are removed. MQTT failures are swallowed — the DB deletion proceeds
|
||||
* regardless.
|
||||
* Returns HTTP 200 with a ``ModbusDeleteResponse`` JSON body on success.
|
||||
*
|
||||
* **Application-layer safety**: the 409 guard uses an explicit SELECT COUNT
|
||||
* query to return a friendly message. FK RESTRICT is enforced at runtime
|
||||
* (the app sets ``PRAGMA foreign_keys=ON``), so the cascade path deletes
|
||||
* readings before the device.
|
||||
*/
|
||||
delete: operations["delete_device_api_modbus_devices__uuid__delete"];
|
||||
options?: never;
|
||||
@@ -3190,7 +3201,9 @@ export interface operations {
|
||||
};
|
||||
delete_device_api_modbus_devices__uuid__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
cascade?: boolean;
|
||||
};
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('useUpdateDevice', () => {
|
||||
describe('useDeleteDevice', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('calls DELETE /api/modbus/devices/{uuid}', async () => {
|
||||
it('calls DELETE /api/modbus/devices/{uuid} without query param when cascade omitted', async () => {
|
||||
mockDelete.mockResolvedValue({ data: null })
|
||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
|
||||
@@ -182,13 +182,32 @@ describe('useDeleteDevice', () => {
|
||||
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('test-uuid-1')
|
||||
await result.current.mutateAsync({ uuid: 'test-uuid-1' })
|
||||
})
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||
params: { path: { uuid: 'test-uuid-1' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('calls DELETE /api/modbus/devices/{uuid} with cascade=true in query when cascade=true', async () => {
|
||||
mockDelete.mockResolvedValue({
|
||||
data: { deleted: true, readings_deleted: 5, toggles_deleted: 2 },
|
||||
})
|
||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
|
||||
const { Wrapper } = makeWrapper()
|
||||
const { useDeleteDevice } = await import('./hooks')
|
||||
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ uuid: 'test-uuid-1', cascade: true })
|
||||
})
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||
params: { path: { uuid: 'test-uuid-1' }, query: { cascade: true } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTestReadDevice', () => {
|
||||
|
||||
@@ -115,12 +115,21 @@ export function useUpdateDevice() {
|
||||
// Mutation: delete device
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DeleteDeviceParams {
|
||||
uuid: string
|
||||
/** When true, perform a cascade delete (removes readings + expose toggles). */
|
||||
cascade?: boolean
|
||||
}
|
||||
|
||||
export function useDeleteDevice() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (uuid: string) =>
|
||||
mutationFn: ({ uuid, cascade }: DeleteDeviceParams) =>
|
||||
apiClient.DELETE('/api/modbus/devices/{uuid}', {
|
||||
params: { path: { uuid } },
|
||||
params: {
|
||||
path: { uuid },
|
||||
...(cascade ? { query: { cascade: true } } : {}),
|
||||
},
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
||||
})
|
||||
|
||||
@@ -288,6 +288,7 @@ describe('EnergyPage — delete device', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||
// On first open (no 409 yet), the regular confirm button is visible.
|
||||
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||
@@ -295,6 +296,38 @@ describe('EnergyPage — delete device', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
|
||||
})
|
||||
// After 409, the force-delete button appears instead of the normal confirm.
|
||||
expect(screen.getByTestId('device-delete-force')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('device-delete-confirm')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('force-delete button triggers cascade delete after 409', async () => {
|
||||
const { ApiError } = await import('../api/client')
|
||||
// First call → 409, second call (cascade) → success
|
||||
mockDelete
|
||||
.mockRejectedValueOnce(new ApiError(409, { detail: 'device has readings' }))
|
||||
.mockResolvedValueOnce({ data: { deleted: true, readings_deleted: 3, toggles_deleted: 1 } })
|
||||
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
||||
|
||||
// First attempt → 409
|
||||
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||
|
||||
// Force-delete button appears
|
||||
await waitFor(() => expect(screen.getByTestId('device-delete-force')).toBeInTheDocument())
|
||||
|
||||
// Click force-delete → should call DELETE with cascade=true
|
||||
fireEvent.click(screen.getByTestId('device-delete-force'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||
params: { path: { uuid: DEVICE.uuid }, query: { cascade: true } },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,13 +57,22 @@ import { formatLocalDateTime } from '../utils/datetime'
|
||||
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, onCancel, loading, has409Error }: ConfirmDeleteProps) {
|
||||
function ConfirmDeleteModal({
|
||||
device,
|
||||
onConfirm,
|
||||
onForceDelete,
|
||||
onCancel,
|
||||
loading,
|
||||
has409Error,
|
||||
}: ConfirmDeleteProps) {
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
@@ -78,24 +87,43 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
|
||||
</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>
|
||||
<>
|
||||
<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>
|
||||
<Alert color="red" variant="light" data-testid="device-delete-force-warning">
|
||||
Alternatively, you can <strong>permanently delete</strong> the device together with{' '}
|
||||
all its historical readings and expose settings. This action{' '}
|
||||
<strong>cannot be undone</strong>.
|
||||
</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>
|
||||
{!has409Error && (
|
||||
<Button
|
||||
color="red"
|
||||
loading={loading}
|
||||
onClick={onConfirm}
|
||||
data-testid="device-delete-confirm"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{has409Error && (
|
||||
<Button
|
||||
color="red"
|
||||
loading={loading}
|
||||
onClick={onForceDelete}
|
||||
data-testid="device-delete-force"
|
||||
>
|
||||
Force Delete (all data)
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
@@ -532,7 +560,7 @@ function DevicesTab() {
|
||||
if (!deleteDevice) return
|
||||
setDelete409(false)
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteDevice.uuid)
|
||||
await deleteMutation.mutateAsync({ uuid: deleteDevice.uuid })
|
||||
closeDelete()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
@@ -542,6 +570,17 @@ function DevicesTab() {
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<Center pt="xl" data-testid="energy-loading">
|
||||
@@ -600,6 +639,7 @@ function DevicesTab() {
|
||||
<ConfirmDeleteModal
|
||||
device={deleteDevice}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onForceDelete={handleForceDelete}
|
||||
onCancel={closeDelete}
|
||||
loading={deleteMutation.isPending}
|
||||
has409Error={delete409}
|
||||
|
||||
Reference in New Issue
Block a user