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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "devices" ADD COLUMN "disabledReason" TEXT;
|
||||||
@@ -138,6 +138,9 @@ model Device {
|
|||||||
serialNumber String? @unique
|
serialNumber String? @unique
|
||||||
mqttUsername String? @unique
|
mqttUsername String? @unique
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
// Set (and cleared on re-enable) via the devices admin page; a device
|
||||||
|
// fetches this via GET /api/devices/:serial/status to show on its own screen.
|
||||||
|
disabledReason String?
|
||||||
lastSeenAt DateTime? @db.Timestamptz(6)
|
lastSeenAt DateTime? @db.Timestamptz(6)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
updatedAt DateTime @updatedAt @db.Timestamptz(6)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { IngestModule } from './ingest/ingest.module';
|
|||||||
import { RealtimeModule } from './realtime/realtime.module';
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
import { SimModule } from './sim/sim.module';
|
import { SimModule } from './sim/sim.module';
|
||||||
|
import { DeviceStatusModule } from './device-status/device-status.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,6 +26,7 @@ import { SimModule } from './sim/sim.module';
|
|||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
SimModule,
|
SimModule,
|
||||||
|
DeviceStatusModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, StatusController],
|
controllers: [AppController, StatusController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
16
backend/src/device-status/device-status.controller.ts
Normal file
16
backend/src/device-status/device-status.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
|
import { DeviceStatusService } from './device-status.service';
|
||||||
|
|
||||||
|
// Public and unauthenticated by design: a field device checks in by serial
|
||||||
|
// number alone (the same trust model already used for devices/<serial>/log
|
||||||
|
// MQTT ingestion) before any human has logged it into an org. The response
|
||||||
|
// only ever reveals a boolean + a short admin-written reason string.
|
||||||
|
@Controller('devices')
|
||||||
|
export class DeviceStatusController {
|
||||||
|
constructor(private readonly deviceStatusService: DeviceStatusService) {}
|
||||||
|
|
||||||
|
@Get(':serial/status')
|
||||||
|
getStatus(@Param('serial') serial: string) {
|
||||||
|
return this.deviceStatusService.getStatus(serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/device-status/device-status.module.ts
Normal file
9
backend/src/device-status/device-status.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DeviceStatusController } from './device-status.controller';
|
||||||
|
import { DeviceStatusService } from './device-status.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DeviceStatusController],
|
||||||
|
providers: [DeviceStatusService],
|
||||||
|
})
|
||||||
|
export class DeviceStatusModule {}
|
||||||
22
backend/src/device-status/device-status.service.ts
Normal file
22
backend/src/device-status/device-status.service.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeviceStatusService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// A device that has never registered (never sent data, never added in the
|
||||||
|
// UI) isn't disabled by anyone — treat it as active so first contact works.
|
||||||
|
async getStatus(serial: string) {
|
||||||
|
const device = await this.prisma.device.findUnique({
|
||||||
|
where: { serialNumber: serial },
|
||||||
|
select: { isActive: true, disabledReason: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
serial,
|
||||||
|
registered: device !== null,
|
||||||
|
disabled: device ? !device.isActive : false,
|
||||||
|
reason: device?.disabledReason ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,16 @@ export class DevicesService {
|
|||||||
|
|
||||||
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
|
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
|
||||||
await this.get(orgId, deviceId);
|
await this.get(orgId, deviceId);
|
||||||
return this.prisma.device.update({ where: { id: deviceId }, data: dto });
|
const { disabledReason, ...rest } = dto;
|
||||||
|
return this.prisma.device.update({
|
||||||
|
where: { id: deviceId },
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
// A reason only makes sense while disabled; re-enabling always clears it.
|
||||||
|
...(dto.isActive === true && { disabledReason: null }),
|
||||||
|
...(dto.isActive === false && { disabledReason: disabledReason ?? null }),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(orgId: string, deviceId: string) {
|
async remove(orgId: string, deviceId: string) {
|
||||||
|
|||||||
@@ -36,4 +36,11 @@ export class UpdateDeviceDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
// Only meaningful when disabling (isActive: false); cleared automatically
|
||||||
|
// on re-enable regardless of what's passed here.
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
disabledReason?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export class LogIngestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const locator = await this.locatorRegistry.resolve(job.orgId, serial);
|
const locator = await this.locatorRegistry.resolve(job.orgId, serial);
|
||||||
|
if (!locator.isActive) {
|
||||||
|
this.logger.warn(`Message from disabled device "${serial}" ignored`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'status') {
|
if (msg.type === 'status') {
|
||||||
this.realtime.publish(`job:${job.id}`, {
|
this.realtime.publish(`job:${job.id}`, {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface DeviceRow {
|
|||||||
serialNumber: string | null;
|
serialNumber: string | null;
|
||||||
mqttUsername: string | null;
|
mqttUsername: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
disabledReason: string | null;
|
||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const [devices, setDevices] = useState<DeviceRow[]>([]);
|
const [devices, setDevices] = useState<DeviceRow[]>([]);
|
||||||
const [form, setForm] = useState({ name: '', mqttUsername: '', serialNumber: '' });
|
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 [notice, setNotice] = useState<string | null>(null);
|
||||||
const [error, setError] = 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 {
|
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();
|
reload();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -87,7 +104,8 @@ export default function DevicesPage() {
|
|||||||
<h1>Devices</h1>
|
<h1>Devices</h1>
|
||||||
<p style={{ color: '#666' }}>
|
<p style={{ color: '#666' }}>
|
||||||
Field devices publish to <code>devices/<mqtt username>/points</code>. Broker credentials are provisioned
|
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>
|
</p>
|
||||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||||
{notice && <p style={{ color: '#388e3c' }}>{notice}</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 style={{ padding: '0.5rem' }}>{d.name}</td>
|
||||||
<td>{d.mqttUsername ? <code>{d.mqttUsername}</code> : '—'}</td>
|
<td>{d.mqttUsername ? <code>{d.mqttUsername}</code> : '—'}</td>
|
||||||
<td>{d.serialNumber ?? '—'}</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>
|
<td>{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}</td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
<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>
|
<button onClick={() => remove(d.id)}>Delete</button>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user