Add remote device disable with reason, and a public device status check
Device gains disabledReason (cleared automatically on re-enable). The devices admin page prompts for a reason when disabling, and shows it under the device's status once disabled. New public GET /api/devices/:serial/status lets a field device check whether it's disabled and why, before any user session exists — unauthenticated by design, matching the existing serial-based trust model used for devices/<serial>/log ingestion, and only ever reveals a boolean plus a short reason string. The devices/<serial>/log ingest path didn't check isActive at all (the devices/<mqttUsername>/points path already did) — closed that gap for both "log" and "status" message types so a disabled device's data is rejected regardless of which path it arrives on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ interface DeviceRow {
|
||||
serialNumber: string | null;
|
||||
mqttUsername: string | null;
|
||||
isActive: boolean;
|
||||
disabledReason: string | null;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,8 @@ export default function DevicesPage() {
|
||||
|
||||
const [devices, setDevices] = useState<DeviceRow[]>([]);
|
||||
const [form, setForm] = useState({ name: '', mqttUsername: '', serialNumber: '' });
|
||||
const [disablingId, setDisablingId] = useState<string | null>(null);
|
||||
const [disableReason, setDisableReason] = useState('');
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -60,9 +63,23 @@ export default function DevicesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (device: DeviceRow) => {
|
||||
const enable = async (deviceId: string) => {
|
||||
try {
|
||||
await api.patch(`/api/orgs/${orgId}/devices/${device.id}`, { isActive: !device.isActive });
|
||||
await api.patch(`/api/orgs/${orgId}/devices/${deviceId}`, { isActive: true });
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDisable = async (deviceId: string) => {
|
||||
try {
|
||||
await api.patch(`/api/orgs/${orgId}/devices/${deviceId}`, {
|
||||
isActive: false,
|
||||
disabledReason: disableReason || undefined,
|
||||
});
|
||||
setDisablingId(null);
|
||||
setDisableReason('');
|
||||
reload();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
@@ -87,7 +104,8 @@ export default function DevicesPage() {
|
||||
<h1>Devices</h1>
|
||||
<p style={{ color: '#666' }}>
|
||||
Field devices publish to <code>devices/<mqtt username>/points</code>. Broker credentials are provisioned
|
||||
separately for now.
|
||||
separately for now. A disabled device can check <code>GET /api/devices/<serial>/status</code> for its
|
||||
disabled state and reason.
|
||||
</p>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
{notice && <p style={{ color: '#388e3c' }}>{notice}</p>}
|
||||
@@ -134,16 +152,59 @@ export default function DevicesPage() {
|
||||
<td style={{ padding: '0.5rem' }}>{d.name}</td>
|
||||
<td>{d.mqttUsername ? <code>{d.mqttUsername}</code> : '—'}</td>
|
||||
<td>{d.serialNumber ?? '—'}</td>
|
||||
<td style={{ color: d.isActive ? '#388e3c' : '#9e9e9e' }}>{d.isActive ? 'active' : 'disabled'}</td>
|
||||
<td style={{ color: d.isActive ? '#388e3c' : '#c62828' }}>
|
||||
{d.isActive ? 'active' : 'disabled'}
|
||||
{!d.isActive && d.disabledReason && (
|
||||
<div style={{ color: '#888', fontWeight: 400, fontSize: '0.85rem' }}>{d.disabledReason}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}</td>
|
||||
{isAdmin && (
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button onClick={() => toggleActive(d)}>{d.isActive ? 'Disable' : 'Enable'}</button>{' '}
|
||||
{d.isActive ? (
|
||||
<button onClick={() => setDisablingId(d.id)}>Disable</button>
|
||||
) : (
|
||||
<button onClick={() => enable(d.id)}>Enable</button>
|
||||
)}{' '}
|
||||
<button onClick={() => remove(d.id)}>Delete</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{isAdmin &&
|
||||
disablingId &&
|
||||
devices.some((d) => d.id === disablingId) && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '0.75rem', background: '#fff8f8' }}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDisable(disablingId);
|
||||
}}
|
||||
style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}
|
||||
>
|
||||
<span>Reason for disabling {devices.find((d) => d.id === disablingId)?.name}:</span>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="e.g. reported lost, billing hold"
|
||||
value={disableReason}
|
||||
onChange={(e) => setDisableReason(e.target.value)}
|
||||
style={{ padding: '0.4rem', flex: 1 }}
|
||||
/>
|
||||
<button type="submit">Confirm disable</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDisablingId(null);
|
||||
setDisableReason('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user