devices/<serial>/log messages now carry a "type": "log" or "status". "log" persists a LocatePoint as before; "status" carries the same position + telemetry shape but is broadcast live over the job's realtime channel without touching locate_points — it's a current- position update, not a recorded point. The job map renders the latest status as a blue "you are here" marker (with a soft accuracy-radius halo) that moves in place as new updates arrive, separate from the colored polyline of logged points. Clicking it shows the same detail readout as a logged point. The simulator gained a message-type toggle (Log point / Status update) so both paths can be exercised from /sim. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
73 lines
1.9 KiB
TypeScript
73 lines
1.9 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import type { LiveStatus, MapPoint } from '../components/map/types';
|
|
|
|
// Subscribes to live points + live position status for a job over the
|
|
// backend WebSocket. Reconnects with capped exponential backoff; resubscribes
|
|
// on reconnect.
|
|
export function useJobStream(
|
|
jobId: string | null,
|
|
onPoints: (points: MapPoint[]) => void,
|
|
onStatus?: (status: LiveStatus) => void,
|
|
) {
|
|
const pointsRef = useRef(onPoints);
|
|
pointsRef.current = onPoints;
|
|
const statusRef = useRef(onStatus);
|
|
statusRef.current = onStatus;
|
|
|
|
useEffect(() => {
|
|
if (!jobId) {
|
|
return;
|
|
}
|
|
|
|
let socket: WebSocket | null = null;
|
|
let closed = false;
|
|
let attempt = 0;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
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: `job:${jobId}` }));
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.jobId !== jobId) {
|
|
return;
|
|
}
|
|
if (msg.type === 'points') {
|
|
pointsRef.current(msg.points);
|
|
} else if (msg.type === 'status') {
|
|
statusRef.current?.(msg as LiveStatus);
|
|
}
|
|
} 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();
|
|
};
|
|
}, [jobId]);
|
|
}
|