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:
ulhub
2026-07-18 01:40:12 +00:00
parent f1c94e9279
commit 842cb23e1f
57 changed files with 2283 additions and 67 deletions

View File

@@ -17,6 +17,13 @@ function orderKey(p: MapPoint): number {
return p.sequence ?? new Date(p.recordedAt).getTime();
}
// Identifies whether the set of points (including each one's own position)
// has actually changed, so a moving point re-fits bounds even when the
// count doesn't change (e.g. a device's single "current location" point).
function pointsSignature(points: MapPoint[]): string {
return points.map((p) => `${p.id}:${p.lat.toFixed(7)}:${p.lng.toFixed(7)}`).join('|');
}
// Draws points as colored circles plus one polyline per utility run.
// Imperative overlays (google.maps.Marker/Polyline) are used because
// @vis.gl/react-google-maps has no polyline component.
@@ -31,7 +38,7 @@ function PointsLayer({
}) {
const map = useMap();
const overlaysRef = useRef<{ setMap: (m: google.maps.Map | null) => void }[]>([]);
const fittedCountRef = useRef(0);
const fittedSignatureRef = useRef('');
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
@@ -85,11 +92,18 @@ function PointsLayer({
}
}
if (fitBounds && points.length !== fittedCountRef.current) {
fittedCountRef.current = points.length;
const bounds = new google.maps.LatLngBounds();
points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng }));
map.fitBounds(bounds, 48);
const signature = pointsSignature(points);
if (fitBounds && signature !== fittedSignatureRef.current) {
fittedSignatureRef.current = signature;
if (points.length === 1) {
// A single (often moving) point has no useful bounds to fit — just
// recenter on it, preserving the current zoom.
map.panTo({ lat: points[0].lat, lng: points[0].lng });
} else {
const bounds = new google.maps.LatLngBounds();
points.forEach((p) => bounds.extend({ lat: p.lat, lng: p.lng }));
map.fitBounds(bounds, 48);
}
}
}, [map, points, fitBounds]);
@@ -157,6 +171,9 @@ function LiveMarker({ status, onSelect }: { status: LiveStatus | null; onSelect:
haloRef.current?.setCenter(position);
haloRef.current?.setRadius(status.hAccuracy ?? 5);
}
// Follow the transmitter as its position updates.
map.panTo(position);
}, [map, status]);
useEffect(
@@ -211,14 +228,18 @@ export default function GoogleJobMap({ points, liveStatus = null, fitBounds = tr
}
// A point can be re-fetched (new object identity) between renders; keep the
// InfoWindow anchored to the latest copy of the same point by id. The live
// marker's "point" is synthesized fresh from liveStatus each time.
// InfoWindow anchored to the latest copy of the same point by id. The
// liveStatus marker's "point" is synthesized fresh from liveStatus each
// time — but only for the id it actually owns. Other callers (e.g. the
// devices page's single current-position point) may also use a "live-"
// prefixed id without ever passing a liveStatus prop, so that id alone
// can't be used to decide which source to read from.
const selectedCurrent = (() => {
if (!selected) {
return null;
}
if (selected.id.startsWith('live-')) {
return liveStatus ? liveStatusToMapPoint(liveStatus) : null;
if (liveStatus && selected.id === `live-${liveStatus.deviceId}`) {
return liveStatusToMapPoint(liveStatus);
}
return points.find((p) => p.id === selected.id) ?? selected;
})();