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:
@@ -12,6 +12,7 @@ import { IngestModule } from './ingest/ingest.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
import { SimModule } from './sim/sim.module';
|
||||
import { DeviceStatusModule } from './device-status/device-status.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { SimModule } from './sim/sim.module';
|
||||
RealtimeModule,
|
||||
ApiKeysModule,
|
||||
SimModule,
|
||||
DeviceStatusModule,
|
||||
],
|
||||
controllers: [AppController, StatusController],
|
||||
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) {
|
||||
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) {
|
||||
|
||||
@@ -36,4 +36,11 @@ export class UpdateDeviceDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
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);
|
||||
if (!locator.isActive) {
|
||||
this.logger.warn(`Message from disabled device "${serial}" ignored`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'status') {
|
||||
this.realtime.publish(`job:${job.id}`, {
|
||||
|
||||
Reference in New Issue
Block a user