From f1c94e927973819eaf71dc579b0318361ba614f4 Mon Sep 17 00:00:00 2001 From: ulhub Date: Wed, 15 Jul 2026 15:11:11 +0000 Subject: [PATCH] Add remote device disable with reason, and a public device status check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//log ingestion, and only ever reveals a boolean plus a short reason string. The devices//log ingest path didn't check isActive at all (the devices//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 --- .../migration.sql | 2 + backend/prisma/schema.prisma | 3 + backend/src/app.module.ts | 2 + .../device-status/device-status.controller.ts | 16 +++++ .../src/device-status/device-status.module.ts | 9 +++ .../device-status/device-status.service.ts | 22 ++++++ backend/src/devices/devices.service.ts | 11 ++- backend/src/devices/dto/devices.dto.ts | 7 ++ backend/src/ingest/log-ingest.service.ts | 4 ++ web/pages/settings/devices.tsx | 71 +++++++++++++++++-- 10 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 backend/prisma/migrations/20260715000000_device_disabled_reason/migration.sql create mode 100644 backend/src/device-status/device-status.controller.ts create mode 100644 backend/src/device-status/device-status.module.ts create mode 100644 backend/src/device-status/device-status.service.ts diff --git a/backend/prisma/migrations/20260715000000_device_disabled_reason/migration.sql b/backend/prisma/migrations/20260715000000_device_disabled_reason/migration.sql new file mode 100644 index 0000000..029fe5f --- /dev/null +++ b/backend/prisma/migrations/20260715000000_device_disabled_reason/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "devices" ADD COLUMN "disabledReason" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f244ffb..b4dd14c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -138,6 +138,9 @@ model Device { serialNumber String? @unique mqttUsername String? @unique 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) createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8ad045b..874cfcd 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], diff --git a/backend/src/device-status/device-status.controller.ts b/backend/src/device-status/device-status.controller.ts new file mode 100644 index 0000000..cebe121 --- /dev/null +++ b/backend/src/device-status/device-status.controller.ts @@ -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//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); + } +} diff --git a/backend/src/device-status/device-status.module.ts b/backend/src/device-status/device-status.module.ts new file mode 100644 index 0000000..e2b5778 --- /dev/null +++ b/backend/src/device-status/device-status.module.ts @@ -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 {} diff --git a/backend/src/device-status/device-status.service.ts b/backend/src/device-status/device-status.service.ts new file mode 100644 index 0000000..29cd4de --- /dev/null +++ b/backend/src/device-status/device-status.service.ts @@ -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, + }; + } +} diff --git a/backend/src/devices/devices.service.ts b/backend/src/devices/devices.service.ts index 9197c6f..b04e5b0 100644 --- a/backend/src/devices/devices.service.ts +++ b/backend/src/devices/devices.service.ts @@ -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) { diff --git a/backend/src/devices/dto/devices.dto.ts b/backend/src/devices/dto/devices.dto.ts index 885b109..42ff63d 100644 --- a/backend/src/devices/dto/devices.dto.ts +++ b/backend/src/devices/dto/devices.dto.ts @@ -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; } diff --git a/backend/src/ingest/log-ingest.service.ts b/backend/src/ingest/log-ingest.service.ts index 9e613de..21773c4 100644 --- a/backend/src/ingest/log-ingest.service.ts +++ b/backend/src/ingest/log-ingest.service.ts @@ -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}`, { diff --git a/web/pages/settings/devices.tsx b/web/pages/settings/devices.tsx index a780dc3..12633e2 100644 --- a/web/pages/settings/devices.tsx +++ b/web/pages/settings/devices.tsx @@ -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([]); const [form, setForm] = useState({ name: '', mqttUsername: '', serialNumber: '' }); + const [disablingId, setDisablingId] = useState(null); + const [disableReason, setDisableReason] = useState(''); const [notice, setNotice] = useState(null); const [error, setError] = useState(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() {

Devices

Field devices publish to devices/<mqtt username>/points. Broker credentials are provisioned - separately for now. + separately for now. A disabled device can check GET /api/devices/<serial>/status for its + disabled state and reason.

{error &&

{error}

} {notice &&

{notice}

} @@ -134,16 +152,59 @@ export default function DevicesPage() { {d.name} {d.mqttUsername ? {d.mqttUsername} : '—'} {d.serialNumber ?? '—'} - {d.isActive ? 'active' : 'disabled'} + + {d.isActive ? 'active' : 'disabled'} + {!d.isActive && d.disabledReason && ( +
{d.disabledReason}
+ )} + {d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'} {isAdmin && ( - {' '} + {d.isActive ? ( + + ) : ( + + )}{' '} )} ))} + {isAdmin && + disablingId && + devices.some((d) => d.id === disablingId) && ( + + +
{ + e.preventDefault(); + confirmDisable(disablingId); + }} + style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }} + > + Reason for disabling {devices.find((d) => d.id === disablingId)?.name}: + setDisableReason(e.target.value)} + style={{ padding: '0.4rem', flex: 1 }} + /> + + +
+ + + )}