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:
@@ -33,6 +33,7 @@ export default function Layout({ children, title }: { children: ReactNode; title
|
||||
<Link href="/settings/devices">Devices</Link>
|
||||
<Link href="/settings/members">Members</Link>
|
||||
<Link href="/settings/api-keys">API Keys</Link>
|
||||
<Link href="/settings/mqtt-certs">MQTT Certs</Link>
|
||||
</nav>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{memberships.length > 1 ? (
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user