Add device-certificate mTLS auth, live position tracking, and API docs
Introduces a CA/PKI module so field devices can authenticate to Mosquitto over TLS (8883) with per-device client certificates (CN = serial number) instead of a shared password, with matching Devices/MQTT-Certs UI. Adds live transmitter position tracking alongside logged points, an MQTTS transport option in the simulator for exercising the real cert-auth path, and Swagger API docs at /api/docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
24
web/lib/format.ts
Normal file
24
web/lib/format.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const UNITS: Array<[string, number]> = [
|
||||
['year', 365 * 24 * 60 * 60],
|
||||
['day', 24 * 60 * 60],
|
||||
['hr', 60 * 60],
|
||||
['min', 60],
|
||||
];
|
||||
|
||||
// "just now", "1 min ago", "10 min ago", "3 days ago", …
|
||||
export function formatRelativeTime(iso: string | null, now: number = Date.now()): string {
|
||||
if (!iso) {
|
||||
return 'never';
|
||||
}
|
||||
const seconds = Math.floor((now - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 45) {
|
||||
return 'just now';
|
||||
}
|
||||
for (const [label, secondsPerUnit] of UNITS) {
|
||||
const value = Math.floor(seconds / secondsPerUnit);
|
||||
if (value >= 1) {
|
||||
return `${value} ${label}${value === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
}
|
||||
return 'just now';
|
||||
}
|
||||
71
web/lib/use-devices-stream.ts
Normal file
71
web/lib/use-devices-stream.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { MapPoint } from '../components/map/types';
|
||||
|
||||
export interface DeviceStreamEvent {
|
||||
deviceId: string;
|
||||
lastSeenAt?: string;
|
||||
isActive?: boolean;
|
||||
disabledReason?: string | null;
|
||||
position?: MapPoint;
|
||||
job?: { id: string; ticketNumber: string; title: string };
|
||||
}
|
||||
|
||||
// Subscribes to live device updates (position pings, log points, and
|
||||
// admin enable/disable) for an org over the backend WebSocket, so the
|
||||
// devices page reflects device activity without a manual refresh.
|
||||
export function useDevicesStream(orgId: string | null, onEvent: (event: DeviceStreamEvent) => void) {
|
||||
const eventRef = useRef(onEvent);
|
||||
eventRef.current = onEvent;
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let closed = false;
|
||||
let attempt = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const channel = `org:${orgId}:devices`;
|
||||
|
||||
const connect = () => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
|
||||
socket.onopen = () => {
|
||||
attempt = 0;
|
||||
socket?.send(JSON.stringify({ type: 'subscribe', channel }));
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'device') {
|
||||
eventRef.current(msg as DeviceStreamEvent);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
attempt += 1;
|
||||
const delay = Math.min(1000 * 2 ** attempt, 15000);
|
||||
timer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
socket?.close();
|
||||
};
|
||||
}, [orgId]);
|
||||
}
|
||||
Reference in New Issue
Block a user