Compare commits
9 Commits
9eefe50401
...
backend/s2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081ff485fa | ||
|
|
d0b2f28762 | ||
|
|
e552ae4e5c | ||
|
|
8aea87c3cd | ||
|
|
8f638bfa4f | ||
|
|
9ffe021354 | ||
|
|
b66ae2cc47 | ||
|
|
8bcc12ec96 | ||
|
|
daa3407b9d |
@@ -8,14 +8,31 @@ describes from a usage/dashboard perspective.
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The broker (`eclipse-mosquitto`, service `mqtt` / container `ul-hub-mqtt`)
|
The broker (`eclipse-mosquitto`, service `mqtt` / container `ul-hub-mqtt`)
|
||||||
exposes four listeners, each with a different trust model:
|
exposes three listeners, each with a different trust model:
|
||||||
|
|
||||||
| Port | Protocol | Auth | Who it's for |
|
| Port | Protocol | Auth | Who it's for |
|
||||||
|------|----------|------|---------------|
|
|------|----------|------|---------------|
|
||||||
| `1883` | MQTT (plaintext) | username/password | internal services (e.g. the Laravel subscriber, Python publisher) |
|
| `1883` | MQTT (plaintext, Compose network only) | username/password | internal backend service; not host-published |
|
||||||
| `9001` (mapped to host `9005`) | MQTT over WebSocket | none (anonymous) | browser clients (dashboard) |
|
|
||||||
| `8883` | MQTT over TLS | **client certificate** | field devices |
|
| `8883` | MQTT over TLS | **client certificate** | field devices |
|
||||||
| `8884` | MQTT over TLS | username/password (server cert only) | administrators |
|
| `443` (`/mqtt` → loopback `9001`) | MQTT over WSS/TLS | username/password | scoped app clients |
|
||||||
|
| `8884` | MQTT over TLS | username/password (server cert only) | scoped app clients and administrators on networks that expose the raw port |
|
||||||
|
|
||||||
|
The former anonymous WebSocket listener on `9001` is now authenticated, uses the same ACL as the
|
||||||
|
TLS listeners, and is bound to host loopback only. Nginx exposes it as WSS at `/mqtt` on port 443;
|
||||||
|
the web portal itself continues to receive live updates from the backend.
|
||||||
|
|
||||||
|
The TLS listeners use `mosquitto/certs/public-fullchain.pem` and
|
||||||
|
`mosquitto/certs/public-privkey.pem`, copied from the host's Let's Encrypt certificate during
|
||||||
|
deployment with owner `1883:1883` and mode `0600`. These files are deployment secrets/artifacts
|
||||||
|
and are excluded from Git.
|
||||||
|
|
||||||
|
The live password database is likewise outside Git at
|
||||||
|
`/home/ubuntu/.config/ul-platform/mosquitto.passwd`, bind-mounted read-only as
|
||||||
|
`/run/secrets/mosquitto_passwd`. The deployed ACL is copied to
|
||||||
|
`/home/ubuntu/.config/ul-platform/mosquitto.acl` and bind-mounted beside it. Both files are owned
|
||||||
|
by broker uid/gid 1883 with mode 0600. Provisioning updates the host password file and restarts Mosquitto; the
|
||||||
|
tracked `mosquitto/config/passwd` is only a legacy/bootstrap sample and must not receive new
|
||||||
|
organization credentials.
|
||||||
|
|
||||||
Device authentication happens on **port 8883**. A device presents a client
|
Device authentication happens on **port 8883**. A device presents a client
|
||||||
certificate signed by the app's own Certificate Authority (CA); Mosquitto
|
certificate signed by the app's own Certificate Authority (CA); Mosquitto
|
||||||
@@ -123,6 +140,10 @@ since they all share `acl_file /mosquitto/config/devices.acl`:
|
|||||||
# Certificate CN becomes the MQTT username — restrict each device to its own namespace.
|
# Certificate CN becomes the MQTT username — restrict each device to its own namespace.
|
||||||
pattern readwrite devices/%u/#
|
pattern readwrite devices/%u/#
|
||||||
|
|
||||||
|
# App username is orgId; app clients may publish durable points and read acks only in that org.
|
||||||
|
pattern write ul/%u/app/+/log/points
|
||||||
|
pattern read ul/%u/app/+/ack
|
||||||
|
|
||||||
# Admin: <username>
|
# Admin: <username>
|
||||||
user <username>
|
user <username>
|
||||||
topic readwrite #
|
topic readwrite #
|
||||||
@@ -169,9 +190,9 @@ Managed via the same `/certificates` page:
|
|||||||
| Client | Listener | Auth | Can publish/subscribe |
|
| Client | Listener | Auth | Can publish/subscribe |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Field device (cert CN = serial) | 8883 (TLS) | client cert | `devices/<serial>/#` only |
|
| Field device (cert CN = serial) | 8883 (TLS) | client cert | `devices/<serial>/#` only |
|
||||||
|
| App (username = orgId) | 8884 (TLS) | scoped per-org username/password | publish `ul/<orgId>/app/+/log/points`; read `ul/<orgId>/app/+/ack` |
|
||||||
| Internal service (e.g. subscriber) | 1883 | username/password | depends on ACL entry for that username — none defined by default beyond `devices/%u/#`, so a plain username with no matching device row is effectively scoped to `devices/<username>/#` too, unless added as an admin |
|
| Internal service (e.g. subscriber) | 1883 | username/password | depends on ACL entry for that username — none defined by default beyond `devices/%u/#`, so a plain username with no matching device row is effectively scoped to `devices/<username>/#` too, unless added as an admin |
|
||||||
| Admin | 8884 (TLS) or 1883 | username/password | `#` and `$SYS/#` (everything) |
|
| Admin | 8884 (TLS) or 1883 | username/password | `#` and `$SYS/#` (everything) |
|
||||||
| Browser dashboard | 9001/9005 (WebSocket) | anonymous | no ACL applied — `allow_anonymous true`, so effectively unrestricted; treat this listener as untrusted/read-only in front-end code |
|
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
@@ -180,5 +201,5 @@ Managed via the same `/certificates` page:
|
|||||||
needs to be production-grade.
|
needs to be production-grade.
|
||||||
- No automatic reload of `mosquitto.conf`/ACL/passwd changes — every
|
- No automatic reload of `mosquitto.conf`/ACL/passwd changes — every
|
||||||
provisioning action requires a manual `docker compose restart mqtt`.
|
provisioning action requires a manual `docker compose restart mqtt`.
|
||||||
- The WebSocket listener (9001) is fully anonymous with no ACL, so anything
|
- Sprint 2's per-org app credential permits ack visibility across clients in the same org. This
|
||||||
reachable on port 9005 should be treated as public.
|
accepted interim limitation is removed when OIDC-derived per-client broker identity lands.
|
||||||
|
|||||||
16
backend/jest.config.js
Normal file
16
backend/jest.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Jest config for the UlHub backend (NestJS).
|
||||||
|
* ts-jest transpiles the TypeScript sources; decorators/metadata are honored via tsconfig.
|
||||||
|
* Specs live next to sources as *.spec.ts (unit) — e2e would go under test/ with a separate config.
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
|
rootDir: 'src',
|
||||||
|
testRegex: '.*\\.spec\\.ts$',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/../tsconfig.json' }],
|
||||||
|
},
|
||||||
|
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||||
|
coverageDirectory: '../coverage',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
};
|
||||||
3527
backend/package-lock.json
generated
3527
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,9 @@
|
|||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
"db:seed": "ts-node prisma/seed.ts"
|
"db:seed": "ts-node prisma/seed.ts",
|
||||||
|
"test": "jest",
|
||||||
|
"test:ci": "jest --ci --runInBand"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "ts-node prisma/seed.ts"
|
||||||
@@ -44,9 +46,12 @@
|
|||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
"prisma": "^6.10.0",
|
"prisma": "^6.10.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- App-path ingest idempotency + provenance (Sprint 2, S2-a; SRS-SYN-2/7, §3.4.2).
|
||||||
|
-- Adds the UUIDv7 idempotency key and forward-compat provenance tags to locate_points.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PointOrigin" AS ENUM ('APP', 'LOCATOR', 'MAGLINK');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PointUploadPath" AS ENUM ('APP_MQTT', 'DEVICE_MQTT', 'REST_BATCH');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "locate_points" ADD COLUMN "pointId" TEXT,
|
||||||
|
ADD COLUMN "origin" "PointOrigin" NOT NULL DEFAULT 'LOCATOR',
|
||||||
|
ADD COLUMN "uploadPath" "PointUploadPath",
|
||||||
|
ADD COLUMN "originClientId" TEXT,
|
||||||
|
ADD COLUMN "createdAt" TIMESTAMPTZ(6);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "locate_points_pointId_key" ON "locate_points"("pointId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "locate_points_jobId_createdAt_idx" ON "locate_points"("jobId", "createdAt");
|
||||||
@@ -53,6 +53,22 @@ enum LocateMode {
|
|||||||
SONDE
|
SONDE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which producer a point originated from (telemetry-schema.md `origin`). Tagged now
|
||||||
|
// for SYN-6 path arbitration; no arbitration LOGIC runs yet (deferred, sprint-2 scope).
|
||||||
|
enum PointOrigin {
|
||||||
|
APP
|
||||||
|
LOCATOR
|
||||||
|
MAGLINK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which transport carried the point to the cloud (telemetry-schema.md `uploadPath`).
|
||||||
|
// Same forward-compat tagging as PointOrigin.
|
||||||
|
enum PointUploadPath {
|
||||||
|
APP_MQTT
|
||||||
|
DEVICE_MQTT
|
||||||
|
REST_BATCH
|
||||||
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -185,6 +201,25 @@ model LocatePoint {
|
|||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
jobId String
|
jobId String
|
||||||
deviceId String?
|
deviceId String?
|
||||||
|
|
||||||
|
// Client-generated UUIDv7, the idempotency key across every upload path
|
||||||
|
// (app MQTT, device-direct MQTT, REST batch — SRS §3.4.2 / telemetry-schema.md).
|
||||||
|
// A replayed UUID is ingested exactly once (unique constraint below). Nullable:
|
||||||
|
// the legacy device-direct path (LogIngest/PointsIngest) predates it and does not
|
||||||
|
// supply one yet; the app path (SYN-2) always does.
|
||||||
|
pointId String? @unique
|
||||||
|
|
||||||
|
// Provenance tags (telemetry-schema.md). Set on the app path now; carried for
|
||||||
|
// forward-compat with SYN-6 arbitration, which does not run yet.
|
||||||
|
origin PointOrigin @default(LOCATOR)
|
||||||
|
uploadPath PointUploadPath?
|
||||||
|
originClientId String?
|
||||||
|
|
||||||
|
// Event time asserted by the producer (record creation, telemetry-schema.md
|
||||||
|
// `createdAt`). Ingest orders and dedups by this, NOT by arrival (`receivedAt`),
|
||||||
|
// so replays and out-of-order delivery converge to the same stored ordering.
|
||||||
|
createdAt DateTime? @db.Timestamptz(6)
|
||||||
|
|
||||||
lat Decimal @db.Decimal(10, 8)
|
lat Decimal @db.Decimal(10, 8)
|
||||||
lng Decimal @db.Decimal(11, 8)
|
lng Decimal @db.Decimal(11, 8)
|
||||||
altitude Decimal? @db.Decimal(8, 3)
|
altitude Decimal? @db.Decimal(8, 3)
|
||||||
@@ -217,7 +252,11 @@ model LocatePoint {
|
|||||||
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||||
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
|
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
// (jobId, recordedAt) serves GPS-time reads; (jobId, createdAt) serves the
|
||||||
|
// event-time ordering the app path and portal read use (SRS-SYN-2). pointId is
|
||||||
|
// already uniquely indexed above — that index also backs the dedup lookup.
|
||||||
@@index([jobId, recordedAt])
|
@@index([jobId, recordedAt])
|
||||||
|
@@index([jobId, createdAt])
|
||||||
@@map("locate_points")
|
@@map("locate_points")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
27
backend/src/app.service.spec.ts
Normal file
27
backend/src/app.service.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
// Smoke test for the QA gate (S1-f, trace: SRS §6 gate / NFR-8).
|
||||||
|
// Exercises Nest DI wiring end-to-end for a pure service so a green run proves the
|
||||||
|
// toolchain (ts-jest + @nestjs/testing) is functional. Real coverage grows from here.
|
||||||
|
describe('AppService (smoke)', () => {
|
||||||
|
let service: AppService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get<AppService>(AppService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is resolvable from the DI container', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the API welcome payload', () => {
|
||||||
|
expect(service.getHello()).toEqual({
|
||||||
|
message: 'Welcome to UlHub API',
|
||||||
|
docs: 'GET /api',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
315
backend/src/ingest/app-log-ingest.service.spec.ts
Normal file
315
backend/src/ingest/app-log-ingest.service.spec.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RealtimeService } from '../realtime/realtime.service';
|
||||||
|
import { AppLogAck } from './dto/app-messages.dto';
|
||||||
|
import { AppLogIngestService } from './app-log-ingest.service';
|
||||||
|
import { MqttClientService } from './mqtt-client.service';
|
||||||
|
|
||||||
|
// Unit coverage for the durable app-log ingest path (S2-a, SRS-SYN-2/7, §3.4.2).
|
||||||
|
// Prisma is an in-memory fake keyed on pointId so idempotency is exercised for real
|
||||||
|
// (a replayed UUID must be stored exactly once), without a database. The broker is a
|
||||||
|
// spy so we can assert the application-level ack shape and topic.
|
||||||
|
describe('AppLogIngestService', () => {
|
||||||
|
const ORG = 'org_alpha';
|
||||||
|
const CLIENT = 'app_client_1';
|
||||||
|
const JOB = { id: 'job_1', orgId: ORG, ticketNumber: 'T-1' };
|
||||||
|
|
||||||
|
let service: AppLogIngestService;
|
||||||
|
let published: { topic: string; payload: AppLogAck }[];
|
||||||
|
let store: Map<string, { pointId: string; jobId: string; createdAt: Date }>;
|
||||||
|
|
||||||
|
const point = (
|
||||||
|
pointId: string,
|
||||||
|
createdAt: string,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
pointId,
|
||||||
|
createdAt,
|
||||||
|
origin: 'APP',
|
||||||
|
uploadPath: 'APP_MQTT',
|
||||||
|
lat: 40,
|
||||||
|
lng: -80,
|
||||||
|
ts: createdAt,
|
||||||
|
qualityFlag: 'IN_SPEC',
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const msg = (
|
||||||
|
points: object[],
|
||||||
|
job: { jobId?: string; ticket?: string } = { jobId: JOB.id },
|
||||||
|
) => JSON.stringify({ schemaVersion: '1', ...job, points });
|
||||||
|
|
||||||
|
const lastAck = () => published[published.length - 1].payload;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
published = [];
|
||||||
|
store = new Map();
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
organization: {
|
||||||
|
findUnique: jest.fn(({ where }: { where: { id: string } }) =>
|
||||||
|
Promise.resolve(where.id === ORG ? { id: ORG } : null),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
job: {
|
||||||
|
findFirst: jest.fn(
|
||||||
|
({ where }: { where: { id: string; orgId: string } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
where.id === JOB.id && where.orgId === ORG ? JOB : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
findUnique: jest.fn(() => Promise.resolve(null)),
|
||||||
|
create: jest.fn(() => Promise.resolve(JOB)),
|
||||||
|
},
|
||||||
|
locatePoint: {
|
||||||
|
findMany: jest.fn(
|
||||||
|
({ where }: { where: { pointId: { in: string[] } } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
where.pointId.in
|
||||||
|
.filter((id) => store.has(id))
|
||||||
|
.map((pointId) => ({ pointId })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createManyAndReturn: jest.fn(
|
||||||
|
({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: { pointId: string; jobId: string; createdAt: Date }[];
|
||||||
|
}) => {
|
||||||
|
const inserted: unknown[] = [];
|
||||||
|
for (const row of data) {
|
||||||
|
if (store.has(row.pointId)) {
|
||||||
|
continue; // skipDuplicates
|
||||||
|
}
|
||||||
|
store.set(row.pointId, row);
|
||||||
|
inserted.push({
|
||||||
|
...row,
|
||||||
|
id: BigInt(store.size),
|
||||||
|
lat: 40,
|
||||||
|
lng: -80,
|
||||||
|
altitude: null,
|
||||||
|
utilityType: 'UNKNOWN',
|
||||||
|
sequence: null,
|
||||||
|
fixType: 'NONE',
|
||||||
|
hAccuracy: null,
|
||||||
|
vAccuracy: null,
|
||||||
|
satellites: null,
|
||||||
|
hdop: null,
|
||||||
|
depth: null,
|
||||||
|
frequencyHz: null,
|
||||||
|
currentMa: null,
|
||||||
|
signalDb: null,
|
||||||
|
gainDb: null,
|
||||||
|
locateMode: null,
|
||||||
|
phaseDeg: null,
|
||||||
|
compassDeg: null,
|
||||||
|
distortionPct: null,
|
||||||
|
recordedAt: row.createdAt,
|
||||||
|
receivedAt: row.createdAt,
|
||||||
|
origin: 'APP',
|
||||||
|
uploadPath: 'APP_MQTT',
|
||||||
|
originClientId: CLIENT,
|
||||||
|
deviceId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(inserted);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mqtt = {
|
||||||
|
publish: jest.fn((topic: string, payload: AppLogAck) =>
|
||||||
|
published.push({ topic, payload }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AppLogIngestService,
|
||||||
|
{ provide: PrismaService, useValue: prisma },
|
||||||
|
{ provide: RealtimeService, useValue: { publish: jest.fn() } },
|
||||||
|
{ provide: MqttClientService, useValue: mqtt },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = moduleRef.get(AppLogIngestService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ingests a fresh point once and acks it accepted on the client namespace', async () => {
|
||||||
|
await service.handle(
|
||||||
|
ORG,
|
||||||
|
CLIENT,
|
||||||
|
msg([
|
||||||
|
point('018f1a00-0000-7000-8000-000000000001', '2026-08-21T10:00:00Z'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.size).toBe(1);
|
||||||
|
expect(published[0].topic).toBe(`ul/${ORG}/app/${CLIENT}/ack`);
|
||||||
|
const ack = lastAck();
|
||||||
|
expect(ack.results).toEqual([
|
||||||
|
{ pointId: '018f1a00-0000-7000-8000-000000000001', outcome: 'ACCEPTED' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes the shared app fixture and emits the shared ack fixture', async () => {
|
||||||
|
const fixtureRoot = resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../../meta/contracts/fixtures',
|
||||||
|
);
|
||||||
|
const publishFixture = readFileSync(
|
||||||
|
resolve(fixtureRoot, 'app-log-points-v1.json'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const expectedAck = JSON.parse(
|
||||||
|
readFileSync(resolve(fixtureRoot, 'app-log-ack-v1.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.handle(ORG, CLIENT, publishFixture);
|
||||||
|
|
||||||
|
expect(store.size).toBe(1);
|
||||||
|
expect(lastAck()).toEqual(expectedAck);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ingests a replayed UUID exactly once (idempotency) and acks it as duplicate', async () => {
|
||||||
|
const p = point(
|
||||||
|
'018f1a00-0000-7000-8000-000000000002',
|
||||||
|
'2026-08-21T10:00:00Z',
|
||||||
|
);
|
||||||
|
await service.handle(ORG, CLIENT, msg([p]));
|
||||||
|
await service.handle(ORG, CLIENT, msg([p])); // replay
|
||||||
|
|
||||||
|
expect(store.size).toBe(1); // stored exactly once
|
||||||
|
const ack = lastAck();
|
||||||
|
expect(ack.results).toEqual([
|
||||||
|
{ pointId: '018f1a00-0000-7000-8000-000000000002', outcome: 'DUPLICATE' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders a batch by event time, not array order', async () => {
|
||||||
|
const later = point(
|
||||||
|
'018f1a00-0000-7000-8000-00000000000a',
|
||||||
|
'2026-08-21T10:05:00Z',
|
||||||
|
);
|
||||||
|
const earlier = point(
|
||||||
|
'018f1a00-0000-7000-8000-00000000000b',
|
||||||
|
'2026-08-21T10:01:00Z',
|
||||||
|
);
|
||||||
|
await service.handle(ORG, CLIENT, msg([later, earlier]));
|
||||||
|
|
||||||
|
const rows = [...store.values()];
|
||||||
|
expect(rows.map((r) => r.pointId)).toEqual([
|
||||||
|
earlier.pointId,
|
||||||
|
later.pointId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a point whose jobId belongs to another org (cross-namespace backstop)', async () => {
|
||||||
|
await service.handle(
|
||||||
|
ORG,
|
||||||
|
CLIENT,
|
||||||
|
msg(
|
||||||
|
[point('018f1a00-0000-7000-8000-00000000000c', '2026-08-21T10:00:00Z')],
|
||||||
|
{ jobId: 'job_of_org_beta' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
const ack = lastAck();
|
||||||
|
expect(ack.results).toEqual([
|
||||||
|
{
|
||||||
|
pointId: '018f1a00-0000-7000-8000-00000000000c',
|
||||||
|
outcome: 'REJECTED',
|
||||||
|
reasonCode: 'UNKNOWN_JOB_OR_WRONG_ORG',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops (no ack) a message on an unknown org namespace', async () => {
|
||||||
|
await service.handle(
|
||||||
|
'org_ghost',
|
||||||
|
CLIENT,
|
||||||
|
msg([
|
||||||
|
point('018f1a00-0000-7000-8000-00000000000d', '2026-08-21T10:00:00Z'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(published).toHaveLength(0);
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit an ambiguous ack when malformed JSON has no usable pointId', async () => {
|
||||||
|
await service.handle(ORG, CLIENT, '{ not json');
|
||||||
|
expect(published).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a schema-invalid point but names its UUID in the ack', async () => {
|
||||||
|
// Missing required createdAt + bad lat → validation fails; pointId still surfaced.
|
||||||
|
await service.handle(
|
||||||
|
ORG,
|
||||||
|
CLIENT,
|
||||||
|
msg([
|
||||||
|
{
|
||||||
|
pointId: '018f1a00-0000-7000-8000-00000000000e',
|
||||||
|
lat: 999,
|
||||||
|
lng: 0,
|
||||||
|
ts: '2026-08-21T10:00:00Z',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const ack = lastAck();
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
expect(ack.results).toEqual([
|
||||||
|
{
|
||||||
|
pointId: '018f1a00-0000-7000-8000-00000000000e',
|
||||||
|
outcome: 'REJECTED',
|
||||||
|
reasonCode: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid point while rejecting a malformed neighbor in the same batch', async () => {
|
||||||
|
const bad = point(
|
||||||
|
'018f1a00-0000-7000-8000-00000000000f',
|
||||||
|
'2026-08-21T10:00:00Z',
|
||||||
|
{ lat: 999 },
|
||||||
|
);
|
||||||
|
const good = point(
|
||||||
|
'018f1a00-0000-7000-8000-000000000010',
|
||||||
|
'2026-08-21T10:00:01Z',
|
||||||
|
);
|
||||||
|
await service.handle(ORG, CLIENT, msg([bad, good]));
|
||||||
|
|
||||||
|
expect(store.size).toBe(1);
|
||||||
|
expect(lastAck().results).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
pointId: bad.pointId,
|
||||||
|
outcome: 'REJECTED',
|
||||||
|
reasonCode: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
{ pointId: good.pointId, outcome: 'ACCEPTED' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-v7 UUID even when it is otherwise a valid UUID', async () => {
|
||||||
|
const uuidV4 = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
await service.handle(
|
||||||
|
ORG,
|
||||||
|
CLIENT,
|
||||||
|
msg([point(uuidV4, '2026-08-21T10:00:00Z')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
expect(lastAck().results).toEqual([
|
||||||
|
{
|
||||||
|
pointId: uuidV4,
|
||||||
|
outcome: 'REJECTED',
|
||||||
|
reasonCode: 'POINT_ID_NOT_UUIDV7',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
277
backend/src/ingest/app-log-ingest.service.ts
Normal file
277
backend/src/ingest/app-log-ingest.service.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { GpsFixType, Prisma } from '@prisma/client';
|
||||||
|
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 {
|
||||||
|
AppFixType,
|
||||||
|
AppLogAck,
|
||||||
|
AppLogAckResult,
|
||||||
|
AppLogPointDto,
|
||||||
|
AppLogPointsMessageDto,
|
||||||
|
} from './dto/app-messages.dto';
|
||||||
|
import { MqttClientService } from './mqtt-client.service';
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = '1';
|
||||||
|
const UUID_V7 =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
// Durable app-originated ingest for ul/{orgId}/app/{clientId}/log/points. The exact v1
|
||||||
|
// publish/ack envelopes are frozen in meta/contracts/telemetry-schema.md.
|
||||||
|
@Injectable()
|
||||||
|
export class AppLogIngestService {
|
||||||
|
private readonly logger = new Logger(AppLogIngestService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly realtime: RealtimeService,
|
||||||
|
private readonly mqttClient: MqttClientService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(
|
||||||
|
orgId: string,
|
||||||
|
clientId: string,
|
||||||
|
rawPayload: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const org = await this.prisma.organization.findUnique({
|
||||||
|
where: { id: orgId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!org) {
|
||||||
|
this.logger.warn(
|
||||||
|
`app-log for unknown org "${orgId}" (client ${clientId}) — dropped`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawPayload);
|
||||||
|
} catch {
|
||||||
|
// There is no trustworthy pointId to acknowledge. Raw app payloads are redacted by the
|
||||||
|
// router; retain/log on the app side rather than sending a null/ambiguous acknowledgement.
|
||||||
|
this.logger.warn(
|
||||||
|
`invalid JSON from app ${orgId}/${clientId} — no acknowledgement emitted`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = plainToInstance(AppLogPointsMessageDto, parsed as object);
|
||||||
|
const rawPoints = Array.isArray((parsed as { points?: unknown[] })?.points)
|
||||||
|
? (parsed as { points: unknown[] }).points
|
||||||
|
: [];
|
||||||
|
const usableIds = rawPoints
|
||||||
|
.map((value) =>
|
||||||
|
typeof (value as { pointId?: unknown })?.pointId === 'string'
|
||||||
|
? (value as { pointId: string }).pointId
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
.filter((value): value is string => value !== null);
|
||||||
|
|
||||||
|
const hasJobId = typeof msg.jobId === 'string' && msg.jobId.length > 0;
|
||||||
|
const hasTicket = typeof msg.ticket === 'string' && msg.ticket.length > 0;
|
||||||
|
const envelopeValid =
|
||||||
|
msg.schemaVersion === SCHEMA_VERSION &&
|
||||||
|
rawPoints.length > 0 &&
|
||||||
|
rawPoints.length <= 500 &&
|
||||||
|
hasJobId !== hasTicket;
|
||||||
|
if (!envelopeValid) {
|
||||||
|
this.publishAck(
|
||||||
|
orgId,
|
||||||
|
clientId,
|
||||||
|
usableIds.map((pointId) => ({
|
||||||
|
pointId,
|
||||||
|
outcome: 'REJECTED',
|
||||||
|
reasonCode: 'ENVELOPE_VALIDATION_ERROR',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate independently so one malformed capture does not discard valid captures in the
|
||||||
|
// same batch (LOG-7). Invalid UUID strings can still be named in a rejection; missing IDs
|
||||||
|
// cannot be safely correlated and therefore remain unacknowledged.
|
||||||
|
const results: AppLogAckResult[] = [];
|
||||||
|
const candidates: AppLogPointDto[] = [];
|
||||||
|
for (const rawPoint of rawPoints) {
|
||||||
|
const point = plainToInstance(AppLogPointDto, rawPoint as object);
|
||||||
|
const pointId = typeof point.pointId === 'string' ? point.pointId : null;
|
||||||
|
const errors = await validate(point, { whitelist: true });
|
||||||
|
if (!pointId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (errors.length > 0 || !UUID_V7.test(pointId)) {
|
||||||
|
results.push({
|
||||||
|
pointId,
|
||||||
|
outcome: 'REJECTED',
|
||||||
|
reasonCode: UUID_V7.test(pointId)
|
||||||
|
? 'VALIDATION_ERROR'
|
||||||
|
: 'POINT_ID_NOT_UUIDV7',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await this.resolveJob(orgId, clientId, msg);
|
||||||
|
if (!job) {
|
||||||
|
results.push(
|
||||||
|
...candidates.map((point) => ({
|
||||||
|
pointId: point.pointId,
|
||||||
|
outcome: 'REJECTED' as const,
|
||||||
|
reasonCode: 'UNKNOWN_JOB_OR_WRONG_ORG',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
this.publishAck(orgId, clientId, results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse duplicates inside this delivery, then use producer event time rather than
|
||||||
|
// arrival/array order. The first valid occurrence wins; repeated IDs are safe to release.
|
||||||
|
const byPointId = new Map<string, AppLogPointDto>();
|
||||||
|
for (const point of candidates) {
|
||||||
|
if (byPointId.has(point.pointId)) {
|
||||||
|
results.push({
|
||||||
|
pointId: point.pointId,
|
||||||
|
outcome: 'DUPLICATE',
|
||||||
|
reasonCode: 'DUPLICATE_IN_BATCH',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
byPointId.set(point.pointId, point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordered = [...byPointId.values()].sort(
|
||||||
|
(a, b) => +new Date(a.createdAt) - +new Date(b.createdAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = ordered.length
|
||||||
|
? await this.prisma.locatePoint.findMany({
|
||||||
|
where: { pointId: { in: ordered.map((point) => point.pointId) } },
|
||||||
|
select: { pointId: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const seenIds = new Set(seen.map((row) => row.pointId));
|
||||||
|
const fresh = ordered.filter((point) => !seenIds.has(point.pointId));
|
||||||
|
for (const point of ordered.filter((candidate) =>
|
||||||
|
seenIds.has(candidate.pointId),
|
||||||
|
)) {
|
||||||
|
results.push({ pointId: point.pointId, outcome: 'DUPLICATE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored: Awaited<
|
||||||
|
ReturnType<typeof this.prisma.locatePoint.createManyAndReturn>
|
||||||
|
> = [];
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
stored = await this.prisma.locatePoint.createManyAndReturn({
|
||||||
|
data: fresh.map((point) => ({
|
||||||
|
jobId: job.id,
|
||||||
|
deviceId: null,
|
||||||
|
pointId: point.pointId,
|
||||||
|
origin: 'APP' as const,
|
||||||
|
uploadPath: 'APP_MQTT' as const,
|
||||||
|
originClientId: clientId,
|
||||||
|
createdAt: new Date(point.createdAt),
|
||||||
|
lat: point.lat,
|
||||||
|
lng: point.lng,
|
||||||
|
altitude: point.alt,
|
||||||
|
utilityType: point.utility,
|
||||||
|
sequence: point.seq,
|
||||||
|
fixType: this.toStoredFix(point.fix),
|
||||||
|
hAccuracy: point.hAcc,
|
||||||
|
vAccuracy: point.vAcc,
|
||||||
|
satellites: point.sats,
|
||||||
|
hdop: point.hdop,
|
||||||
|
depth: point.depth,
|
||||||
|
frequencyHz: point.freqHz,
|
||||||
|
currentMa: point.currentMa,
|
||||||
|
signalDb: point.signalDb,
|
||||||
|
gainDb: point.gainDb,
|
||||||
|
locateMode: point.mode,
|
||||||
|
phaseDeg: point.phaseDeg,
|
||||||
|
compassDeg: point.compassDeg,
|
||||||
|
distortionPct: point.distortionPct,
|
||||||
|
recordedAt: new Date(point.ts),
|
||||||
|
raw: point as unknown as Prisma.InputJsonValue,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedIds = new Set(stored.map((row) => row.pointId));
|
||||||
|
for (const point of fresh) {
|
||||||
|
results.push({
|
||||||
|
pointId: point.pointId,
|
||||||
|
outcome: storedIds.has(point.pointId) ? 'ACCEPTED' : 'DUPLICATE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored.length > 0) {
|
||||||
|
this.realtime.publish(`job:${job.id}`, {
|
||||||
|
type: 'points',
|
||||||
|
jobId: job.id,
|
||||||
|
points: stored.map(toPointDto),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.publishAck(orgId, clientId, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveJob(
|
||||||
|
orgId: string,
|
||||||
|
clientId: string,
|
||||||
|
msg: AppLogPointsMessageDto,
|
||||||
|
) {
|
||||||
|
if (msg.jobId) {
|
||||||
|
const job = await this.prisma.job.findFirst({
|
||||||
|
where: { id: msg.jobId, orgId },
|
||||||
|
});
|
||||||
|
if (!job)
|
||||||
|
this.logger.warn(
|
||||||
|
`app-log ${orgId}/${clientId} referenced a foreign/unknown job ${msg.jobId}`,
|
||||||
|
);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.job.findUnique({
|
||||||
|
where: { orgId_ticketNumber: { orgId, ticketNumber: msg.ticket! } },
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
return this.prisma.job.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
ticketNumber: msg.ticket!,
|
||||||
|
title: `Ticket ${msg.ticket} (app-created)`,
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
source: 'WEB',
|
||||||
|
startedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStoredFix(fix?: AppFixType): GpsFixType | undefined {
|
||||||
|
switch (fix) {
|
||||||
|
case 'FLOAT':
|
||||||
|
return GpsFixType.FLOAT_RTK;
|
||||||
|
case 'FIXED':
|
||||||
|
return GpsFixType.FIXED_RTK;
|
||||||
|
case 'NO_FIX':
|
||||||
|
return GpsFixType.NONE;
|
||||||
|
case 'AUTONOMOUS':
|
||||||
|
return GpsFixType.AUTONOMOUS;
|
||||||
|
case 'DGPS':
|
||||||
|
return GpsFixType.DGPS;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishAck(
|
||||||
|
orgId: string,
|
||||||
|
clientId: string,
|
||||||
|
results: AppLogAckResult[],
|
||||||
|
): void {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
const ack: AppLogAck = { schemaVersion: SCHEMA_VERSION, results };
|
||||||
|
this.mqttClient.publish(`ul/${orgId}/app/${clientId}/ack`, ack);
|
||||||
|
}
|
||||||
|
}
|
||||||
189
backend/src/ingest/dto/app-messages.dto.ts
Normal file
189
backend/src/ingest/dto/app-messages.dto.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { LocateMode, UtilityType } from '@prisma/client';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const APP_FIX_TYPES = [
|
||||||
|
'AUTONOMOUS',
|
||||||
|
'DGPS',
|
||||||
|
'FLOAT',
|
||||||
|
'FIXED',
|
||||||
|
'NO_FIX',
|
||||||
|
] as const;
|
||||||
|
export type AppFixType = (typeof APP_FIX_TYPES)[number];
|
||||||
|
|
||||||
|
// Frozen app MQTT wire profile v1 from meta/contracts/telemetry-schema.md. This is deliberately
|
||||||
|
// separate from MqttPointDto: the app wire uses the normative fix tokens FLOAT/FIXED/NO_FIX,
|
||||||
|
// while the legacy device path uses Prisma's FLOAT_RTK/FIXED_RTK/NONE storage tokens.
|
||||||
|
export class AppLogPointDto {
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Client-generated UUIDv7 idempotency key.',
|
||||||
|
})
|
||||||
|
@IsUUID()
|
||||||
|
pointId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date-time' })
|
||||||
|
@IsDateString()
|
||||||
|
createdAt: string;
|
||||||
|
|
||||||
|
@IsIn(['APP'])
|
||||||
|
origin: 'APP';
|
||||||
|
|
||||||
|
@IsIn(['APP_MQTT'])
|
||||||
|
uploadPath: 'APP_MQTT';
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
lat: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
lng: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
alt?: number;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
ts: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(APP_FIX_TYPES)
|
||||||
|
fix?: AppFixType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
hAcc?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
vAcc?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sats?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
hdop?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
depth?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
freqHz?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
currentMa?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
signalDb?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
gainDb?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LocateMode)
|
||||||
|
mode?: LocateMode;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
phaseDeg?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(360)
|
||||||
|
compassDeg?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
distortionPct?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UtilityType)
|
||||||
|
utility?: UtilityType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
seq?: number;
|
||||||
|
|
||||||
|
@IsIn(['IN_SPEC', 'OUT_OF_SPEC', 'MANUAL', 'PPK_CORRECTED', 'WAIVED', 'NONCOMPLIANT'])
|
||||||
|
qualityFlag: 'IN_SPEC' | 'OUT_OF_SPEC' | 'MANUAL' | 'PPK_CORRECTED' | 'WAIVED' | 'NONCOMPLIANT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppLogPointsMessageDto {
|
||||||
|
@ApiPropertyOptional({ description: 'MQTT app-log wire profile version.' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(16)
|
||||||
|
schemaVersion: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Job id; exactly one of jobId or ticket is required.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
jobId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Ticket number; exactly one of jobId or ticket is required.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
ticket?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [AppLogPointDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(500)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AppLogPointDto)
|
||||||
|
points: AppLogPointDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppLogAckOutcome = 'ACCEPTED' | 'DUPLICATE' | 'REJECTED';
|
||||||
|
|
||||||
|
export interface AppLogAckResult {
|
||||||
|
pointId: string;
|
||||||
|
outcome: AppLogAckOutcome;
|
||||||
|
reasonCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppLogAck {
|
||||||
|
schemaVersion: string;
|
||||||
|
results: AppLogAckResult[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { AppLogIngestService } from './app-log-ingest.service';
|
||||||
import { JobsIngestService } from './jobs-ingest.service';
|
import { JobsIngestService } from './jobs-ingest.service';
|
||||||
import { LogIngestService } from './log-ingest.service';
|
import { LogIngestService } from './log-ingest.service';
|
||||||
import { MqttClientService } from './mqtt-client.service';
|
import { MqttClientService } from './mqtt-client.service';
|
||||||
@@ -15,6 +16,7 @@ export class IngestRouterService implements OnModuleInit {
|
|||||||
private readonly pointsIngest: PointsIngestService,
|
private readonly pointsIngest: PointsIngestService,
|
||||||
private readonly jobsIngest: JobsIngestService,
|
private readonly jobsIngest: JobsIngestService,
|
||||||
private readonly logIngest: LogIngestService,
|
private readonly logIngest: LogIngestService,
|
||||||
|
private readonly appLogIngest: AppLogIngestService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -26,10 +28,32 @@ export class IngestRouterService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async route(topic: string, payload: string) {
|
private async route(topic: string, payload: string) {
|
||||||
// Raw log captures every message on devices/#, matched or not (audit/debug trail)
|
const segments = topic.split('/');
|
||||||
await this.prisma.deviceEvent.create({ data: { topic, payload } });
|
const root = segments[0];
|
||||||
|
|
||||||
const [root, idSegment, ...rest] = topic.split('/');
|
// App point payloads contain personal/location data. Preserve routing evidence without
|
||||||
|
// creating an unmanaged second copy of the telemetry in device_events (S2 security H3).
|
||||||
|
const auditPayload =
|
||||||
|
root === 'ul' &&
|
||||||
|
segments[2] === 'app' &&
|
||||||
|
segments.slice(4).join('/') === 'log/points'
|
||||||
|
? JSON.stringify({
|
||||||
|
redacted: true,
|
||||||
|
bytes: Buffer.byteLength(payload, 'utf8'),
|
||||||
|
})
|
||||||
|
: payload;
|
||||||
|
await this.prisma.deviceEvent.create({
|
||||||
|
data: { topic, payload: auditPayload },
|
||||||
|
});
|
||||||
|
|
||||||
|
// App / device MQTT namespace: ul/{orgId}/{clientClass}/{clientId}/{subtopic...}
|
||||||
|
// (SRS §3.4.2). Sprint 2 handles the durable app-log path; other clientClasses and
|
||||||
|
// subtopics are represented but not yet ingested here.
|
||||||
|
if (root === 'ul') {
|
||||||
|
return this.routeUl(segments, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, idSegment, ...rest] = segments;
|
||||||
const subtopic = rest.join('/');
|
const subtopic = rest.join('/');
|
||||||
if (root !== 'devices' || !idSegment || 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
|
||||||
@@ -42,13 +66,19 @@ export class IngestRouterService implements OnModuleInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const device = await this.prisma.device.findUnique({ where: { mqttUsername: idSegment } });
|
const device = await this.prisma.device.findUnique({
|
||||||
|
where: { mqttUsername: idSegment },
|
||||||
|
});
|
||||||
if (!device) {
|
if (!device) {
|
||||||
this.logger.warn(`Message from unregistered device username "${idSegment}" (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 "${idSegment}" ignored`);
|
this.logger.warn(
|
||||||
|
`Message from deactivated device "${idSegment}" ignored`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,4 +99,29 @@ export class IngestRouterService implements OnModuleInit {
|
|||||||
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${idSegment}`);
|
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${idSegment}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ul/{orgId}/{clientClass}/{clientId}/{subtopic...} — SRS §3.4.2 topic scheme.
|
||||||
|
// The namespace itself carries tenant (orgId) + publisher (clientId) identity; the
|
||||||
|
// broker ACL confines a publisher to its own namespace, and AppLogIngest re-checks
|
||||||
|
// tenant scope on the payload's job as a backstop. Only the durable app-log path is
|
||||||
|
// ingested this sprint.
|
||||||
|
private async routeUl(segments: string[], payload: string) {
|
||||||
|
const [, orgId, clientClass, clientId, ...rest] = segments;
|
||||||
|
const subtopic = rest.join('/');
|
||||||
|
if (!orgId || !clientClass || !clientId) {
|
||||||
|
this.logger.warn(`Malformed ul topic "${segments.join('/')}" — ignored`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Our own cloud→publisher acks are echoed back to us on the same subscription; skip.
|
||||||
|
if (subtopic === 'ack') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clientClass === 'app' && subtopic === 'log/points') {
|
||||||
|
await this.appLogIngest.handle(orgId, clientId, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.debug(
|
||||||
|
`Unhandled ul path "${clientClass}/${subtopic}" (org ${orgId}, client ${clientId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { RealtimeModule } from '../realtime/realtime.module';
|
import { RealtimeModule } from '../realtime/realtime.module';
|
||||||
|
import { AppLogIngestService } from './app-log-ingest.service';
|
||||||
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 { LocatorRegistryService } from './locator-registry.service';
|
||||||
@@ -15,6 +16,7 @@ import { PointsIngestService } from './points-ingest.service';
|
|||||||
PointsIngestService,
|
PointsIngestService,
|
||||||
JobsIngestService,
|
JobsIngestService,
|
||||||
LogIngestService,
|
LogIngestService,
|
||||||
|
AppLogIngestService,
|
||||||
LocatorRegistryService,
|
LocatorRegistryService,
|
||||||
],
|
],
|
||||||
exports: [MqttClientService],
|
exports: [MqttClientService],
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { connect, MqttClient } from 'mqtt';
|
import { connect, MqttClient } from 'mqtt';
|
||||||
|
|
||||||
export type MqttMessageListener = (topic: string, payload: Buffer) => void;
|
export type MqttMessageListener = (topic: string, payload: Buffer) => void;
|
||||||
@@ -12,19 +17,29 @@ export class MqttClientService implements OnModuleInit, OnModuleDestroy {
|
|||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
const host = process.env.MQTT_HOST || 'mosquitto';
|
const host = process.env.MQTT_HOST || 'mosquitto';
|
||||||
const port = Number(process.env.MQTT_PORT || 1883);
|
const port = Number(process.env.MQTT_PORT || 1883);
|
||||||
|
const username = process.env.MQTT_USERNAME;
|
||||||
|
const password = process.env.MQTT_PASSWORD;
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error(
|
||||||
|
'MQTT_USERNAME and MQTT_PASSWORD are required; refusing to use a default broker credential.',
|
||||||
|
);
|
||||||
|
}
|
||||||
this.client = connect(`mqtt://${host}:${port}`, {
|
this.client = connect(`mqtt://${host}:${port}`, {
|
||||||
username: process.env.MQTT_USERNAME || 'backend',
|
username,
|
||||||
password: process.env.MQTT_PASSWORD || 'backendpass',
|
password,
|
||||||
clientId: `ulhub-backend-${Math.random().toString(16).slice(2)}`,
|
clientId: `ulhub-backend-${Math.random().toString(16).slice(2)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('connect', () => {
|
this.client.on('connect', () => {
|
||||||
this.logger.log(`Connected to MQTT broker at ${host}:${port}`);
|
this.logger.log(`Connected to MQTT broker at ${host}:${port}`);
|
||||||
this.client!.subscribe('devices/#', (err) => {
|
// devices/# = legacy device-direct namespace; ul/# = SRS §3.4.2 topic scheme
|
||||||
|
// (app + device-direct). QoS 1 so the broker redelivers durable log records the
|
||||||
|
// backend missed while disconnected (broker loss ≠ capture loss, SRS-SYN-1/7).
|
||||||
|
this.client!.subscribe(['devices/#', 'ul/#'], { qos: 1 }, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.error('Failed to subscribe to devices/#', err);
|
this.logger.error('Failed to subscribe to devices/# + ul/#', err);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log('Subscribed to devices/#');
|
this.logger.log('Subscribed to devices/# and ul/#');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import { CreatePointDto, QueryPointsDto } from './dto/points.dto';
|
|||||||
// JSON-safe shape: BigInt id -> string, Decimal -> number
|
// JSON-safe shape: BigInt id -> string, Decimal -> number
|
||||||
export interface PointDto {
|
export interface PointDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
pointId: string | null;
|
||||||
jobId: string;
|
jobId: string;
|
||||||
deviceId: string | null;
|
deviceId: string | null;
|
||||||
|
origin: string;
|
||||||
|
uploadPath: string | null;
|
||||||
|
originClientId: string | null;
|
||||||
|
createdAt: Date | null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
altitude: number | null;
|
altitude: number | null;
|
||||||
@@ -38,8 +43,13 @@ function num(value: unknown): number | null {
|
|||||||
export function toPointDto(p: LocatePoint): PointDto {
|
export function toPointDto(p: LocatePoint): PointDto {
|
||||||
return {
|
return {
|
||||||
id: p.id.toString(),
|
id: p.id.toString(),
|
||||||
|
pointId: p.pointId,
|
||||||
jobId: p.jobId,
|
jobId: p.jobId,
|
||||||
deviceId: p.deviceId,
|
deviceId: p.deviceId,
|
||||||
|
origin: p.origin,
|
||||||
|
uploadPath: p.uploadPath,
|
||||||
|
originClientId: p.originClientId,
|
||||||
|
createdAt: p.createdAt,
|
||||||
lat: Number(p.lat),
|
lat: Number(p.lat),
|
||||||
lng: Number(p.lng),
|
lng: Number(p.lng),
|
||||||
altitude: num(p.altitude),
|
altitude: num(p.altitude),
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ services:
|
|||||||
mosquitto:
|
mosquitto:
|
||||||
image: eclipse-mosquitto:2
|
image: eclipse-mosquitto:2
|
||||||
ports:
|
ports:
|
||||||
- "1883:1883"
|
|
||||||
- "8883:8883"
|
- "8883:8883"
|
||||||
- "9001:9001"
|
- "8884:8884"
|
||||||
|
- "127.0.0.1:9001:9001"
|
||||||
volumes:
|
volumes:
|
||||||
- ./mosquitto:/mosquitto
|
- ./mosquitto:/mosquitto
|
||||||
|
- /home/ubuntu/.config/ul-platform/mosquitto.passwd:/run/secrets/mosquitto_passwd:ro
|
||||||
|
- /home/ubuntu/.config/ul-platform/mosquitto.acl:/run/secrets/mosquitto_acl:ro
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -37,7 +39,7 @@ services:
|
|||||||
MQTT_HOST: mosquitto
|
MQTT_HOST: mosquitto
|
||||||
MQTT_PORT: 1883
|
MQTT_PORT: 1883
|
||||||
MQTT_USERNAME: ${MQTT_BACKEND_USERNAME:-backend}
|
MQTT_USERNAME: ${MQTT_BACKEND_USERNAME:-backend}
|
||||||
MQTT_PASSWORD: ${MQTT_BACKEND_PASSWORD:-backendpass}
|
MQTT_PASSWORD: ${MQTT_BACKEND_PASSWORD:?set MQTT_BACKEND_PASSWORD}
|
||||||
MQTT_CERTS_DIR: /mosquitto-certs
|
MQTT_CERTS_DIR: /mosquitto-certs
|
||||||
MQTT_TLS_PORT: 8883
|
MQTT_TLS_PORT: 8883
|
||||||
# Must match the CN the broker's server cert was provisioned for
|
# Must match the CN the broker's server cert was provisioned for
|
||||||
|
|||||||
@@ -12,10 +12,22 @@ 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)
|
# and publishes on behalf of the /sim simulator tool (devices/<serial>/log).
|
||||||
|
# Also ingests the SRS §3.4.2 ul/# namespace (app + device-direct) and writes the
|
||||||
|
# application-level acks back on ul/{orgId}/{clientClass}/{clientId}/ack.
|
||||||
user backend
|
user backend
|
||||||
topic read devices/#
|
topic read devices/#
|
||||||
topic write devices/+/jobs/ack
|
topic write devices/+/jobs/ack
|
||||||
topic write devices/+/log
|
topic write devices/+/log
|
||||||
|
topic read ul/#
|
||||||
|
topic write ul/+/+/+/ack
|
||||||
|
|
||||||
|
# --- App-client namespace confinement (S2-a / SRS §3.4.2 Topic scheme) --------------
|
||||||
|
# The interim app credential uses username == orgId, so %u confines it to that tenant.
|
||||||
|
# The '+' clientId wildcard is the accepted Sprint 2 tradeoff: clients within one org share
|
||||||
|
# the credential and can see that org's ack topics until OIDC-derived per-client identity lands.
|
||||||
|
# Publish/read permissions are narrower than readwrite '#': only durable point input and acks.
|
||||||
|
pattern write ul/%u/app/+/log/points
|
||||||
|
pattern read ul/%u/app/+/ack
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ per_listener_settings true
|
|||||||
|
|
||||||
# Plain MQTT — internal services and clients authenticate with username/password on port 1883
|
# Plain MQTT — internal services and clients authenticate with username/password on port 1883
|
||||||
listener 1883 0.0.0.0
|
listener 1883 0.0.0.0
|
||||||
password_file /mosquitto/config/passwd
|
password_file /run/secrets/mosquitto_passwd
|
||||||
acl_file /mosquitto/config/devices.acl
|
acl_file /run/secrets/mosquitto_acl
|
||||||
allow_anonymous false
|
allow_anonymous false
|
||||||
|
|
||||||
# WebSocket — browser clients, no authentication required
|
# Authenticated MQTT over WebSocket for app clients. Docker binds this listener only to
|
||||||
|
# host loopback; nginx supplies the public WSS/TLS endpoint at /mqtt on port 443.
|
||||||
listener 9001 0.0.0.0
|
listener 9001 0.0.0.0
|
||||||
protocol websockets
|
protocol websockets
|
||||||
allow_anonymous true
|
password_file /run/secrets/mosquitto_passwd
|
||||||
|
acl_file /run/secrets/mosquitto_acl
|
||||||
|
allow_anonymous false
|
||||||
|
|
||||||
# TLS MQTT — devices authenticate with client certificates (port 8883)
|
# TLS MQTT — devices authenticate with client certificates (port 8883)
|
||||||
# require_certificate true forces client cert; cert CN becomes the MQTT username.
|
# require_certificate true forces client cert; cert CN becomes the MQTT username.
|
||||||
@@ -19,20 +22,19 @@ allow_anonymous true
|
|||||||
# since there's no config/cert hot-reload.
|
# since there's no config/cert hot-reload.
|
||||||
listener 8883 0.0.0.0
|
listener 8883 0.0.0.0
|
||||||
cafile /mosquitto/certs/ca.crt
|
cafile /mosquitto/certs/ca.crt
|
||||||
certfile /mosquitto/certs/server.crt
|
certfile /mosquitto/certs/public-fullchain.pem
|
||||||
keyfile /mosquitto/certs/server.key
|
keyfile /mosquitto/certs/public-privkey.pem
|
||||||
require_certificate true
|
require_certificate true
|
||||||
use_identity_as_username true
|
use_identity_as_username true
|
||||||
allow_anonymous false
|
allow_anonymous false
|
||||||
acl_file /mosquitto/config/devices.acl
|
acl_file /run/secrets/mosquitto_acl
|
||||||
|
|
||||||
# TLS MQTT — admin access via username/password, no client cert required (port 8884)
|
# TLS MQTT — app/admin username+password access (port 8884). App usernames are orgIds;
|
||||||
# Connect with CA cert for server verification, then username/password.
|
# devices.acl confines them to ul/{orgId}/app/... . No anonymous listener is exposed.
|
||||||
# listener 8884 0.0.0.0
|
listener 8884 0.0.0.0
|
||||||
# cafile /mosquitto/certs/ca.crt
|
certfile /mosquitto/certs/public-fullchain.pem
|
||||||
# certfile /mosquitto/certs/server.crt
|
keyfile /mosquitto/certs/public-privkey.pem
|
||||||
# keyfile /mosquitto/certs/server.key
|
require_certificate false
|
||||||
# require_certificate false
|
password_file /run/secrets/mosquitto_passwd
|
||||||
# password_file /mosquitto/config/passwd
|
allow_anonymous false
|
||||||
# allow_anonymous false
|
acl_file /run/secrets/mosquitto_acl
|
||||||
# acl_file /mosquitto/config/devices.acl
|
|
||||||
|
|||||||
@@ -6,18 +6,6 @@ server {
|
|||||||
root /var/www/certbot;
|
root /var/www/certbot;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /mqtt {
|
|
||||||
# proxy MQTT over WebSocket (strip the /mqtt prefix so Mosquitto sees /)
|
|
||||||
proxy_pass http://127.0.0.1:9001/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
@@ -33,6 +21,8 @@ server {
|
|||||||
client_max_body_size 20m;
|
client_max_body_size 20m;
|
||||||
|
|
||||||
location /mqtt {
|
location /mqtt {
|
||||||
|
# Authenticated, ACL-confined MQTT-over-WebSocket. Mosquitto is bound to loopback;
|
||||||
|
# nginx terminates publicly trusted TLS so phones can use standard port 443.
|
||||||
proxy_pass http://127.0.0.1:9001/;
|
proxy_pass http://127.0.0.1:9001/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
|||||||
12
web/.eslintrc.json
Normal file
12
web/.eslintrc.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["pages/settings/mqtt-certs.tsx", "pages/sim/index.tsx"],
|
||||||
|
"rules": {
|
||||||
|
"@next/next/no-html-link-for-pages": "off",
|
||||||
|
"react/no-unescaped-entities": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,66 +1,141 @@
|
|||||||
import Head from 'next/head';
|
import Head from "next/head";
|
||||||
import Link from 'next/link';
|
import Image from "next/image";
|
||||||
import { useRouter } from 'next/router';
|
import Link from "next/link";
|
||||||
import { ReactNode } from 'react';
|
import { useRouter } from "next/router";
|
||||||
import { useAuth } from '../lib/auth-context';
|
import { ReactNode } from "react";
|
||||||
|
import { useAuth } from "../lib/auth-context";
|
||||||
|
|
||||||
export default function Layout({ children, title }: { children: ReactNode; title?: string }) {
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/", label: "Jobs" },
|
||||||
|
{ href: "/settings/devices", label: "Devices" },
|
||||||
|
{ href: "/settings/members", label: "Members" },
|
||||||
|
{ href: "/settings/api-keys", label: "API keys" },
|
||||||
|
{ href: "/settings/mqtt-certs", label: "MQTT certs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth();
|
const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isActive = (href: string) =>
|
||||||
|
href === "/"
|
||||||
|
? router.pathname === "/" || router.pathname.startsWith("/jobs")
|
||||||
|
: router.pathname.startsWith(href);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
router.push("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinks = NAV_ITEMS.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="ul-shell__nav-link"
|
||||||
|
aria-current={isActive(item.href) ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title ? `${title} · UlHub` : 'UlHub'}</title>
|
<title>{title ? `${title} · UlHub` : "UlHub"}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<header
|
<a className="ul-skip-link" href="#main-content">
|
||||||
style={{
|
Skip to main content
|
||||||
display: 'flex',
|
</a>
|
||||||
alignItems: 'center',
|
<header className="ul-shell__header">
|
||||||
gap: '1.5rem',
|
<Link href="/" className="ul-shell__brand" aria-label="UlHub jobs home">
|
||||||
padding: '0.75rem 1.5rem',
|
<Image
|
||||||
borderBottom: '1px solid #ddd',
|
className="ul-shell__brand-mark"
|
||||||
fontFamily: 'sans-serif',
|
src="/um-trace-mark.png"
|
||||||
}}
|
alt=""
|
||||||
>
|
width={40}
|
||||||
<Link href="/" style={{ fontWeight: 700, fontSize: '1.1rem', textDecoration: 'none', color: '#111' }}>
|
height={40}
|
||||||
UlHub
|
priority
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<span>UlHub</span>
|
||||||
</Link>
|
</Link>
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<nav style={{ display: 'flex', gap: '1rem' }}>
|
<nav
|
||||||
<Link href="/">Jobs</Link>
|
className="ul-shell__nav ul-shell__nav--desktop"
|
||||||
<Link href="/settings/devices">Devices</Link>
|
aria-label="Primary navigation"
|
||||||
<Link href="/settings/members">Members</Link>
|
>
|
||||||
<Link href="/settings/api-keys">API Keys</Link>
|
{navLinks}
|
||||||
<Link href="/settings/mqtt-certs">MQTT Certs</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
<div className="ul-shell__context">
|
||||||
{memberships.length > 1 ? (
|
<div className="ul-org-context">
|
||||||
<select value={activeOrg?.org.id ?? ''} onChange={(e) => setActiveOrgId(e.target.value)}>
|
<span className="ul-org-context__label" id="active-org-label">
|
||||||
{memberships.map((m) => (
|
Active organization
|
||||||
<option key={m.org.id} value={m.org.id}>
|
</span>
|
||||||
{m.org.name}
|
{memberships.length > 1 ? (
|
||||||
</option>
|
<select
|
||||||
))}
|
className="ul-org-context__select"
|
||||||
</select>
|
aria-labelledby="active-org-label"
|
||||||
) : (
|
value={activeOrg?.org.id ?? ""}
|
||||||
<span style={{ color: '#555' }}>{activeOrg?.org.name}</span>
|
onChange={(event) => setActiveOrgId(event.target.value)}
|
||||||
)}
|
>
|
||||||
<span style={{ color: '#888', fontSize: '0.9rem' }}>{user.email}</span>
|
{memberships.map((membership) => (
|
||||||
<button
|
<option key={membership.org.id} value={membership.org.id}>
|
||||||
onClick={async () => {
|
{membership.org.name}
|
||||||
await logout();
|
</option>
|
||||||
router.push('/login');
|
))}
|
||||||
}}
|
</select>
|
||||||
>
|
) : (
|
||||||
Log out
|
<strong className="ul-org-context__name">
|
||||||
</button>
|
{activeOrg?.org.name ?? "No organization"}
|
||||||
|
</strong>
|
||||||
|
)}
|
||||||
|
{activeOrg && (
|
||||||
|
<span className="ul-org-context__role">
|
||||||
|
{activeOrg.role.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<details className="ul-shell__menu">
|
||||||
|
<summary>Menu</summary>
|
||||||
|
<nav
|
||||||
|
className="ul-shell__nav ul-shell__nav--mobile"
|
||||||
|
aria-label="Mobile navigation"
|
||||||
|
>
|
||||||
|
{navLinks}
|
||||||
|
<div className="ul-shell__mobile-account">
|
||||||
|
<span>{user.email}</span>
|
||||||
|
<button
|
||||||
|
className="ul-button ul-button--quiet"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</details>
|
||||||
|
<div className="ul-shell__account">
|
||||||
|
<span className="ul-shell__email">{user.email}</span>
|
||||||
|
<button
|
||||||
|
className="ul-button ul-button--quiet"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main style={{ fontFamily: 'sans-serif', padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>{children}</main>
|
<main id="main-content" className="ul-shell__main" tabIndex={-1}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
96
web/components/PortalUi.tsx
Normal file
96
web/components/PortalUi.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type StatusTone = "info" | "success" | "warning" | "danger" | "neutral";
|
||||||
|
|
||||||
|
const STATUS_PRESENTATION: Record<
|
||||||
|
string,
|
||||||
|
{ icon: string; label: string; tone: StatusTone }
|
||||||
|
> = {
|
||||||
|
OPEN: { icon: "○", label: "Open", tone: "info" },
|
||||||
|
IN_PROGRESS: { icon: "◐", label: "In progress", tone: "warning" },
|
||||||
|
COMPLETED: { icon: "✓", label: "Completed", tone: "success" },
|
||||||
|
CANCELLED: { icon: "—", label: "Cancelled", tone: "neutral" },
|
||||||
|
LIVE: { icon: "●", label: "Live", tone: "success" },
|
||||||
|
CONNECTING: { icon: "↻", label: "Connecting", tone: "info" },
|
||||||
|
OFFLINE: { icon: "○", label: "Offline · reconnecting", tone: "warning" },
|
||||||
|
ERROR: { icon: "!", label: "Connection error · retrying", tone: "danger" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function humanizeStatus(status: string): string {
|
||||||
|
return (
|
||||||
|
STATUS_PRESENTATION[status]?.label ??
|
||||||
|
status
|
||||||
|
.toLowerCase()
|
||||||
|
.split("_")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const presentation = STATUS_PRESENTATION[status] ?? {
|
||||||
|
icon: "•",
|
||||||
|
label: humanizeStatus(status),
|
||||||
|
tone: "neutral" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`ul-status-badge ul-status-badge--${presentation.tone}`}>
|
||||||
|
<span className="ul-status-badge__icon" aria-hidden="true">
|
||||||
|
{presentation.icon}
|
||||||
|
</span>
|
||||||
|
<span>{label ?? presentation.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageState({
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
kind: "loading" | "empty" | "error";
|
||||||
|
title: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const icon = kind === "error" ? "!" : kind === "empty" ? "□" : "…";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`ul-page-state ul-page-state--${kind}`}
|
||||||
|
role={kind === "error" ? "alert" : "status"}
|
||||||
|
>
|
||||||
|
<span className="ul-page-state__icon" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
{children && <div className="ul-page-state__detail">{children}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Metric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
detail?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="ul-metric">
|
||||||
|
<span className="ul-metric__label">{label}</span>
|
||||||
|
<strong className="ul-metric__value">{value}</strong>
|
||||||
|
{detail && <span className="ul-metric__detail">{detail}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import type MapViewConstructor from '@arcgis/core/views/MapView.js';
|
|||||||
import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types';
|
import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types';
|
||||||
|
|
||||||
const API_KEY = process.env.NEXT_PUBLIC_ARCGIS_API_KEY || '';
|
const API_KEY = process.env.NEXT_PUBLIC_ARCGIS_API_KEY || '';
|
||||||
const LIVE_COLOR = '#1a73e8';
|
|
||||||
|
|
||||||
const DEFAULT_CENTER: [number, number] = [-98.35, 39.5]; // continental US, [lng, lat]
|
const DEFAULT_CENTER: [number, number] = [-98.35, 39.5]; // continental US, [lng, lat]
|
||||||
const DEFAULT_ZOOM = 4;
|
const DEFAULT_ZOOM = 4;
|
||||||
@@ -63,6 +62,10 @@ function colorFor(utilityType: string): string {
|
|||||||
return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN;
|
return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function semanticColor(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
}
|
||||||
|
|
||||||
function orderKey(p: MapPoint): number {
|
function orderKey(p: MapPoint): number {
|
||||||
return p.sequence ?? new Date(p.recordedAt).getTime();
|
return p.sequence ?? new Date(p.recordedAt).getTime();
|
||||||
}
|
}
|
||||||
@@ -78,10 +81,10 @@ function popupHtml(point: MapPoint): string {
|
|||||||
const rows = pointDetailRows(point)
|
const rows = pointDetailRows(point)
|
||||||
.map(
|
.map(
|
||||||
([label, value]) =>
|
([label, value]) =>
|
||||||
`<tr><td style="color:#666;padding-right:0.75rem;white-space:nowrap;">${label}</td><td style="font-weight:600;">${value}</td></tr>`,
|
`<tr><td class="ul-map-popup-table__label">${label}</td><td class="ul-map-popup-table__value">${value}</td></tr>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
return `<table style="border-collapse:collapse;font-family:sans-serif;">${rows}</table>`;
|
return `<table class="ul-map-popup-table">${rows}</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esri opens/closes/dismisses-on-outside-click popups natively via each
|
// Esri opens/closes/dismisses-on-outside-click popups natively via each
|
||||||
@@ -94,7 +97,7 @@ function pointGraphic(mods: EsriModules, point: MapPoint, color: string) {
|
|||||||
style: 'circle',
|
style: 'circle',
|
||||||
color,
|
color,
|
||||||
size: 10,
|
size: 10,
|
||||||
outline: { color: '#ffffff', width: 1.5 },
|
outline: { color: semanticColor('--ul-color-on-primary'), width: 1.5 },
|
||||||
},
|
},
|
||||||
popupTemplate: {
|
popupTemplate: {
|
||||||
title: `${point.utilityType} · ${point.fixType}`,
|
title: `${point.utilityType} · ${point.fixType}`,
|
||||||
@@ -119,15 +122,16 @@ function lineGraphic(mods: EsriModules, points: MapPoint[], color: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
|
function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
|
||||||
|
const liveColor = semanticColor('--ul-color-primary');
|
||||||
const position = { type: 'point' as const, longitude: status.lng, latitude: status.lat };
|
const position = { type: 'point' as const, longitude: status.lng, latitude: status.lat };
|
||||||
const marker = new mods.Graphic({
|
const marker = new mods.Graphic({
|
||||||
geometry: position,
|
geometry: position,
|
||||||
symbol: {
|
symbol: {
|
||||||
type: 'simple-marker',
|
type: 'simple-marker',
|
||||||
style: 'circle',
|
style: 'circle',
|
||||||
color: LIVE_COLOR,
|
color: liveColor,
|
||||||
size: 16,
|
size: 16,
|
||||||
outline: { color: '#ffffff', width: 2 },
|
outline: { color: semanticColor('--ul-color-on-primary'), width: 2 },
|
||||||
},
|
},
|
||||||
popupTemplate: {
|
popupTemplate: {
|
||||||
title: `Live · ${status.serial}`,
|
title: `Live · ${status.serial}`,
|
||||||
@@ -144,15 +148,15 @@ function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
|
|||||||
}),
|
}),
|
||||||
symbol: {
|
symbol: {
|
||||||
type: 'simple-fill',
|
type: 'simple-fill',
|
||||||
color: [...hexToRgb(LIVE_COLOR), 0.15],
|
color: [...hexChannels(liveColor), 0.15],
|
||||||
outline: { color: [...hexToRgb(LIVE_COLOR), 0.3], width: 1 },
|
outline: { color: [...hexChannels(liveColor), 0.3], width: 1 },
|
||||||
},
|
},
|
||||||
} as ConstructorParameters<typeof GraphicConstructor>[0]);
|
} as ConstructorParameters<typeof GraphicConstructor>[0]);
|
||||||
|
|
||||||
return [halo, marker];
|
return [halo, marker];
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgb(hex: string): [number, number, number] {
|
function hexChannels(hex: string): [number, number, number] {
|
||||||
const n = parseInt(hex.slice(1), 16);
|
const n = parseInt(hex.slice(1), 16);
|
||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
}
|
}
|
||||||
@@ -165,6 +169,9 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
|||||||
const modsRef = useRef<EsriModules | null>(null);
|
const modsRef = useRef<EsriModules | null>(null);
|
||||||
const fittedSignatureRef = useRef('');
|
const fittedSignatureRef = useRef('');
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const sizeClass = heightPx <= 360 ? 'ul-map--compact' : heightPx >= 500 ? 'ul-map--detail' : '';
|
||||||
|
const mapClassName = ['ul-map', sizeClass].filter(Boolean).join(' ');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !API_KEY) {
|
if (!containerRef.current || !API_KEY) {
|
||||||
@@ -173,26 +180,33 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let view: InstanceType<typeof MapViewConstructor> | undefined;
|
let view: InstanceType<typeof MapViewConstructor> | undefined;
|
||||||
|
|
||||||
loadEsriModules().then((mods) => {
|
loadEsriModules()
|
||||||
if (cancelled || !containerRef.current) {
|
.then((mods) => {
|
||||||
return;
|
if (cancelled || !containerRef.current) {
|
||||||
}
|
return;
|
||||||
const pointsLayer = new mods.GraphicsLayer();
|
}
|
||||||
const liveLayer = new mods.GraphicsLayer();
|
const pointsLayer = new mods.GraphicsLayer();
|
||||||
const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] });
|
const liveLayer = new mods.GraphicsLayer();
|
||||||
view = new mods.MapView({
|
const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] });
|
||||||
container: containerRef.current,
|
view = new mods.MapView({
|
||||||
map,
|
container: containerRef.current,
|
||||||
center: DEFAULT_CENTER,
|
map,
|
||||||
zoom: DEFAULT_ZOOM,
|
center: DEFAULT_CENTER,
|
||||||
});
|
zoom: DEFAULT_ZOOM,
|
||||||
|
});
|
||||||
|
|
||||||
modsRef.current = mods;
|
modsRef.current = mods;
|
||||||
pointsLayerRef.current = pointsLayer;
|
pointsLayerRef.current = pointsLayer;
|
||||||
liveLayerRef.current = liveLayer;
|
liveLayerRef.current = liveLayer;
|
||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
setReady(true);
|
setReady(true);
|
||||||
});
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('EsriJobMap: map failed to load', error);
|
||||||
|
setLoadError(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -267,25 +281,23 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
|||||||
|
|
||||||
if (!API_KEY) {
|
if (!API_KEY) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`${mapClassName} ul-map__fallback`} role="status">
|
||||||
style={{
|
|
||||||
height: heightPx,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
border: '1px dashed #999',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: '#666',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Set NEXT_PUBLIC_ARCGIS_API_KEY in .env to enable the map.
|
Set NEXT_PUBLIC_ARCGIS_API_KEY in .env to enable the map.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className={`${mapClassName} ul-map__fallback`} role="alert">
|
||||||
|
The locate map could not be loaded. Check the network connection and ArcGIS configuration.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: heightPx, borderRadius: 8, overflow: 'hidden' }}>
|
<div className={mapClassName} role="region" aria-label="Interactive locate map" aria-busy={!ready}>
|
||||||
<div ref={containerRef} style={{ height: '100%' }} />
|
<div ref={containerRef} className="ul-map__canvas" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { LiveStatus, MapPoint } from '../components/map/types';
|
import type { LiveStatus, MapPoint } from "../components/map/types";
|
||||||
|
|
||||||
|
export type JobStreamState = "connecting" | "live" | "offline" | "error";
|
||||||
|
|
||||||
// Subscribes to live points + live position status for a job over the
|
// Subscribes to live points + live position status for a job over the
|
||||||
// backend WebSocket. Reconnects with capped exponential backoff; resubscribes
|
// backend WebSocket. Reconnects with capped exponential backoff; resubscribes
|
||||||
@@ -9,6 +11,8 @@ export function useJobStream(
|
|||||||
onPoints: (points: MapPoint[]) => void,
|
onPoints: (points: MapPoint[]) => void,
|
||||||
onStatus?: (status: LiveStatus) => void,
|
onStatus?: (status: LiveStatus) => void,
|
||||||
) {
|
) {
|
||||||
|
const [connectionState, setConnectionState] =
|
||||||
|
useState<JobStreamState>("connecting");
|
||||||
const pointsRef = useRef(onPoints);
|
const pointsRef = useRef(onPoints);
|
||||||
pointsRef.current = onPoints;
|
pointsRef.current = onPoints;
|
||||||
const statusRef = useRef(onStatus);
|
const statusRef = useRef(onStatus);
|
||||||
@@ -16,6 +20,7 @@ export function useJobStream(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
|
setConnectionState("offline");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,12 +30,17 @@ export function useJobStream(
|
|||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
let socketErrored = false;
|
||||||
|
setConnectionState(attempt === 0 ? "connecting" : "offline");
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
attempt = 0;
|
attempt = 0;
|
||||||
socket?.send(JSON.stringify({ type: 'subscribe', channel: `job:${jobId}` }));
|
setConnectionState("live");
|
||||||
|
socket?.send(
|
||||||
|
JSON.stringify({ type: "subscribe", channel: `job:${jobId}` }),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
@@ -39,9 +49,9 @@ export function useJobStream(
|
|||||||
if (msg.jobId !== jobId) {
|
if (msg.jobId !== jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'points') {
|
if (msg.type === "points") {
|
||||||
pointsRef.current(msg.points);
|
pointsRef.current(msg.points);
|
||||||
} else if (msg.type === 'status') {
|
} else if (msg.type === "status") {
|
||||||
statusRef.current?.(msg as LiveStatus);
|
statusRef.current?.(msg as LiveStatus);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -49,10 +59,18 @@ export function useJobStream(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socketErrored = true;
|
||||||
|
setConnectionState("error");
|
||||||
|
};
|
||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!socketErrored) {
|
||||||
|
setConnectionState("offline");
|
||||||
|
}
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
const delay = Math.min(1000 * 2 ** attempt, 15000);
|
const delay = Math.min(1000 * 2 ** attempt, 15000);
|
||||||
timer = setTimeout(connect, delay);
|
timer = setTimeout(connect, delay);
|
||||||
@@ -69,4 +87,6 @@ export function useJobStream(
|
|||||||
socket?.close();
|
socket?.close();
|
||||||
};
|
};
|
||||||
}, [jobId]);
|
}, [jobId]);
|
||||||
|
|
||||||
|
return connectionState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "node --test tests/*.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esri-loader": "^3.7.0",
|
"esri-loader": "^3.7.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AppProps } from 'next/app';
|
import type { AppProps } from "next/app";
|
||||||
import { AuthProvider } from '../lib/auth-context';
|
import { AuthProvider } from "../lib/auth-context";
|
||||||
|
import "../styles/global.css";
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import Layout from '../components/Layout';
|
import Layout from "../components/Layout";
|
||||||
import { api } from '../lib/api';
|
import { humanizeStatus, PageState, StatusBadge } from "../components/PortalUi";
|
||||||
import { useRequireAuth } from '../lib/auth-context';
|
import { api } from "../lib/api";
|
||||||
|
import { useRequireAuth } from "../lib/auth-context";
|
||||||
|
|
||||||
interface JobRow {
|
interface JobRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,103 +17,146 @@ interface JobRow {
|
|||||||
_count: { points: number };
|
_count: { points: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUSES = ['', 'OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
const STATUSES = ["", "OPEN", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
OPEN: '#1976d2',
|
|
||||||
IN_PROGRESS: '#f57c00',
|
|
||||||
COMPLETED: '#388e3c',
|
|
||||||
CANCELLED: '#9e9e9e',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function JobsPage() {
|
export default function JobsPage() {
|
||||||
const { user, activeOrg, loading } = useRequireAuth();
|
const { user, activeOrg, loading } = useRequireAuth();
|
||||||
const [jobs, setJobs] = useState<JobRow[]>([]);
|
const [jobs, setJobs] = useState<JobRow[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState("");
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeOrg) {
|
if (!activeOrg) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (status) params.set('status', status);
|
if (status) params.set("status", status);
|
||||||
if (q) params.set('q', q);
|
if (q) params.set("q", q);
|
||||||
|
setFetching(true);
|
||||||
api
|
api
|
||||||
.get<{ jobs: JobRow[]; total: number }>(`/api/orgs/${activeOrg.org.id}/jobs?${params}`)
|
.get<{ jobs: JobRow[]; total: number }>(
|
||||||
|
`/api/orgs/${activeOrg.org.id}/jobs?${params}`,
|
||||||
|
)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
setJobs(res.jobs);
|
setJobs(res.jobs);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
setError(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message));
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setFetching(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [activeOrg, status, q]);
|
}, [activeOrg, status, q]);
|
||||||
|
|
||||||
if (loading || !user) {
|
if (loading || !user) {
|
||||||
return null;
|
return <PageState kind="loading" title="Loading your workspace…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Jobs">
|
<Layout title="Jobs">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
<header className="ul-page-header">
|
||||||
<h1 style={{ margin: 0 }}>Jobs</h1>
|
<div className="ul-page-header__title-group">
|
||||||
<span style={{ color: '#888' }}>{total} total</span>
|
<span className="ul-page-header__eyebrow">Operations</span>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
<h1>Jobs</h1>
|
||||||
<input placeholder="Search ticket, title, address…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<p className="ul-page-header__meta" aria-live="polite">
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
{fetching
|
||||||
{STATUSES.map((s) => (
|
? "Updating jobs…"
|
||||||
<option key={s} value={s}>
|
: `${total} ${total === 1 ? "job" : "jobs"}`}
|
||||||
{s || 'All statuses'}
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/jobs/new" className="ul-button">
|
||||||
|
<span aria-hidden="true">+</span> New job
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="ul-panel ul-filter-bar" aria-label="Job filters">
|
||||||
|
<label className="ul-field">
|
||||||
|
<span className="ul-field__label">Search jobs</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Ticket, title, or address"
|
||||||
|
value={q}
|
||||||
|
onChange={(event) => setQ(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="ul-field">
|
||||||
|
<span className="ul-field__label">Status</span>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(event) => setStatus(event.target.value)}
|
||||||
|
>
|
||||||
|
{STATUSES.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item ? humanizeStatus(item) : "All statuses"}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Link href="/jobs/new">
|
</label>
|
||||||
<button>+ New job</button>
|
</section>
|
||||||
</Link>
|
|
||||||
|
{error ? (
|
||||||
|
<PageState kind="error" title="Jobs could not be loaded">
|
||||||
|
{error}
|
||||||
|
</PageState>
|
||||||
|
) : jobs.length === 0 && !fetching ? (
|
||||||
|
<PageState kind="empty" title="No jobs found">
|
||||||
|
{q || status
|
||||||
|
? "Try clearing or changing the current filters."
|
||||||
|
: "Create a job, or let a field device post points to auto-create its ticket."}
|
||||||
|
</PageState>
|
||||||
|
) : (
|
||||||
|
<div className="ul-panel ul-table-wrap" aria-busy={fetching}>
|
||||||
|
<table className="ul-table">
|
||||||
|
<caption className="ul-visually-hidden">
|
||||||
|
Jobs for the active organization
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Ticket</th>
|
||||||
|
<th scope="col">Title</th>
|
||||||
|
<th scope="col">Address</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Source</th>
|
||||||
|
<th scope="col">Assignee</th>
|
||||||
|
<th scope="col" className="ul-table__numeric">
|
||||||
|
Points
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<tr key={job.id}>
|
||||||
|
<td data-label="Ticket">
|
||||||
|
<Link className="ul-ticket-link" href={`/jobs/${job.id}`}>
|
||||||
|
{job.ticketNumber}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td data-label="Title">{job.title}</td>
|
||||||
|
<td data-label="Address">{job.address ?? "—"}</td>
|
||||||
|
<td data-label="Status">
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</td>
|
||||||
|
<td data-label="Source">{humanizeStatus(job.source)}</td>
|
||||||
|
<td data-label="Assignee">{job.assignedTo?.name ?? "—"}</td>
|
||||||
|
<td data-label="Points" className="ul-table__numeric">
|
||||||
|
{job._count.points}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
|
||||||
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
|
||||||
<th style={{ padding: '0.5rem' }}>Ticket</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Address</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Source</th>
|
|
||||||
<th>Assignee</th>
|
|
||||||
<th style={{ textAlign: 'right' }}>Points</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{jobs.map((job) => (
|
|
||||||
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
|
|
||||||
<td style={{ padding: '0.5rem' }}>
|
|
||||||
<Link href={`/jobs/${job.id}`}>{job.ticketNumber}</Link>
|
|
||||||
</td>
|
|
||||||
<td>{job.title}</td>
|
|
||||||
<td>{job.address ?? '—'}</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ color: STATUS_COLORS[job.status] ?? '#333', fontWeight: 600 }}>{job.status}</span>
|
|
||||||
</td>
|
|
||||||
<td>{job.source}</td>
|
|
||||||
<td>{job.assignedTo?.name ?? '—'}</td>
|
|
||||||
<td style={{ textAlign: 'right' }}>{job._count.points}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{jobs.length === 0 && !error && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
|
|
||||||
No jobs yet. Create one, or let a device post points to auto-create its ticket.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import { useRouter } from 'next/router';
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useRouter } from "next/router";
|
||||||
import Layout from '../../components/Layout';
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import JobMap from '../../components/map/JobMap';
|
import Layout from "../../components/Layout";
|
||||||
import { pointSummary, type LiveStatus, type MapPoint } from '../../components/map/types';
|
import {
|
||||||
import { api } from '../../lib/api';
|
humanizeStatus,
|
||||||
import { useRequireAuth } from '../../lib/auth-context';
|
Metric,
|
||||||
import { useJobStream } from '../../lib/use-job-stream';
|
PageState,
|
||||||
|
StatusBadge,
|
||||||
|
} from "../../components/PortalUi";
|
||||||
|
import JobMap from "../../components/map/JobMap";
|
||||||
|
import {
|
||||||
|
pointSummary,
|
||||||
|
type LiveStatus,
|
||||||
|
type MapPoint,
|
||||||
|
} from "../../components/map/types";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { useRequireAuth } from "../../lib/auth-context";
|
||||||
|
import { useJobStream } from "../../lib/use-job-stream";
|
||||||
|
|
||||||
interface JobDetail {
|
interface JobDetail {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,12 +32,13 @@ interface JobDetail {
|
|||||||
_count: { points: number };
|
_count: { points: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUSES = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
const STATUSES = ["OPEN", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
|
||||||
|
|
||||||
export default function JobDetailPage() {
|
export default function JobDetailPage() {
|
||||||
const { user, activeOrg, loading } = useRequireAuth();
|
const { user, activeOrg, loading } = useRequireAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const jobId = typeof router.query.jobId === 'string' ? router.query.jobId : null;
|
const jobId =
|
||||||
|
typeof router.query.jobId === "string" ? router.query.jobId : null;
|
||||||
const orgId = activeOrg?.org.id ?? null;
|
const orgId = activeOrg?.org.id ?? null;
|
||||||
|
|
||||||
const [job, setJob] = useState<JobDetail | null>(null);
|
const [job, setJob] = useState<JobDetail | null>(null);
|
||||||
@@ -34,19 +46,40 @@ export default function JobDetailPage() {
|
|||||||
const [live, setLive] = useState(0);
|
const [live, setLive] = useState(0);
|
||||||
const [liveStatus, setLiveStatus] = useState<LiveStatus | null>(null);
|
const [liveStatus, setLiveStatus] = useState<LiveStatus | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orgId || !jobId) {
|
if (!orgId || !jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api
|
let cancelled = false;
|
||||||
.get<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`)
|
setJob(null);
|
||||||
.then(setJob)
|
setPoints([]);
|
||||||
.catch((err) => setError(err.message));
|
setLive(0);
|
||||||
api
|
setLiveStatus(null);
|
||||||
.get<{ points: MapPoint[] }>(`/api/orgs/${orgId}/jobs/${jobId}/points`)
|
setError(null);
|
||||||
.then((res) => setPoints(res.points))
|
setFetching(true);
|
||||||
.catch((err) => setError(err.message));
|
Promise.all([
|
||||||
|
api.get<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`),
|
||||||
|
api.get<{ points: MapPoint[] }>(
|
||||||
|
`/api/orgs/${orgId}/jobs/${jobId}/points`,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([jobResult, pointResult]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setJob(jobResult);
|
||||||
|
setPoints(pointResult.points);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setFetching(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [orgId, jobId]);
|
}, [orgId, jobId]);
|
||||||
|
|
||||||
const onLivePoints = useCallback((incoming: MapPoint[]) => {
|
const onLivePoints = useCallback((incoming: MapPoint[]) => {
|
||||||
@@ -58,67 +91,163 @@ export default function JobDetailPage() {
|
|||||||
setLive((n) => n + incoming.length);
|
setLive((n) => n + incoming.length);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onStatus = useCallback((status: LiveStatus) => setLiveStatus(status), []);
|
const onStatus = useCallback(
|
||||||
|
(status: LiveStatus) => setLiveStatus(status),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useJobStream(jobId, onLivePoints, onStatus);
|
const streamState = useJobStream(jobId, onLivePoints, onStatus);
|
||||||
|
|
||||||
const updateStatus = async (status: string) => {
|
const updateStatus = async (status: string) => {
|
||||||
if (!orgId || !jobId) {
|
if (!orgId || !jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setUpdatingStatus(true);
|
||||||
try {
|
try {
|
||||||
setJob(await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, { status }));
|
setJob(
|
||||||
|
await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, {
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setUpdatingStatus(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading || !user) {
|
if (loading || !user) {
|
||||||
return null;
|
return <PageState kind="loading" title="Loading your workspace…" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const streamStatus = streamState.toUpperCase();
|
||||||
<Layout title={job ? job.ticketNumber : 'Job'}>
|
const streamLabel =
|
||||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
streamState === "live"
|
||||||
{job && (
|
? live > 0
|
||||||
<>
|
? `Live · ${live} new ${live === 1 ? "point" : "points"} this session`
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '1rem', flexWrap: 'wrap' }}>
|
: "Live · waiting for activity"
|
||||||
<h1 style={{ margin: 0 }}>{job.ticketNumber}</h1>
|
: undefined;
|
||||||
<span style={{ fontSize: '1.1rem' }}>{job.title}</span>
|
|
||||||
<select value={job.status} onChange={(e) => updateStatus(e.target.value)} style={{ marginLeft: 'auto' }}>
|
|
||||||
{STATUSES.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p style={{ color: '#666' }}>
|
|
||||||
{job.address && <>{job.address} · </>}
|
|
||||||
source {job.source} · created {new Date(job.createdAt).toLocaleString()}
|
|
||||||
{job.assignedTo && <> · assigned to {job.assignedTo.name}</>}
|
|
||||||
{job.dueAt && <> · locate by {new Date(job.dueAt).toLocaleString()}</>}
|
|
||||||
</p>
|
|
||||||
{job.description && <p>{job.description}</p>}
|
|
||||||
|
|
||||||
<div style={{ margin: '1rem 0', display: 'flex', gap: '1rem', color: '#555', flexWrap: 'wrap' }}>
|
return (
|
||||||
<span>
|
<Layout title={job ? job.ticketNumber : "Job"}>
|
||||||
<strong>{points.length}</strong> points
|
<nav className="ul-breadcrumb" aria-label="Breadcrumb">
|
||||||
</span>
|
<Link href="/">← All jobs</Link>
|
||||||
<span style={{ color: live > 0 ? '#388e3c' : '#999' }}>
|
</nav>
|
||||||
● live{live > 0 ? ` (+${live} this session)` : ''}
|
|
||||||
</span>
|
{error && !job ? (
|
||||||
{points.length > 0 && (
|
<PageState kind="error" title="Job could not be loaded">
|
||||||
<span style={{ color: '#777' }}>latest: {pointSummary(points[points.length - 1])}</span>
|
{error}
|
||||||
|
</PageState>
|
||||||
|
) : fetching || !job ? (
|
||||||
|
<PageState kind="loading" title="Loading job and locate points…" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<header className="ul-detail-header">
|
||||||
|
<div>
|
||||||
|
<span className="ul-detail-header__eyebrow">Locate job</span>
|
||||||
|
<div className="ul-detail-header__title-row">
|
||||||
|
<h1>{job.ticketNumber}</h1>
|
||||||
|
<span className="ul-detail-header__title">{job.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="ul-detail-header__subtitle">
|
||||||
|
{job.address ?? "No address"} · {humanizeStatus(job.source)}{" "}
|
||||||
|
source
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="ul-field">
|
||||||
|
<span className="ul-field__label">Job status</span>
|
||||||
|
<select
|
||||||
|
value={job.status}
|
||||||
|
disabled={updatingStatus}
|
||||||
|
aria-describedby="job-status-update"
|
||||||
|
onChange={(event) => updateStatus(event.target.value)}
|
||||||
|
>
|
||||||
|
{STATUSES.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{humanizeStatus(status)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
id="job-status-update"
|
||||||
|
className="ul-visually-hidden"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{updatingStatus
|
||||||
|
? "Updating job status"
|
||||||
|
: `Current status: ${humanizeStatus(job.status)}`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<PageState kind="error" title="The latest update failed">
|
||||||
|
{error}
|
||||||
|
</PageState>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="ul-panel ul-job-summary" aria-label="Job summary">
|
||||||
|
<Metric
|
||||||
|
label="Status"
|
||||||
|
value={<StatusBadge status={job.status} />}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Created"
|
||||||
|
value={new Date(job.createdAt).toLocaleDateString()}
|
||||||
|
detail={new Date(job.createdAt).toLocaleTimeString()}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Locate by"
|
||||||
|
value={
|
||||||
|
job.dueAt ? new Date(job.dueAt).toLocaleDateString() : "Not set"
|
||||||
|
}
|
||||||
|
detail={
|
||||||
|
job.dueAt ? new Date(job.dueAt).toLocaleTimeString() : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Assigned to"
|
||||||
|
value={job.assignedTo?.name ?? "Unassigned"}
|
||||||
|
detail={job.assignedTo?.email}
|
||||||
|
/>
|
||||||
|
<Metric label="Source" value={humanizeStatus(job.source)} />
|
||||||
|
<Metric label="Recorded points" value={points.length} />
|
||||||
|
{job.description && (
|
||||||
|
<p className="ul-job-summary__description">{job.description}</p>
|
||||||
)}
|
)}
|
||||||
{liveStatus && (
|
</section>
|
||||||
<span style={{ color: '#1a73e8' }}>
|
|
||||||
● transmitter {liveStatus.serial} live ({new Date(liveStatus.recordedAt).toLocaleTimeString()})
|
<div className="ul-live-strip" aria-live="polite">
|
||||||
|
<StatusBadge status={streamStatus} label={streamLabel} />
|
||||||
|
{points.length > 0 && (
|
||||||
|
<span className="ul-live-strip__latest">
|
||||||
|
Latest point: {pointSummary(points[points.length - 1])}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{liveStatus && (
|
||||||
|
<StatusBadge
|
||||||
|
status="LIVE"
|
||||||
|
label={`Transmitter ${liveStatus.serial} · ${new Date(liveStatus.recordedAt).toLocaleTimeString()}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<JobMap points={points} liveStatus={liveStatus} heightPx={520} />
|
<section
|
||||||
|
className="ul-map-section"
|
||||||
|
aria-labelledby="live-map-heading"
|
||||||
|
>
|
||||||
|
<div className="ul-section-heading">
|
||||||
|
<div>
|
||||||
|
<span className="ul-section-heading__eyebrow">
|
||||||
|
Live operations
|
||||||
|
</span>
|
||||||
|
<h2 id="live-map-heading">Locate map</h2>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={streamStatus} />
|
||||||
|
</div>
|
||||||
|
<JobMap points={points} liveStatus={liveStatus} heightPx={520} />
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
BIN
web/public/um-trace-mark.png
Normal file
BIN
web/public/um-trace-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
859
web/styles/global.css
Normal file
859
web/styles/global.css
Normal file
@@ -0,0 +1,859 @@
|
|||||||
|
@import "./tokens.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--portal-canvas: var(--ul-color-canvas);
|
||||||
|
--portal-surface: var(--ul-color-surface);
|
||||||
|
--portal-surface-subtle: var(--ul-color-surface-raised);
|
||||||
|
--portal-map-field: var(--ul-color-map);
|
||||||
|
--portal-text: var(--ul-color-text);
|
||||||
|
--portal-text-muted: var(--ul-color-text-muted);
|
||||||
|
--portal-primary: var(--ul-color-primary);
|
||||||
|
--portal-on-primary: var(--ul-color-on-primary);
|
||||||
|
--portal-attention: var(--ul-color-accent);
|
||||||
|
--portal-success: var(--ul-color-success);
|
||||||
|
--portal-warning: var(--ul-color-warning);
|
||||||
|
--portal-danger: var(--ul-color-error);
|
||||||
|
--portal-border: var(--ul-color-border);
|
||||||
|
--portal-border-strong: var(--ul-color-text-muted);
|
||||||
|
--portal-focus: var(--ul-color-focus);
|
||||||
|
--portal-font-ui: var(--ul-type-family-sans);
|
||||||
|
--portal-font-mono: var(--ul-type-family-mono);
|
||||||
|
--portal-space-1: var(--ul-space-2xs);
|
||||||
|
--portal-space-2: var(--ul-space-xs);
|
||||||
|
--portal-space-3: var(--ul-space-sm);
|
||||||
|
--portal-space-4: var(--ul-space-md);
|
||||||
|
--portal-space-6: var(--ul-space-lg);
|
||||||
|
--portal-space-8: var(--ul-space-xl);
|
||||||
|
--portal-radius-sm: var(--ul-radius-sm);
|
||||||
|
--portal-radius-md: var(--ul-radius-md);
|
||||||
|
--portal-shadow: var(--ul-elevation-raised);
|
||||||
|
--portal-motion: var(--ul-motion-base) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: var(--portal-canvas);
|
||||||
|
color: var(--portal-text);
|
||||||
|
font-family: var(--portal-font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 20rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--portal-canvas);
|
||||||
|
color: var(--portal-text);
|
||||||
|
font-family: var(--portal-font-ui);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--portal-primary);
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
input[type="button"],
|
||||||
|
input[type="submit"] {
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
border: 1px solid var(--portal-border-strong);
|
||||||
|
border-radius: var(--portal-radius-sm);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
padding: var(--portal-space-2) var(--portal-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(a, button, input, select, textarea, summary):focus-visible {
|
||||||
|
outline: 3px solid var(--portal-focus);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
top: var(--portal-space-2);
|
||||||
|
left: var(--portal-space-2);
|
||||||
|
translate: 0 -200%;
|
||||||
|
border-radius: var(--portal-radius-sm);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
padding: var(--portal-space-3) var(--portal-space-4);
|
||||||
|
color: var(--portal-text);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: var(--portal-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-skip-link:focus {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 50;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--portal-space-6);
|
||||||
|
border-bottom: 1px solid var(--portal-border);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
padding: var(--portal-space-3) clamp(1rem, 3vw, 2rem);
|
||||||
|
box-shadow: var(--portal-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__brand {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--portal-space-2);
|
||||||
|
color: var(--portal-text);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 750;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__brand-mark {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-radius: var(--portal-radius-sm);
|
||||||
|
border: 1px solid var(--portal-border);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--portal-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__nav-link {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--portal-radius-sm);
|
||||||
|
padding: 0 var(--portal-space-3);
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__nav-link:hover,
|
||||||
|
.ul-shell__nav-link[aria-current="page"] {
|
||||||
|
background: var(--portal-surface-subtle);
|
||||||
|
color: var(--portal-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__nav-link[aria-current="page"] {
|
||||||
|
box-shadow: inset 0 -0.2rem var(--portal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__context {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--portal-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-org-context {
|
||||||
|
display: grid;
|
||||||
|
min-width: 10rem;
|
||||||
|
grid-template-columns: minmax(0, auto) auto;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: var(--portal-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-org-context__label {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-org-context__select {
|
||||||
|
max-width: 13rem;
|
||||||
|
height: var(--ul-touch-minimum);
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
padding-block: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-org-context__name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-org-context__role {
|
||||||
|
border: 1px solid var(--portal-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 var(--portal-space-2);
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--portal-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__email {
|
||||||
|
max-width: 12rem;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__menu {
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__menu summary {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--portal-border-strong);
|
||||||
|
border-radius: var(--portal-radius-sm);
|
||||||
|
padding: 0 var(--portal-space-3);
|
||||||
|
font-weight: 700;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__menu summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__nav--mobile {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--portal-space-2));
|
||||||
|
right: 0;
|
||||||
|
width: min(18rem, calc(100vw - 2rem));
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
border: 1px solid var(--portal-border);
|
||||||
|
border-radius: var(--portal-radius-md);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
padding: var(--portal-space-2);
|
||||||
|
box-shadow: var(--portal-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__mobile-account {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--portal-space-2);
|
||||||
|
margin-top: var(--portal-space-2);
|
||||||
|
border-top: 1px solid var(--portal-border);
|
||||||
|
padding: var(--portal-space-3) var(--portal-space-2) var(--portal-space-2);
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__main {
|
||||||
|
width: min(100% - 2rem, 75rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-block: clamp(1.25rem, 4vw, 2.5rem) 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--portal-radius-sm);
|
||||||
|
background: var(--portal-primary);
|
||||||
|
padding: 0 var(--portal-space-4);
|
||||||
|
color: var(--portal-on-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
filter var(--portal-motion),
|
||||||
|
transform var(--portal-motion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-button:hover {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-button--quiet {
|
||||||
|
border-color: var(--portal-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--portal-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--portal-space-4);
|
||||||
|
margin-bottom: var(--portal-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-header__title-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-header h1,
|
||||||
|
.ul-detail-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-header__eyebrow,
|
||||||
|
.ul-section-heading__eyebrow,
|
||||||
|
.ul-detail-header__eyebrow {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--portal-space-1);
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-header__meta,
|
||||||
|
.ul-detail-header__subtitle {
|
||||||
|
margin: var(--portal-space-1) 0 0;
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-panel {
|
||||||
|
border: 1px solid var(--portal-border);
|
||||||
|
border-radius: var(--portal-radius-md);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
box-shadow: var(--portal-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-filter-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(14rem, 1fr) minmax(10rem, auto);
|
||||||
|
gap: var(--portal-space-3);
|
||||||
|
margin-bottom: var(--portal-space-4);
|
||||||
|
padding: var(--portal-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-field {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--portal-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-field__label {
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table th,
|
||||||
|
.ul-table td {
|
||||||
|
padding: var(--portal-space-3) var(--portal-space-4);
|
||||||
|
border-bottom: 1px solid var(--portal-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table th {
|
||||||
|
background: var(--portal-surface-subtle);
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table tbody tr:hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--portal-primary) 5%,
|
||||||
|
var(--portal-surface)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table__numeric {
|
||||||
|
font-family: var(--portal-font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-ticket-link {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--portal-font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--portal-space-2);
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 var(--portal-space-3);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-status-badge__icon {
|
||||||
|
font-family: var(--portal-font-mono);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-status-badge--info {
|
||||||
|
color: var(--portal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-status-badge--success {
|
||||||
|
color: var(--portal-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-status-badge--warning {
|
||||||
|
color: var(--portal-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-status-badge--danger {
|
||||||
|
color: var(--portal-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-status-badge--neutral {
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-state {
|
||||||
|
display: flex;
|
||||||
|
min-height: 7rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--portal-space-3);
|
||||||
|
border: 1px dashed var(--portal-border-strong);
|
||||||
|
border-radius: var(--portal-radius-md);
|
||||||
|
background: var(--portal-surface-subtle);
|
||||||
|
padding: var(--portal-space-6);
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-state--error {
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--portal-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-state__icon {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-family: var(--portal-font-mono);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-state__detail {
|
||||||
|
margin-top: var(--portal-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-detail-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(12rem, auto);
|
||||||
|
gap: var(--portal-space-6);
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: var(--portal-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-detail-header__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--portal-space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-detail-header__title {
|
||||||
|
font-size: clamp(1.125rem, 2vw, 1.375rem);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-breadcrumb {
|
||||||
|
margin-bottom: var(--portal-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-breadcrumb a {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-job-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--portal-space-4);
|
||||||
|
margin-block: var(--portal-space-4) var(--portal-space-6);
|
||||||
|
padding: var(--portal-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-job-summary__description {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
padding-top: var(--portal-space-3);
|
||||||
|
border-top: 1px solid var(--portal-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-metric {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--portal-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-metric__label {
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-metric__value {
|
||||||
|
font-family: var(--portal-font-mono);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-metric__detail {
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-live-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--portal-space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--portal-space-4);
|
||||||
|
border: 1px solid var(--portal-border);
|
||||||
|
border-radius: var(--portal-radius-md);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
padding: var(--portal-space-3) var(--portal-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-live-strip__latest {
|
||||||
|
min-width: 12rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-family: var(--portal-font-mono);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map-section {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--portal-border);
|
||||||
|
border-radius: var(--portal-radius-md);
|
||||||
|
background: var(--portal-map-field);
|
||||||
|
box-shadow: var(--portal-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--portal-space-4);
|
||||||
|
border-bottom: 1px solid var(--portal-border);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
padding: var(--portal-space-3) var(--portal-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map {
|
||||||
|
position: relative;
|
||||||
|
height: 30rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--portal-map-field);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map--compact {
|
||||||
|
height: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map--detail {
|
||||||
|
height: min(32.5rem, 65vh);
|
||||||
|
min-height: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map__canvas {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map__fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--portal-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map-popup-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--portal-font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map-popup-table td {
|
||||||
|
padding-block: var(--portal-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map-popup-table__label {
|
||||||
|
padding-right: var(--portal-space-3);
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map-popup-table__value {
|
||||||
|
font-family: var(--portal-font-mono);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0 0 0 0) !important;
|
||||||
|
clip-path: inset(50%) !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS custom properties are not valid in media conditions; values mirror the generated breakpoint tokens. */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.ul-shell__nav--desktop,
|
||||||
|
.ul-shell__email {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.ul-shell__header {
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--portal-space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__context {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-org-context {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__account {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-header,
|
||||||
|
.ul-detail-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-header .ul-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-filter-bar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table-wrap {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table,
|
||||||
|
.ul-table tbody,
|
||||||
|
.ul-table tr,
|
||||||
|
.ul-table td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table tbody {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--portal-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table tbody tr {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--portal-border);
|
||||||
|
border-radius: var(--portal-radius-md);
|
||||||
|
background: var(--portal-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table td {
|
||||||
|
display: grid;
|
||||||
|
min-height: var(--ul-touch-minimum);
|
||||||
|
grid-template-columns: 7rem minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--portal-space-3);
|
||||||
|
border-bottom: 1px solid var(--portal-border);
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
color: var(--portal-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table td:first-child {
|
||||||
|
background: var(--portal-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-table td:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-job-summary {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map--detail {
|
||||||
|
height: 28rem;
|
||||||
|
min-height: 22rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.ul-shell__header {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__brand span:last-child,
|
||||||
|
.ul-shell__account {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-org-context__role {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-shell__main {
|
||||||
|
width: min(100% - 1.5rem, 75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-page-header h1,
|
||||||
|
.ul-detail-header h1 {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-job-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-job-summary__description {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-live-strip {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ul-map--detail {
|
||||||
|
height: 24rem;
|
||||||
|
min-height: 20rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.ul-status-badge,
|
||||||
|
.ul-page-state,
|
||||||
|
.ul-panel,
|
||||||
|
.ul-map-section {
|
||||||
|
forced-color-adjust: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
web/styles/tokens.css
Normal file
99
web/styles/tokens.css
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/* GENERATED FILE — DO NOT EDIT.
|
||||||
|
* Source: meta/contracts/design-tokens.json v3.0.0
|
||||||
|
* Run: node meta/scripts/generate-design-tokens.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root,
|
||||||
|
[data-ul-theme="default"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--ul-color-canvas: #EEF3F2;
|
||||||
|
--ul-color-surface: #FCFEFD;
|
||||||
|
--ul-color-surface-raised: #FFFFFF;
|
||||||
|
--ul-color-surface-strong: #14252A;
|
||||||
|
--ul-color-text: #14252A;
|
||||||
|
--ul-color-text-muted: #5D6B6E;
|
||||||
|
--ul-color-primary: #0B7774;
|
||||||
|
--ul-color-primary-pressed: #075F5D;
|
||||||
|
--ul-color-accent: #B82D46;
|
||||||
|
--ul-color-border: #C7D2D0;
|
||||||
|
--ul-color-focus: #B82D46;
|
||||||
|
--ul-color-map: #DCE9E7;
|
||||||
|
--ul-color-success: #347A55;
|
||||||
|
--ul-color-warning: #A85C00;
|
||||||
|
--ul-color-error: #B42318;
|
||||||
|
--ul-color-neutral: #40565B;
|
||||||
|
--ul-color-disabled: #819092;
|
||||||
|
--ul-color-on-strong: #FFFFFF;
|
||||||
|
--ul-color-on-primary: #FFFFFF;
|
||||||
|
--ul-color-on-accent: #FFFFFF;
|
||||||
|
--ul-space-2xs: 4px;
|
||||||
|
--ul-space-xs: 8px;
|
||||||
|
--ul-space-sm: 12px;
|
||||||
|
--ul-space-md: 16px;
|
||||||
|
--ul-space-lg: 24px;
|
||||||
|
--ul-space-xl: 32px;
|
||||||
|
--ul-space-2xl: 48px;
|
||||||
|
--ul-radius-sm: 8px;
|
||||||
|
--ul-radius-md: 12px;
|
||||||
|
--ul-radius-lg: 20px;
|
||||||
|
--ul-radius-pill: 999px;
|
||||||
|
--ul-touch-minimum: 48px;
|
||||||
|
--ul-touch-comfortable: 56px;
|
||||||
|
--ul-touch-primary: 64px;
|
||||||
|
--ul-type-family-sans: "IBM Plex Sans", Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--ul-type-family-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
--ul-type-display-size: 2.5rem;
|
||||||
|
--ul-type-display-line-height: 1.2;
|
||||||
|
--ul-type-display-weight: 700;
|
||||||
|
--ul-type-page-size: 1.75rem;
|
||||||
|
--ul-type-page-line-height: 1.2142857142857142;
|
||||||
|
--ul-type-page-weight: 700;
|
||||||
|
--ul-type-section-size: 1.375rem;
|
||||||
|
--ul-type-section-line-height: 1.2727272727272727;
|
||||||
|
--ul-type-section-weight: 600;
|
||||||
|
--ul-type-body-size: 1rem;
|
||||||
|
--ul-type-body-line-height: 1.5;
|
||||||
|
--ul-type-body-weight: 400;
|
||||||
|
--ul-type-body-sm-size: 0.875rem;
|
||||||
|
--ul-type-body-sm-line-height: 1.4285714285714286;
|
||||||
|
--ul-type-body-sm-weight: 400;
|
||||||
|
--ul-type-label-size: 0.875rem;
|
||||||
|
--ul-type-label-line-height: 1.4285714285714286;
|
||||||
|
--ul-type-label-weight: 600;
|
||||||
|
--ul-type-mono-size: 1rem;
|
||||||
|
--ul-type-mono-line-height: 1.375;
|
||||||
|
--ul-type-mono-weight: 500;
|
||||||
|
--ul-motion-fast: 120ms;
|
||||||
|
--ul-motion-base: 200ms;
|
||||||
|
--ul-elevation-raised: 0 2px 8px rgba(20, 37, 42, 0.12);
|
||||||
|
--ul-elevation-overlay: 0 6px 16px rgba(20, 37, 42, 0.16);
|
||||||
|
--ul-breakpoint-compact: 600px;
|
||||||
|
--ul-breakpoint-medium: 900px;
|
||||||
|
--ul-breakpoint-wide: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[data-ul-theme="sunlight"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--ul-color-canvas: #FFFFFF;
|
||||||
|
--ul-color-surface: #FFFFFF;
|
||||||
|
--ul-color-surface-raised: #FFFFFF;
|
||||||
|
--ul-color-surface-strong: #000000;
|
||||||
|
--ul-color-text: #000000;
|
||||||
|
--ul-color-text-muted: #344448;
|
||||||
|
--ul-color-primary: #075F5D;
|
||||||
|
--ul-color-primary-pressed: #14252A;
|
||||||
|
--ul-color-accent: #982138;
|
||||||
|
--ul-color-border: #5D6B6E;
|
||||||
|
--ul-color-focus: #982138;
|
||||||
|
--ul-color-map: #E8F0EF;
|
||||||
|
--ul-color-success: #245F40;
|
||||||
|
--ul-color-warning: #754000;
|
||||||
|
--ul-color-error: #871A12;
|
||||||
|
--ul-color-neutral: #14252A;
|
||||||
|
--ul-color-disabled: #344448;
|
||||||
|
--ul-color-on-strong: #FFFFFF;
|
||||||
|
--ul-color-on-primary: #FFFFFF;
|
||||||
|
--ul-color-on-accent: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
151
web/tests/s3-portal.test.mjs
Normal file
151
web/tests/s3-portal.test.mjs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const read = (path) => readFileSync(resolve(webRoot, path), "utf8");
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
function loadTsxComponent(path) {
|
||||||
|
const typescript = require("typescript");
|
||||||
|
const output = typescript.transpileModule(read(path), {
|
||||||
|
compilerOptions: {
|
||||||
|
esModuleInterop: true,
|
||||||
|
jsx: typescript.JsxEmit.ReactJSX,
|
||||||
|
module: typescript.ModuleKind.CommonJS,
|
||||||
|
target: typescript.ScriptTarget.ES2020,
|
||||||
|
},
|
||||||
|
}).outputText;
|
||||||
|
const loadedModule = { exports: {} };
|
||||||
|
new Function("require", "module", "exports", output)(
|
||||||
|
require,
|
||||||
|
loadedModule,
|
||||||
|
loadedModule.exports,
|
||||||
|
);
|
||||||
|
return loadedModule.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
const migratedPresentation = [
|
||||||
|
"components/Layout.tsx",
|
||||||
|
"components/PortalUi.tsx",
|
||||||
|
"pages/index.tsx",
|
||||||
|
"pages/jobs/[jobId].tsx",
|
||||||
|
];
|
||||||
|
|
||||||
|
test("migrated shell and job pages use reusable classes without inline presentation", () => {
|
||||||
|
for (const path of migratedPresentation) {
|
||||||
|
const source = read(path);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/\bstyle\s*=/,
|
||||||
|
`${path} must not use inline style props`,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/#[\da-f]{3,8}\b/i,
|
||||||
|
`${path} must not define visual color literals`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the authenticated shell keeps organization and navigation context accessible", () => {
|
||||||
|
const layout = read("components/Layout.tsx");
|
||||||
|
assert.match(layout, /Active organization/);
|
||||||
|
assert.match(layout, /activeOrg\.role/);
|
||||||
|
assert.match(layout, /aria-current=/);
|
||||||
|
assert.match(layout, /Skip to main content/);
|
||||||
|
assert.match(layout, /aria-label="Primary navigation"/);
|
||||||
|
assert.match(layout, /src="\/um-trace-mark\.png"/);
|
||||||
|
assert.doesNotMatch(layout, />\s*UL\s*</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the portal uses the cropped UM foreground without nested icon padding", () => {
|
||||||
|
const layout = read("components/Layout.tsx");
|
||||||
|
const css = read("styles/global.css");
|
||||||
|
assert.match(layout, /src="\/um-trace-mark\.png"/);
|
||||||
|
assert.doesNotMatch(layout, /um-trace-icon\.svg/);
|
||||||
|
assert.match(css, /\.ul-shell__brand-mark\s*\{[^}]*object-fit:\s*contain;/s);
|
||||||
|
assert.match(css, /\.ul-shell__brand-mark\s*\{[^}]*padding:\s*0\.375rem;/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status presentation always combines a readable label with an icon", () => {
|
||||||
|
const components = read("components/PortalUi.tsx");
|
||||||
|
for (const status of [
|
||||||
|
"OPEN",
|
||||||
|
"IN_PROGRESS",
|
||||||
|
"COMPLETED",
|
||||||
|
"CANCELLED",
|
||||||
|
"LIVE",
|
||||||
|
"OFFLINE",
|
||||||
|
"ERROR",
|
||||||
|
]) {
|
||||||
|
assert.match(
|
||||||
|
components,
|
||||||
|
new RegExp(`${status}: \\{ icon: .+, label: .+, tone:`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(components, /ul-status-badge__icon/);
|
||||||
|
assert.match(components, /aria-hidden="true"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status and error-state components render accessible text and semantics", () => {
|
||||||
|
const React = require("react");
|
||||||
|
const { renderToStaticMarkup } = require("react-dom/server");
|
||||||
|
const { PageState, StatusBadge } = loadTsxComponent(
|
||||||
|
"components/PortalUi.tsx",
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = renderToStaticMarkup(
|
||||||
|
React.createElement(StatusBadge, { status: "IN_PROGRESS" }),
|
||||||
|
);
|
||||||
|
assert.match(status, />In progress</);
|
||||||
|
assert.match(status, /ul-status-badge--warning/);
|
||||||
|
assert.match(status, /aria-hidden="true"/);
|
||||||
|
|
||||||
|
const error = renderToStaticMarkup(
|
||||||
|
React.createElement(PageState, {
|
||||||
|
kind: "error",
|
||||||
|
title: "Jobs could not be loaded",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.match(error, /role="alert"/);
|
||||||
|
assert.match(error, /Jobs could not be loaded/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responsive, focus, touch-target, and reduced-motion rules are present", () => {
|
||||||
|
const css = read("styles/global.css");
|
||||||
|
assert.ok(css.startsWith('@import "./tokens.css";'));
|
||||||
|
for (const token of [
|
||||||
|
"--ul-color-canvas",
|
||||||
|
"--ul-color-primary",
|
||||||
|
"--ul-color-map",
|
||||||
|
"--ul-touch-minimum",
|
||||||
|
"--ul-type-family-sans",
|
||||||
|
]) {
|
||||||
|
assert.match(css, new RegExp(`var\\(${token}\\)`));
|
||||||
|
}
|
||||||
|
assert.match(css, /:focus-visible/);
|
||||||
|
assert.match(css, /min-height:\s*var\(--ul-touch-minimum\)/);
|
||||||
|
assert.match(css, /@media \(max-width: 1200px\)/);
|
||||||
|
assert.match(css, /@media \(max-width: 900px\)/);
|
||||||
|
assert.match(css, /@media \(max-width: 600px\)/);
|
||||||
|
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("job pages preserve organization-scoped APIs, streaming, and provider-neutral map usage", () => {
|
||||||
|
const jobs = read("pages/index.tsx");
|
||||||
|
const detail = read("pages/jobs/[jobId].tsx");
|
||||||
|
const dispatcher = read("components/map/JobMap.tsx");
|
||||||
|
const stream = read("lib/use-job-stream.ts");
|
||||||
|
|
||||||
|
assert.match(jobs, /\/api\/orgs\/\$\{activeOrg\.org\.id\}\/jobs/);
|
||||||
|
assert.match(detail, /\/api\/orgs\/\$\{orgId\}\/jobs\/\$\{jobId\}/);
|
||||||
|
assert.match(detail, /<JobMap/);
|
||||||
|
assert.doesNotMatch(detail, /EsriJobMap/);
|
||||||
|
assert.match(dispatcher, /dynamic\(\(\) => import\('\.\/esri\/EsriJobMap'\)/);
|
||||||
|
assert.match(stream, /channel: `job:\$\{jobId\}`/);
|
||||||
|
assert.match(stream, /return connectionState/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user