Tie points to locator serial numbers and store receiver telemetry
Adds devices/<serial>/log as a job-anchored MQTT ingest path: a locator identifies itself by serial in the topic (auto-registered on first sight, org resolved from the job in the payload) rather than by a pre-provisioned broker credential. Device.serialNumber is now globally unique so the bare topic segment is enough to resolve identity. Locator lookup/auto-register logic is shared (LocatorRegistryService) between this and the existing devices/<mqttUsername>/points path. New /sim page (own header, outside the main app nav) simulates a transmitter: pick an open job or create one, set a serial number and telemetry defaults, and send points one at a time or on an interval along a simulated walking path. It calls a new authenticated backend endpoint (POST /orgs/:orgId/sim/publish) that publishes onto the real broker rather than writing the DB directly, so the simulator exercises the actual ingest pipeline end-to-end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
-- Locator serial numbers are globally unique (not per-org), so a bare
|
||||||
|
-- devices/<serial>/log MQTT topic can identify the device without org context.
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "devices_orgId_serialNumber_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "devices_serialNumber_key" ON "devices"("serialNumber");
|
||||||
@@ -132,7 +132,10 @@ model Device {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
name String
|
name String
|
||||||
serialNumber String?
|
// Globally unique (manufacturer serials aren't org-scoped in the real world) —
|
||||||
|
// this lets a bare devices/<serial>/log MQTT topic identify the device without
|
||||||
|
// any org context in the topic itself.
|
||||||
|
serialNumber String? @unique
|
||||||
mqttUsername String? @unique
|
mqttUsername String? @unique
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
lastSeenAt DateTime? @db.Timestamptz(6)
|
lastSeenAt DateTime? @db.Timestamptz(6)
|
||||||
@@ -142,7 +145,6 @@ model Device {
|
|||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
points LocatePoint[]
|
points LocatePoint[]
|
||||||
|
|
||||||
@@unique([orgId, serialNumber])
|
|
||||||
@@map("devices")
|
@@map("devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { DevicesModule } from './devices/devices.module';
|
|||||||
import { IngestModule } from './ingest/ingest.module';
|
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -23,6 +24,7 @@ import { ApiKeysModule } from './api-keys/api-keys.module';
|
|||||||
IngestModule,
|
IngestModule,
|
||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
ApiKeysModule,
|
ApiKeysModule,
|
||||||
|
SimModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, StatusController],
|
controllers: [AppController, StatusController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
if (dto.serialNumber) {
|
if (dto.serialNumber) {
|
||||||
const existing = await this.prisma.device.findUnique({
|
const existing = await this.prisma.device.findUnique({
|
||||||
where: { orgId_serialNumber: { orgId, serialNumber: dto.serialNumber } },
|
where: { serialNumber: dto.serialNumber },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException('A device with this serial number already exists in this organization');
|
throw new ConflictException('A device with this serial number already exists');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const device = await this.prisma.device.create({
|
const device = await this.prisma.device.create({
|
||||||
@@ -46,7 +46,8 @@ export class DevicesService {
|
|||||||
note: 'Broker credentials must be created separately (mosquitto_passwd) until dynamic broker auth lands.',
|
note: 'Broker credentials must be created separately (mosquitto_passwd) until dynamic broker auth lands.',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
note: `Relayed locator: points published by a gateway should carry "serial": "${device.serialNumber}".`,
|
logTopic: device.serialNumber ? `devices/${device.serialNumber}/log` : undefined,
|
||||||
|
note: `Relayed locator: points published by a gateway should carry "serial": "${device.serialNumber}", or a direct-connect locator can publish single readings to devices/${device.serialNumber}/log.`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ export class MqttPointsMessageDto {
|
|||||||
points: MqttPointDto[];
|
points: MqttPointDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-reading log entry for a locator that publishes directly (or is
|
||||||
|
// relayed) to devices/<serial>/log — the serial comes from the topic itself,
|
||||||
|
// so identity is job-anchored rather than publisher-credential-anchored.
|
||||||
|
export class MqttLogMessageDto extends MqttPointDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class MqttJobMessageDto {
|
export class MqttJobMessageDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { JobsIngestService } from './jobs-ingest.service';
|
import { JobsIngestService } from './jobs-ingest.service';
|
||||||
|
import { LogIngestService } from './log-ingest.service';
|
||||||
import { MqttClientService } from './mqtt-client.service';
|
import { MqttClientService } from './mqtt-client.service';
|
||||||
import { PointsIngestService } from './points-ingest.service';
|
import { PointsIngestService } from './points-ingest.service';
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export class IngestRouterService implements OnModuleInit {
|
|||||||
private readonly mqttClient: MqttClientService,
|
private readonly mqttClient: MqttClientService,
|
||||||
private readonly pointsIngest: PointsIngestService,
|
private readonly pointsIngest: PointsIngestService,
|
||||||
private readonly jobsIngest: JobsIngestService,
|
private readonly jobsIngest: JobsIngestService,
|
||||||
|
private readonly logIngest: LogIngestService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -27,19 +29,26 @@ export class IngestRouterService implements OnModuleInit {
|
|||||||
// Raw log captures every message on devices/#, matched or not (audit/debug trail)
|
// Raw log captures every message on devices/#, matched or not (audit/debug trail)
|
||||||
await this.prisma.deviceEvent.create({ data: { topic, payload } });
|
await this.prisma.deviceEvent.create({ data: { topic, payload } });
|
||||||
|
|
||||||
const [root, username, ...rest] = topic.split('/');
|
const [root, idSegment, ...rest] = topic.split('/');
|
||||||
const subtopic = rest.join('/');
|
const subtopic = rest.join('/');
|
||||||
if (root !== 'devices' || !username || subtopic === 'jobs/ack') {
|
if (root !== 'devices' || !idSegment || subtopic === 'jobs/ack') {
|
||||||
return; // not device traffic, or our own ack echoed back
|
return; // not device traffic, or our own ack echoed back
|
||||||
}
|
}
|
||||||
|
|
||||||
const device = await this.prisma.device.findUnique({ where: { mqttUsername: username } });
|
// devices/<serial>/log identifies the locator by serial in the topic
|
||||||
|
// itself (job in the payload supplies org) — no publisher device lookup.
|
||||||
|
if (subtopic === 'log') {
|
||||||
|
await this.logIngest.handle(idSegment, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await this.prisma.device.findUnique({ where: { mqttUsername: idSegment } });
|
||||||
if (!device) {
|
if (!device) {
|
||||||
this.logger.warn(`Message from unregistered device username "${username}" (raw-logged only)`);
|
this.logger.warn(`Message from unregistered device username "${idSegment}" (raw-logged only)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!device.isActive) {
|
if (!device.isActive) {
|
||||||
this.logger.warn(`Message from deactivated device "${username}" ignored`);
|
this.logger.warn(`Message from deactivated device "${idSegment}" ignored`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +66,7 @@ export class IngestRouterService implements OnModuleInit {
|
|||||||
case 'status':
|
case 'status':
|
||||||
break; // lastSeenAt already stamped above
|
break; // lastSeenAt already stamped above
|
||||||
default:
|
default:
|
||||||
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${username}`);
|
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${idSegment}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,21 @@ import { Module } from '@nestjs/common';
|
|||||||
import { RealtimeModule } from '../realtime/realtime.module';
|
import { RealtimeModule } from '../realtime/realtime.module';
|
||||||
import { IngestRouterService } from './ingest-router.service';
|
import { IngestRouterService } from './ingest-router.service';
|
||||||
import { JobsIngestService } from './jobs-ingest.service';
|
import { JobsIngestService } from './jobs-ingest.service';
|
||||||
|
import { LocatorRegistryService } from './locator-registry.service';
|
||||||
|
import { LogIngestService } from './log-ingest.service';
|
||||||
import { MqttClientService } from './mqtt-client.service';
|
import { MqttClientService } from './mqtt-client.service';
|
||||||
import { PointsIngestService } from './points-ingest.service';
|
import { PointsIngestService } from './points-ingest.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RealtimeModule],
|
imports: [RealtimeModule],
|
||||||
providers: [MqttClientService, IngestRouterService, PointsIngestService, JobsIngestService],
|
providers: [
|
||||||
|
MqttClientService,
|
||||||
|
IngestRouterService,
|
||||||
|
PointsIngestService,
|
||||||
|
JobsIngestService,
|
||||||
|
LogIngestService,
|
||||||
|
LocatorRegistryService,
|
||||||
|
],
|
||||||
|
exports: [MqttClientService],
|
||||||
})
|
})
|
||||||
export class IngestModule {}
|
export class IngestModule {}
|
||||||
|
|||||||
37
backend/src/ingest/locator-registry.service.ts
Normal file
37
backend/src/ingest/locator-registry.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Device } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocatorRegistryService {
|
||||||
|
private readonly logger = new Logger(LocatorRegistryService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// Serial numbers are globally unique, so a bare serial identifies a locator
|
||||||
|
// regardless of which org's data pipeline saw it first. Unknown serials are
|
||||||
|
// auto-registered under the given org so field data is never dropped.
|
||||||
|
async resolve(orgId: string, serial: string): Promise<Device> {
|
||||||
|
const existing = await this.prisma.device.findUnique({ where: { serialNumber: serial } });
|
||||||
|
if (existing) {
|
||||||
|
this.prisma.device
|
||||||
|
.update({ where: { id: existing.id }, data: { lastSeenAt: new Date() } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Auto-registering locator serial ${serial} under org ${orgId}`);
|
||||||
|
try {
|
||||||
|
return await this.prisma.device.create({
|
||||||
|
data: { orgId, name: `Locator ${serial}`, serialNumber: serial, lastSeenAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// lost a concurrent-registration race; the row exists now
|
||||||
|
const raced = await this.prisma.device.findUnique({ where: { serialNumber: serial } });
|
||||||
|
if (!raced) {
|
||||||
|
throw new Error(`Failed to resolve or create locator ${serial}`);
|
||||||
|
}
|
||||||
|
return raced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backend/src/ingest/log-ingest.service.ts
Normal file
74
backend/src/ingest/log-ingest.service.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { toPointDto } from '../points/points.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
|
import { MqttLogMessageDto } from './dto/mqtt-messages.dto';
|
||||||
|
import { LocatorRegistryService } from './locator-registry.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogIngestService {
|
||||||
|
private readonly logger = new Logger(LogIngestService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly realtime: RealtimeService,
|
||||||
|
private readonly locatorRegistry: LocatorRegistryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// devices/<serial>/log carries the locator's identity in the topic itself
|
||||||
|
// (no publisher device needs to be pre-registered); the job named in the
|
||||||
|
// payload supplies the org, since the topic alone doesn't identify one.
|
||||||
|
async handle(serial: string, rawPayload: string) {
|
||||||
|
const msg = plainToInstance(MqttLogMessageDto, JSON.parse(rawPayload) as object);
|
||||||
|
const errors = await validate(msg, { whitelist: true });
|
||||||
|
if (errors.length > 0) {
|
||||||
|
this.logger.warn(`Invalid log message from serial ${serial}: ${errors}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await this.prisma.job.findUnique({ where: { id: msg.jobId } });
|
||||||
|
if (!job) {
|
||||||
|
this.logger.warn(`Unknown jobId ${msg.jobId} in log message from serial ${serial}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locator = await this.locatorRegistry.resolve(job.orgId, serial);
|
||||||
|
|
||||||
|
const point = await this.prisma.locatePoint.create({
|
||||||
|
data: {
|
||||||
|
jobId: job.id,
|
||||||
|
deviceId: locator.id,
|
||||||
|
lat: msg.lat,
|
||||||
|
lng: msg.lng,
|
||||||
|
altitude: msg.alt,
|
||||||
|
utilityType: msg.utility,
|
||||||
|
sequence: msg.seq,
|
||||||
|
fixType: msg.fix,
|
||||||
|
hAccuracy: msg.hAcc,
|
||||||
|
vAccuracy: msg.vAcc,
|
||||||
|
satellites: msg.sats,
|
||||||
|
hdop: msg.hdop,
|
||||||
|
depth: msg.depth,
|
||||||
|
frequencyHz: msg.freqHz,
|
||||||
|
currentMa: msg.currentMa,
|
||||||
|
signalDb: msg.signalDb,
|
||||||
|
gainDb: msg.gainDb,
|
||||||
|
locateMode: msg.mode,
|
||||||
|
phaseDeg: msg.phaseDeg,
|
||||||
|
compassDeg: msg.compassDeg,
|
||||||
|
distortionPct: msg.distortionPct,
|
||||||
|
recordedAt: new Date(msg.ts),
|
||||||
|
raw: msg as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.realtime.publish(`job:${job.id}`, {
|
||||||
|
type: 'points',
|
||||||
|
jobId: job.id,
|
||||||
|
points: [toPointDto(point)],
|
||||||
|
});
|
||||||
|
this.logger.debug(`Stored 1 point from serial ${serial} for job ${job.ticketNumber}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { toPointDto } from '../points/points.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RealtimeService } from '../realtime/realtime.service';
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
import { MqttPointsMessageDto } from './dto/mqtt-messages.dto';
|
import { MqttPointsMessageDto } from './dto/mqtt-messages.dto';
|
||||||
|
import { LocatorRegistryService } from './locator-registry.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PointsIngestService {
|
export class PointsIngestService {
|
||||||
@@ -14,6 +15,7 @@ export class PointsIngestService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly realtime: RealtimeService,
|
private readonly realtime: RealtimeService,
|
||||||
|
private readonly locatorRegistry: LocatorRegistryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(device: Device, rawPayload: string) {
|
async handle(device: Device, rawPayload: string) {
|
||||||
@@ -33,7 +35,9 @@ export class PointsIngestService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const locator = await this.resolveLocator(device, msg.serial);
|
const locator = !msg.serial || msg.serial === device.serialNumber
|
||||||
|
? device
|
||||||
|
: await this.locatorRegistry.resolve(device.orgId, msg.serial);
|
||||||
|
|
||||||
const points = await this.prisma.locatePoint.createManyAndReturn({
|
const points = await this.prisma.locatePoint.createManyAndReturn({
|
||||||
data: msg.points.map((p) => ({
|
data: msg.points.map((p) => ({
|
||||||
@@ -71,42 +75,6 @@ export class PointsIngestService {
|
|||||||
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
|
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Points are attributed to the locator receiver named by its serial number.
|
|
||||||
// The publishing MQTT credential (a phone/gateway, possibly relaying for
|
|
||||||
// several locators) only establishes the org; unknown serials are
|
|
||||||
// auto-registered so field data is never dropped.
|
|
||||||
private async resolveLocator(publisher: Device, serial: string | undefined): Promise<Device> {
|
|
||||||
if (!serial || serial === publisher.serialNumber) {
|
|
||||||
return publisher;
|
|
||||||
}
|
|
||||||
const existing = await this.prisma.device.findUnique({
|
|
||||||
where: { orgId_serialNumber: { orgId: publisher.orgId, serialNumber: serial } },
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
this.prisma.device
|
|
||||||
.update({ where: { id: existing.id }, data: { lastSeenAt: new Date() } })
|
|
||||||
.catch(() => undefined);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
this.logger.log(`Auto-registering locator serial ${serial} (via ${publisher.mqttUsername})`);
|
|
||||||
try {
|
|
||||||
return await this.prisma.device.create({
|
|
||||||
data: {
|
|
||||||
orgId: publisher.orgId,
|
|
||||||
name: `Locator ${serial}`,
|
|
||||||
serialNumber: serial,
|
|
||||||
lastSeenAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// lost a concurrent-registration race; the row exists now
|
|
||||||
const raced = await this.prisma.device.findUnique({
|
|
||||||
where: { orgId_serialNumber: { orgId: publisher.orgId, serialNumber: serial } },
|
|
||||||
});
|
|
||||||
return raced ?? publisher;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveJob(device: Device, msg: MqttPointsMessageDto): Promise<Job | null> {
|
private async resolveJob(device: Device, msg: MqttPointsMessageDto): Promise<Job | null> {
|
||||||
if (msg.jobId) {
|
if (msg.jobId) {
|
||||||
const job = await this.prisma.job.findFirst({
|
const job = await this.prisma.job.findFirst({
|
||||||
|
|||||||
17
backend/src/sim/dto/sim.dto.ts
Normal file
17
backend/src/sim/dto/sim.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { MqttPointDto } from '../../ingest/dto/mqtt-messages.dto';
|
||||||
|
|
||||||
|
// What the simulator UI sends the backend. Mirrors a devices/<serial>/log
|
||||||
|
// payload plus the two fields that live in the topic/routing rather than the
|
||||||
|
// wire payload itself (serial, jobId) so this DTO doubles as validation for
|
||||||
|
// both the REST call and the outgoing MQTT message.
|
||||||
|
export class SimPublishPointDto extends MqttPointDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64)
|
||||||
|
serial: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
19
backend/src/sim/sim.controller.ts
Normal file
19
backend/src/sim/sim.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/auth.guard';
|
||||||
|
import { OrgRolesGuard } from '../auth/guards/org-roles.guard';
|
||||||
|
import { SimPublishPointDto } from './dto/sim.dto';
|
||||||
|
import { SimService } from './sim.service';
|
||||||
|
|
||||||
|
// JWT-only: this is a UI-driven testing tool, not a public integration surface.
|
||||||
|
@Controller('orgs/:orgId/sim')
|
||||||
|
@UseGuards(JwtAuthGuard, OrgRolesGuard)
|
||||||
|
@Roles('MEMBER')
|
||||||
|
export class SimController {
|
||||||
|
constructor(private readonly simService: SimService) {}
|
||||||
|
|
||||||
|
@Post('publish')
|
||||||
|
publish(@Param('orgId') orgId: string, @Body() dto: SimPublishPointDto) {
|
||||||
|
return this.simService.publish(orgId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/sim/sim.module.ts
Normal file
11
backend/src/sim/sim.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IngestModule } from '../ingest/ingest.module';
|
||||||
|
import { SimController } from './sim.controller';
|
||||||
|
import { SimService } from './sim.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [IngestModule],
|
||||||
|
controllers: [SimController],
|
||||||
|
providers: [SimService],
|
||||||
|
})
|
||||||
|
export class SimModule {}
|
||||||
25
backend/src/sim/sim.service.ts
Normal file
25
backend/src/sim/sim.service.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { MqttClientService } from '../ingest/mqtt-client.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SimPublishPointDto } from './dto/sim.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly mqttClient: MqttClientService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Publishes onto the real broker rather than writing to the DB directly, so
|
||||||
|
// the simulator exercises the exact same ingest path a real locator would.
|
||||||
|
async publish(orgId: string, dto: SimPublishPointDto) {
|
||||||
|
const job = await this.prisma.job.findFirst({ where: { id: dto.jobId, orgId }, select: { id: true } });
|
||||||
|
if (!job) {
|
||||||
|
throw new NotFoundException('Job not found in this organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serial, ...payload } = dto;
|
||||||
|
this.mqttClient.publish(`devices/${serial}/log`, payload);
|
||||||
|
return { ok: true, topic: `devices/${serial}/log` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@ user admin
|
|||||||
topic readwrite #
|
topic readwrite #
|
||||||
topic readwrite $SYS/#
|
topic readwrite $SYS/#
|
||||||
|
|
||||||
# Backend service: reads all device traffic, writes job acks back to devices
|
# Backend service: reads all device traffic, writes job acks back to devices,
|
||||||
|
# and publishes on behalf of the /sim simulator tool (devices/<serial>/log)
|
||||||
user backend
|
user backend
|
||||||
topic read devices/#
|
topic read devices/#
|
||||||
topic write devices/+/jobs/ack
|
topic write devices/+/jobs/ack
|
||||||
|
topic write devices/+/log
|
||||||
|
|
||||||
# testuser is a demo *device*: only the per-device pattern rule above applies
|
# testuser is a demo *device*: only the per-device pattern rule above applies
|
||||||
|
|||||||
428
web/pages/sim/index.tsx
Normal file
428
web/pages/sim/index.tsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { useRequireAuth } from '../../lib/auth-context';
|
||||||
|
|
||||||
|
interface JobOption {
|
||||||
|
id: string;
|
||||||
|
ticketNumber: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentEntry {
|
||||||
|
at: string;
|
||||||
|
seq: number;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UTILITIES = ['GAS', 'WATER', 'ELECTRIC', 'SEWER', 'TELECOM', 'CATV', 'FIBER', 'STEAM', 'UNKNOWN'];
|
||||||
|
const FIX_TYPES = ['FIXED_RTK', 'FLOAT_RTK', 'DGPS', 'AUTONOMOUS', 'NONE'];
|
||||||
|
const LOCATE_MODES = ['PEAK', 'NULL', 'BROAD_PEAK', 'SONDE'];
|
||||||
|
|
||||||
|
const SERIAL_KEY = 'ulhub_sim_serial';
|
||||||
|
|
||||||
|
function jitter(base: number, spread: number): number {
|
||||||
|
return Math.round((base + (Math.random() * 2 - 1) * spread) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advances a lat/lng by `distanceM` meters along `headingDeg` (0 = north, 90 = east)
|
||||||
|
function step(lat: number, lng: number, headingDeg: number, distanceM: number) {
|
||||||
|
const rad = (headingDeg * Math.PI) / 180;
|
||||||
|
const dLat = (distanceM * Math.cos(rad)) / 111320;
|
||||||
|
const dLng = (distanceM * Math.sin(rad)) / (111320 * Math.cos((lat * Math.PI) / 180));
|
||||||
|
return { lat: lat + dLat, lng: lng + dLng };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SimulatorPage() {
|
||||||
|
const { user, activeOrg, loading } = useRequireAuth();
|
||||||
|
const orgId = activeOrg?.org.id ?? null;
|
||||||
|
|
||||||
|
const [jobs, setJobs] = useState<JobOption[]>([]);
|
||||||
|
const [jobId, setJobId] = useState('');
|
||||||
|
const [creatingJob, setCreatingJob] = useState(false);
|
||||||
|
const [newTicket, setNewTicket] = useState('');
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
|
||||||
|
const [serial, setSerial] = useState('');
|
||||||
|
const [utility, setUtility] = useState('GAS');
|
||||||
|
const [fixType, setFixType] = useState('FIXED_RTK');
|
||||||
|
const [locateMode, setLocateMode] = useState('PEAK');
|
||||||
|
const [depth, setDepth] = useState(1.2);
|
||||||
|
const [frequencyHz, setFrequencyHz] = useState(33000);
|
||||||
|
const [currentMa, setCurrentMa] = useState(45);
|
||||||
|
const [originLat, setOriginLat] = useState(33.15012345);
|
||||||
|
const [originLng, setOriginLng] = useState(-96.83512345);
|
||||||
|
const [headingDeg, setHeadingDeg] = useState(45);
|
||||||
|
const [stepMeters, setStepMeters] = useState(1.5);
|
||||||
|
const [intervalSec, setIntervalSec] = useState(2);
|
||||||
|
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [autoSending, setAutoSending] = useState(false);
|
||||||
|
const [sent, setSent] = useState<SentEntry[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(SERIAL_KEY);
|
||||||
|
setSerial(stored || `SIM-${Math.floor(1000 + Math.random() * 9000)}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orgId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.get<{ jobs: JobOption[] }>(`/api/orgs/${orgId}/jobs?limit=100`)
|
||||||
|
.then((res) => {
|
||||||
|
const openish = res.jobs.filter((j) => j.status === 'OPEN' || j.status === 'IN_PROGRESS');
|
||||||
|
setJobs(openish);
|
||||||
|
if (!jobId && openish[0]) {
|
||||||
|
setJobId(openish[0].id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [orgId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// setInterval captures whatever closure exists when it's created; routing
|
||||||
|
// every tick through a ref that's refreshed each render keeps it reading
|
||||||
|
// current state (count, serial, path config, …) instead of stale values.
|
||||||
|
const sendPointRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
const createJob = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!orgId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const job = await api.post<JobOption>(`/api/orgs/${orgId}/jobs`, {
|
||||||
|
ticketNumber: newTicket,
|
||||||
|
title: newTitle,
|
||||||
|
});
|
||||||
|
setJobs((j) => [{ ...job }, ...j]);
|
||||||
|
setJobId(job.id);
|
||||||
|
setCreatingJob(false);
|
||||||
|
setNewTicket('');
|
||||||
|
setNewTitle('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendPoint = async () => {
|
||||||
|
if (!orgId || !jobId || !serial) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem(SERIAL_KEY, serial);
|
||||||
|
const pos = step(originLat, originLng, headingDeg, stepMeters * count);
|
||||||
|
const seq = count + 1;
|
||||||
|
const pointDepth = Math.max(0.1, jitter(depth, 0.15));
|
||||||
|
try {
|
||||||
|
await api.post(`/api/orgs/${orgId}/sim/publish`, {
|
||||||
|
serial,
|
||||||
|
jobId,
|
||||||
|
lat: pos.lat,
|
||||||
|
lng: pos.lng,
|
||||||
|
fix: fixType,
|
||||||
|
hAcc: 0.014,
|
||||||
|
vAcc: 0.02,
|
||||||
|
sats: Math.round(jitter(22, 3)),
|
||||||
|
hdop: 0.7,
|
||||||
|
depth: pointDepth,
|
||||||
|
freqHz: frequencyHz,
|
||||||
|
currentMa: Math.max(0, jitter(currentMa, 3)),
|
||||||
|
signalDb: jitter(60, 5),
|
||||||
|
gainDb: 40,
|
||||||
|
mode: locateMode,
|
||||||
|
compassDeg: jitter(headingDeg, 4),
|
||||||
|
distortionPct: Math.max(0, jitter(4, 2)),
|
||||||
|
utility,
|
||||||
|
seq,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setCount(seq);
|
||||||
|
setSent((prev) => [{ at: new Date().toLocaleTimeString(), seq, lat: pos.lat, lng: pos.lng, depth: pointDepth }, ...prev].slice(0, 25));
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
setAutoSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sendPointRef.current = sendPoint;
|
||||||
|
|
||||||
|
const toggleAutoSend = () => {
|
||||||
|
if (autoSending) {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
setAutoSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAutoSending(true);
|
||||||
|
sendPointRef.current();
|
||||||
|
timerRef.current = setInterval(() => sendPointRef.current(), intervalSec * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
setAutoSending(false);
|
||||||
|
setCount(0);
|
||||||
|
setSent([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedJob = jobs.find((j) => j.id === jobId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: 'sans-serif', minHeight: '100vh', background: '#fafafa' }}>
|
||||||
|
<Head>
|
||||||
|
<title>Transmitter Simulator · UlHub</title>
|
||||||
|
</Head>
|
||||||
|
<header style={{ padding: '0.75rem 1.5rem', borderBottom: '1px solid #ddd', background: '#212121', color: '#fff' }}>
|
||||||
|
<strong>UlHub</strong> <span style={{ opacity: 0.7 }}>· Transmitter Simulator</span>
|
||||||
|
<Link href="/" style={{ float: 'right', color: '#9cc9ff' }}>
|
||||||
|
← Back to app
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main style={{ maxWidth: 720, margin: '0 auto', padding: '1.5rem' }}>
|
||||||
|
<p style={{ color: '#666' }}>
|
||||||
|
Simulates a locator receiver publishing GPS + telemetry to the MQTT broker at{' '}
|
||||||
|
<code>devices/{serial || '<serial>'}/log</code>, exactly as a real device would.
|
||||||
|
</p>
|
||||||
|
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||||
|
|
||||||
|
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
||||||
|
<legend>Job</legend>
|
||||||
|
{!creatingJob ? (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<select value={jobId} onChange={(e) => setJobId(e.target.value)} style={{ flex: 1, padding: '0.4rem' }}>
|
||||||
|
{jobs.length === 0 && <option value="">No open jobs</option>}
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<option key={j.id} value={j.id}>
|
||||||
|
{j.ticketNumber} — {j.title} ({j.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={() => setCreatingJob(true)}>
|
||||||
|
+ New job
|
||||||
|
</button>
|
||||||
|
{selectedJob && (
|
||||||
|
<Link href={`/jobs/${selectedJob.id}`} target="_blank">
|
||||||
|
View map ↗
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={createJob} style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
placeholder="ticket number"
|
||||||
|
value={newTicket}
|
||||||
|
onChange={(e) => setNewTicket(e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ padding: '0.4rem' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="title"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
required
|
||||||
|
style={{ padding: '0.4rem', flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
<button type="button" onClick={() => setCreatingJob(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
||||||
|
<legend>Transmitter</legend>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
|
<label>
|
||||||
|
Serial number
|
||||||
|
<input value={serial} onChange={(e) => setSerial(e.target.value)} style={{ width: '100%', padding: '0.4rem' }} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Utility type
|
||||||
|
<select value={utility} onChange={(e) => setUtility(e.target.value)} style={{ width: '100%', padding: '0.4rem' }}>
|
||||||
|
{UTILITIES.map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{u}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
GPS fix type
|
||||||
|
<select value={fixType} onChange={(e) => setFixType(e.target.value)} style={{ width: '100%', padding: '0.4rem' }}>
|
||||||
|
{FIX_TYPES.map((f) => (
|
||||||
|
<option key={f} value={f}>
|
||||||
|
{f}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Locate mode
|
||||||
|
<select value={locateMode} onChange={(e) => setLocateMode(e.target.value)} style={{ width: '100%', padding: '0.4rem' }}>
|
||||||
|
{LOCATE_MODES.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Depth (m)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={depth}
|
||||||
|
onChange={(e) => setDepth(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', padding: '0.4rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Frequency (Hz)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={frequencyHz}
|
||||||
|
onChange={(e) => setFrequencyHz(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', padding: '0.4rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Current (mA)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={currentMa}
|
||||||
|
onChange={(e) => setCurrentMa(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', padding: '0.4rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
||||||
|
<legend>Path</legend>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '0.75rem' }}>
|
||||||
|
<label>
|
||||||
|
Start lat
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0000001"
|
||||||
|
value={originLat}
|
||||||
|
onChange={(e) => setOriginLat(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', padding: '0.4rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Start lng
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0000001"
|
||||||
|
value={originLng}
|
||||||
|
onChange={(e) => setOriginLng(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', padding: '0.4rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Heading (°)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={headingDeg}
|
||||||
|
onChange={(e) => setHeadingDeg(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', padding: '0.4rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Step (m)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={stepMeters}
|
||||||
|
onChange={(e) => setStepMeters(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', padding: '0.4rem' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||||
|
<button onClick={sendPoint} disabled={!jobId || autoSending} style={{ padding: '0.6rem 1rem' }}>
|
||||||
|
Send point
|
||||||
|
</button>
|
||||||
|
<button onClick={toggleAutoSend} disabled={!jobId} style={{ padding: '0.6rem 1rem' }}>
|
||||||
|
{autoSending ? 'Stop auto-send' : 'Start auto-send'}
|
||||||
|
</button>
|
||||||
|
<label>
|
||||||
|
every{' '}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={intervalSec}
|
||||||
|
onChange={(e) => setIntervalSec(Number(e.target.value))}
|
||||||
|
style={{ width: 50, padding: '0.3rem' }}
|
||||||
|
/>{' '}
|
||||||
|
s
|
||||||
|
</label>
|
||||||
|
<button onClick={reset} style={{ marginLeft: 'auto' }}>
|
||||||
|
Reset path
|
||||||
|
</button>
|
||||||
|
<span style={{ color: '#666' }}>{count} sent this session</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||||
|
<th style={{ padding: '0.4rem' }}>Time</th>
|
||||||
|
<th>Seq</th>
|
||||||
|
<th>Lat</th>
|
||||||
|
<th>Lng</th>
|
||||||
|
<th>Depth</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sent.map((s) => (
|
||||||
|
<tr key={s.seq} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: '0.4rem' }}>{s.at}</td>
|
||||||
|
<td>{s.seq}</td>
|
||||||
|
<td>{s.lat.toFixed(7)}</td>
|
||||||
|
<td>{s.lng.toFixed(7)}</td>
|
||||||
|
<td>{s.depth} m</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{sent.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} style={{ padding: '1.5rem', textAlign: 'center', color: '#888' }}>
|
||||||
|
No points sent yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user