hvac: add device delete (retire stale mhi_modbus rows after MQTT switch)
There was no way to remove a zone_devices mapping — a gap exposed by the Modbus->MQTT switchover, which leaves the old mhi_modbus rows stale beside the freshly auto-registered mhi_mqtt ones. Adds DELETE /api/devices/:id (manage_devices cap, unlinks photo files then deletes; device_photos cascade) and a 'Delete device' button in the expanded device row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6325532cfe
commit
7ce95a31da
3 changed files with 53 additions and 7 deletions
|
|
@ -162,6 +162,31 @@ export async function deviceRoutes(app, opts) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// DELETE /api/devices/:id — remove a device mapping entirely. Main use: retiring
|
||||||
|
// the old mhi_modbus rows after a gateway is switched to native MQTT (the units
|
||||||
|
// re-register as fresh mhi_mqtt devices, leaving the Modbus rows stale). Photo
|
||||||
|
// rows cascade via FK; their files are unlinked here so nothing is orphaned.
|
||||||
|
app.delete('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT id, discovered_name, external_ref, zone_id FROM zone_devices WHERE id = $1',
|
||||||
|
[req.params.id]
|
||||||
|
)
|
||||||
|
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
|
||||||
|
const device = rows[0]
|
||||||
|
|
||||||
|
const { rows: photos } = await pool.query('SELECT file_path FROM device_photos WHERE device_id = $1', [device.id])
|
||||||
|
for (const p of photos) {
|
||||||
|
await unlink(join(UPLOADS_DIR, p.file_path)).catch(() => {})
|
||||||
|
}
|
||||||
|
await pool.query('DELETE FROM zone_devices WHERE id = $1', [device.id]) // device_photos cascade
|
||||||
|
await logActivity(device.zone_id, 'device_command', {
|
||||||
|
note: `Deleted device ${device.discovered_name || device.external_ref}`,
|
||||||
|
source: 'manual',
|
||||||
|
userEmail: req.user.email,
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
// POST /api/devices/:id/create-maintenance-asset — explicit stub. Real integration
|
// POST /api/devices/:id/create-maintenance-asset — explicit stub. Real integration
|
||||||
// needs maintenance's own Settings -> API Keys page + POST /api/public/assets first
|
// needs maintenance's own Settings -> API Keys page + POST /api/public/assets first
|
||||||
// (see lib/maintenance-client.js). Returns a clear "not implemented" response rather
|
// (see lib/maintenance-client.js). Returns a clear "not implemented" response rather
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ export function discoverDevices(deviceType: string): Promise<{ ok: boolean; devi
|
||||||
export function updateDevice(id: number, body: { zone_id?: number | null; location?: string }): Promise<Device> {
|
export function updateDevice(id: number, body: { zone_id?: number | null; location?: string }): Promise<Device> {
|
||||||
return request(`/devices/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
return request(`/devices/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||||
}
|
}
|
||||||
|
export function deleteDevice(id: number): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/devices/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
export function createMaintenanceAsset(id: number): Promise<{ ok: boolean; notImplemented?: boolean; error?: string }> {
|
export function createMaintenanceAsset(id: number): Promise<{ ok: boolean; notImplemented?: boolean; error?: string }> {
|
||||||
return request(`/devices/${id}/create-maintenance-asset`, { method: 'POST', body: JSON.stringify({}) })
|
return request(`/devices/${id}/create-maintenance-asset`, { method: 'POST', body: JSON.stringify({}) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Fragment, useEffect, useState } from 'react'
|
import { Fragment, useEffect, useState } from 'react'
|
||||||
import { RadioTower, ChevronDown, ChevronRight, Wrench } from 'lucide-react'
|
import { RadioTower, ChevronDown, ChevronRight, Wrench, Trash2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
fetchDevices, discoverDevices, updateDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset,
|
fetchDevices, discoverDevices, updateDevice, deleteDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { Device, Zone, DevicePhoto, DeviceType } from '../types'
|
import type { Device, Zone, DevicePhoto, DeviceType } from '../types'
|
||||||
import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types'
|
import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types'
|
||||||
|
|
@ -76,6 +76,17 @@ export default function Devices() {
|
||||||
setMsg(res.error || (res.ok ? 'Asset created' : 'Not available yet'))
|
setMsg(res.error || (res.ok ? 'Asset created' : 'Not available yet'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(id: number, label: string) {
|
||||||
|
if (!confirm(`Delete device "${label}"? This removes its mapping and any photos, and cannot be undone.`)) return
|
||||||
|
try {
|
||||||
|
await deleteDevice(id)
|
||||||
|
setMsg('Device deleted')
|
||||||
|
load()
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : 'Delete failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
|
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -182,11 +193,18 @@ export default function Devices() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{canManage && d.zone_id && (
|
{canManage && d.zone_id && (
|
||||||
<button className="btn btn-sm" onClick={() => createAsset(d.id)}>
|
<button className="btn btn-sm" onClick={() => createAsset(d.id)}>
|
||||||
<Wrench size={13} strokeWidth={1.75} /> Create asset in Maintenance
|
<Wrench size={13} strokeWidth={1.75} /> Create asset in Maintenance
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={() => remove(d.id, d.discovered_name || d.external_ref)}>
|
||||||
|
<Trash2 size={13} strokeWidth={1.75} /> Delete device
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue