fix(webapp): bind client streams earlier and show diagnostics

This commit is contained in:
2026-04-13 10:30:00 +01:00
parent f7c3eafb54
commit 531fd87197
3 changed files with 246 additions and 14 deletions

View File

@@ -73,6 +73,8 @@ const DEFAULT_CAMERA_CONSTRAINTS = {
frameRate: { ideal: 15, max: 24 } frameRate: { ideal: 15, max: 24 }
}; };
const SOCKET_HEARTBEAT_INTERVAL_MS = 10_000; const SOCKET_HEARTBEAT_INTERVAL_MS = 10_000;
const MAX_STREAM_DIAGNOSTIC_SESSIONS = 12;
const MAX_STREAM_DIAGNOSTIC_ENTRIES = 24;
const rtcConfig = { const rtcConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
@@ -444,6 +446,96 @@ const addActivity = (type, message) => {
})); }));
}; };
const pruneStreamDiagnostics = (diagnostics) => {
const entries = Object.entries(diagnostics || {});
if (entries.length <= MAX_STREAM_DIAGNOSTIC_SESSIONS) {
return diagnostics;
}
return Object.fromEntries(
entries
.sort(
(left, right) =>
new Date(right[1]?.updatedAt || 0).getTime() - new Date(left[1]?.updatedAt || 0).getTime()
)
.slice(0, MAX_STREAM_DIAGNOSTIC_SESSIONS)
);
};
const pushStreamDiagnostic = (streamSessionId, stage, message, level = 'info', meta = {}) => {
if (!streamSessionId || !stage || !message) {
return;
}
const createdAt = new Date().toISOString();
patchAppState((state) => {
const current = state.streamDiagnostics?.[streamSessionId] ?? {
streamSessionId,
cameraDeviceId: null,
updatedAt: createdAt,
entries: []
};
const next = {
...current,
...meta,
streamSessionId,
updatedAt: createdAt,
entries: [
{
id: makeId(),
stage,
message,
level,
createdAt
},
...(current.entries || [])
].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES)
};
return {
streamDiagnostics: pruneStreamDiagnostics({
...(state.streamDiagnostics || {}),
[streamSessionId]: next
})
};
});
};
const bindClientStreamSession = (cameraDeviceId, streamSessionId, reason = '') => {
if (!cameraDeviceId || !streamSessionId) {
return;
}
const currentState = getAppState();
const nextCameraSessions = {
...(currentState.cameraSessions || {}),
[cameraDeviceId]: streamSessionId
};
const nextState = {
cameraSessions: nextCameraSessions
};
if (currentState.activeCameraDeviceId === cameraDeviceId) {
nextState.activeStreamSessionId = streamSessionId;
}
setAppState(nextState);
if (reason) {
pushStreamDiagnostic(streamSessionId, 'session', reason, 'info', { cameraDeviceId });
}
if (currentState.activeCameraDeviceId === cameraDeviceId) {
if (remoteStreams.has(streamSessionId)) {
attachClientStreamToElement();
setClientStreamMode('video');
} else if (getAppState().clientStreamMode !== 'video') {
setClientStreamMode('connecting');
}
}
};
const setClientStreamMode = (mode) => { const setClientStreamMode = (mode) => {
let clientPlaceholderText = 'Select a camera to view'; let clientPlaceholderText = 'Select a camera to view';
if (mode === 'connecting') clientPlaceholderText = 'Connecting stream...'; if (mode === 'connecting') clientPlaceholderText = 'Connecting stream...';
@@ -532,7 +624,14 @@ const attachClientStreamToElement = () => {
clientVideoElement.srcObject = stream; clientVideoElement.srcObject = stream;
} }
clientVideoElement.muted = true; clientVideoElement.muted = true;
pushStreamDiagnostic(activeStreamSessionId, 'viewer', 'Attached remote stream to client video element');
void clientVideoElement.play().catch((error) => { void clientVideoElement.play().catch((error) => {
pushStreamDiagnostic(
activeStreamSessionId,
'viewer',
`Autoplay blocked: ${error?.name || 'play_failed'}`,
'error'
);
addActivity('Stream', `Autoplay blocked for ${activeStreamSessionId}: ${error?.name || 'play_failed'}`); addActivity('Stream', `Autoplay blocked for ${activeStreamSessionId}: ${error?.name || 'play_failed'}`);
}); });
}; };
@@ -561,6 +660,7 @@ const primeClientStreamPlayback = (streamSessionId, stream) => {
if (hiddenVideoElement.srcObject !== stream) { if (hiddenVideoElement.srcObject !== stream) {
hiddenVideoElement.srcObject = stream; hiddenVideoElement.srcObject = stream;
} }
pushStreamDiagnostic(streamSessionId, 'viewer', 'Primed remote stream in hidden video element');
void hiddenVideoElement.play().catch(() => {}); void hiddenVideoElement.play().catch(() => {});
}; };
@@ -794,11 +894,16 @@ const clearClientStream = () => {
clientVideoElement.srcObject = null; clientVideoElement.srcObject = null;
} }
if (activeStreamSessionId) {
pushStreamDiagnostic(activeStreamSessionId, 'viewer', 'Cleared active client stream viewer');
}
setClientStreamMode('none'); setClientStreamMode('none');
}; };
const endClientStreamSession = async (streamSessionId, { teardown = true } = {}) => { const endClientStreamSession = async (streamSessionId, { teardown = true } = {}) => {
if (!streamSessionId) return; if (!streamSessionId) return;
pushStreamDiagnostic(streamSessionId, 'session', 'Ending client stream session');
try { try {
await api.streams.end(streamSessionId); await api.streams.end(streamSessionId);
@@ -1163,6 +1268,7 @@ const teardownPeerConnection = (streamSessionId) => {
return; return;
} }
pushStreamDiagnostic(streamSessionId, 'peer', 'Tearing down peer connection');
if (peerConnections.has(streamSessionId)) { if (peerConnections.has(streamSessionId)) {
peerConnections.get(streamSessionId)?.close(); peerConnections.get(streamSessionId)?.close();
peerConnections.delete(streamSessionId); peerConnections.delete(streamSessionId);
@@ -1215,10 +1321,14 @@ const takeQueuedCandidates = (streamSessionId, fromDeviceId) => {
const applyQueuedCandidates = async (connection, streamSessionId, fromDeviceId) => { const applyQueuedCandidates = async (connection, streamSessionId, fromDeviceId) => {
if (!connection?.remoteDescription) return; if (!connection?.remoteDescription) return;
const queued = takeQueuedCandidates(streamSessionId, fromDeviceId); const queued = takeQueuedCandidates(streamSessionId, fromDeviceId);
if (queued.length > 0) {
pushStreamDiagnostic(streamSessionId, 'signal', `Applying ${queued.length} queued ICE candidate(s)`);
}
for (const candidate of queued) { for (const candidate of queued) {
try { try {
await connection.addIceCandidate(new RTCIceCandidate(candidate.data)); await connection.addIceCandidate(new RTCIceCandidate(candidate.data));
} catch (error) { } catch (error) {
pushStreamDiagnostic(streamSessionId, 'error', 'Dropped queued ICE candidate', 'error');
console.warn('Dropping queued ICE candidate', error); console.warn('Dropping queued ICE candidate', error);
} }
} }
@@ -1231,9 +1341,15 @@ const ensurePeerConnection = async ({ streamSessionId, targetDeviceId, asCamera
const connection = new RTCPeerConnection(rtcConfig); const connection = new RTCPeerConnection(rtcConfig);
peerConnections.set(streamSessionId, connection); peerConnections.set(streamSessionId, connection);
pushStreamDiagnostic(
streamSessionId,
'peer',
asCamera ? 'Created camera-side RTCPeerConnection' : 'Created client-side RTCPeerConnection'
);
connection.onicecandidate = (event) => { connection.onicecandidate = (event) => {
if (!socket || !event.candidate) return; if (!socket || !event.candidate) return;
pushStreamDiagnostic(streamSessionId, 'signal', 'Sent ICE candidate');
socket.emit('webrtc:signal', { socket.emit('webrtc:signal', {
toDeviceId: targetDeviceId, toDeviceId: targetDeviceId,
streamSessionId, streamSessionId,
@@ -1243,6 +1359,12 @@ const ensurePeerConnection = async ({ streamSessionId, targetDeviceId, asCamera
}; };
connection.onconnectionstatechange = () => { connection.onconnectionstatechange = () => {
pushStreamDiagnostic(
streamSessionId,
'peer',
`Connection state changed to ${connection.connectionState}`,
connection.connectionState === 'failed' ? 'error' : 'info'
);
if (connection.connectionState === 'connected') { if (connection.connectionState === 'connected') {
addActivity('WebRTC', `Peer connected for ${streamSessionId}`); addActivity('WebRTC', `Peer connected for ${streamSessionId}`);
connectedPeers.add(streamSessionId); connectedPeers.add(streamSessionId);
@@ -1263,6 +1385,15 @@ const ensurePeerConnection = async ({ streamSessionId, targetDeviceId, asCamera
} }
}; };
connection.oniceconnectionstatechange = () => {
pushStreamDiagnostic(
streamSessionId,
'peer',
`ICE connection state changed to ${connection.iceConnectionState}`,
connection.iceConnectionState === 'failed' ? 'error' : 'info'
);
};
connection.ontrack = (event) => { connection.ontrack = (event) => {
if (streamTimers.has(streamSessionId)) { if (streamTimers.has(streamSessionId)) {
clearTimeout(streamTimers.get(streamSessionId)); clearTimeout(streamTimers.get(streamSessionId));
@@ -1271,6 +1402,7 @@ const ensurePeerConnection = async ({ streamSessionId, targetDeviceId, asCamera
const [stream] = event.streams; const [stream] = event.streams;
if (!stream) return; if (!stream) return;
pushStreamDiagnostic(streamSessionId, 'media', 'Received remote media track');
connectedPeers.add(streamSessionId); connectedPeers.add(streamSessionId);
setConnectedStreamSessionIds(); setConnectedStreamSessionIds();
@@ -1307,6 +1439,7 @@ const startOfferToClient = async (streamSessionId, requesterDeviceId) => {
const offer = await connection.createOffer(); const offer = await connection.createOffer();
await connection.setLocalDescription(offer); await connection.setLocalDescription(offer);
pushStreamDiagnostic(streamSessionId, 'signal', 'Created and sent WebRTC offer');
socket.emit('webrtc:signal', { socket.emit('webrtc:signal', {
toDeviceId: requesterDeviceId, toDeviceId: requesterDeviceId,
streamSessionId, streamSessionId,
@@ -1626,6 +1759,27 @@ const connectSocket = () => {
void stopLocalRecording(); void stopLocalRecording();
teardownPeerConnection(); teardownPeerConnection();
setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null }); setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null });
patchAppState((state) => ({
streamDiagnostics: Object.fromEntries(
Object.entries(state.streamDiagnostics || {}).map(([streamSessionId, diagnostic]) => [
streamSessionId,
{
...diagnostic,
updatedAt: new Date().toISOString(),
entries: [
{
id: makeId(),
stage: 'realtime',
message: 'Realtime socket disconnected',
level: 'error',
createdAt: new Date().toISOString()
},
...(diagnostic.entries || [])
].slice(0, MAX_STREAM_DIAGNOSTIC_ENTRIES)
}
])
)
}));
applyMotionDetectionReadiness(); applyMotionDetectionReadiness();
}); });
@@ -1661,6 +1815,15 @@ const connectSocket = () => {
}); });
socket.on('stream:requested', async (payload) => { socket.on('stream:requested', async (payload) => {
if (getAppState().device?.role === 'client') {
bindClientStreamSession(
payload.cameraDeviceId,
payload.streamSessionId,
'Requester received stream session creation event'
);
return;
}
if (getAppState().device?.role !== 'camera') return; if (getAppState().device?.role !== 'camera') return;
try { try {
@@ -1688,20 +1851,9 @@ const connectSocket = () => {
socket.on('stream:started', async (payload) => { socket.on('stream:started', async (payload) => {
addActivity('Stream', 'Stream is live, connecting...'); addActivity('Stream', 'Stream is live, connecting...');
pushStreamDiagnostic(payload.streamSessionId, 'session', 'Received stream:started from realtime gateway');
const currentState = getAppState(); bindClientStreamSession(payload.cameraDeviceId, payload.streamSessionId);
const cameraSessions = { ...currentState.cameraSessions, [payload.cameraDeviceId]: payload.streamSessionId };
setAppState({ cameraSessions });
if (payload.cameraDeviceId === currentState.activeCameraDeviceId) {
setAppState({ activeStreamSessionId: payload.streamSessionId });
if (remoteStreams.has(payload.streamSessionId)) {
attachClientStreamToElement();
setClientStreamMode('video');
} else {
setClientStreamMode('connecting');
}
}
streamTimers.set( streamTimers.set(
payload.streamSessionId, payload.streamSessionId,
@@ -1719,6 +1871,7 @@ const connectSocket = () => {
socket.on('stream:ended', async (payload) => { socket.on('stream:ended', async (payload) => {
if (!payload?.streamSessionId) return; if (!payload?.streamSessionId) return;
const streamSessionId = payload.streamSessionId; const streamSessionId = payload.streamSessionId;
pushStreamDiagnostic(streamSessionId, 'session', 'Received stream:ended event');
teardownPeerConnection(streamSessionId); teardownPeerConnection(streamSessionId);
if (streamSessionId === getAppState().activeStreamSessionId) { if (streamSessionId === getAppState().activeStreamSessionId) {
@@ -1747,6 +1900,7 @@ const connectSocket = () => {
try { try {
if (payload.signalType === 'offer') { if (payload.signalType === 'offer') {
if (device.role !== 'client') return; if (device.role !== 'client') return;
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received WebRTC offer');
addActivity('WebRTC', 'Offer received'); addActivity('WebRTC', 'Offer received');
const connection = await ensurePeerConnection({ const connection = await ensurePeerConnection({
streamSessionId: payload.streamSessionId, streamSessionId: payload.streamSessionId,
@@ -1754,15 +1908,18 @@ const connectSocket = () => {
asCamera: false asCamera: false
}); });
await connection.setRemoteDescription(new RTCSessionDescription(payload.data)); await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote offer');
await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId); await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId);
const answer = await connection.createAnswer(); const answer = await connection.createAnswer();
await connection.setLocalDescription(answer); await connection.setLocalDescription(answer);
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Created local answer');
socket.emit('webrtc:signal', { socket.emit('webrtc:signal', {
toDeviceId: payload.fromDeviceId, toDeviceId: payload.fromDeviceId,
streamSessionId: payload.streamSessionId, streamSessionId: payload.streamSessionId,
signalType: 'answer', signalType: 'answer',
data: answer data: answer
}); });
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Sent WebRTC answer');
addActivity('WebRTC', 'Answer sent'); addActivity('WebRTC', 'Answer sent');
return; return;
} }
@@ -1770,6 +1927,7 @@ const connectSocket = () => {
if (payload.signalType === 'answer') { if (payload.signalType === 'answer') {
const connection = peerConnections.get(payload.streamSessionId); const connection = peerConnections.get(payload.streamSessionId);
if (device.role !== 'camera' || !connection) return; if (device.role !== 'camera' || !connection) return;
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received WebRTC answer');
if (connection.signalingState !== 'have-local-offer') { if (connection.signalingState !== 'have-local-offer') {
if (connection.signalingState === 'stable' && connection.remoteDescription?.type === 'answer') { if (connection.signalingState === 'stable' && connection.remoteDescription?.type === 'answer') {
@@ -1778,6 +1936,7 @@ const connectSocket = () => {
return; return;
} }
await connection.setRemoteDescription(new RTCSessionDescription(payload.data)); await connection.setRemoteDescription(new RTCSessionDescription(payload.data));
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote answer');
await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId); await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId);
addActivity('WebRTC', 'Answer received and applied'); addActivity('WebRTC', 'Answer received and applied');
return; return;
@@ -1787,14 +1946,17 @@ const connectSocket = () => {
if (!payload.data) return; if (!payload.data) return;
const connection = peerConnections.get(payload.streamSessionId); const connection = peerConnections.get(payload.streamSessionId);
if (!connection || !connection.remoteDescription) { if (!connection || !connection.remoteDescription) {
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Queued remote ICE candidate');
queueRemoteCandidate(payload); queueRemoteCandidate(payload);
return; return;
} }
await connection.addIceCandidate(new RTCIceCandidate(payload.data)); await connection.addIceCandidate(new RTCIceCandidate(payload.data));
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Applied remote ICE candidate');
return; return;
} }
if (payload.signalType === 'hangup') { if (payload.signalType === 'hangup') {
pushStreamDiagnostic(payload.streamSessionId, 'signal', 'Received remote hangup');
teardownPeerConnection(payload.streamSessionId); teardownPeerConnection(payload.streamSessionId);
if (getAppState().activeStreamSessionId === payload.streamSessionId) { if (getAppState().activeStreamSessionId === payload.streamSessionId) {
setAppState({ activeStreamSessionId: null }); setAppState({ activeStreamSessionId: null });
@@ -1802,6 +1964,12 @@ const connectSocket = () => {
addActivity('Stream', 'Remote stream ended'); addActivity('Stream', 'Remote stream ended');
} }
} catch (error) { } catch (error) {
pushStreamDiagnostic(
payload.streamSessionId,
'error',
error?.message || 'Failed handling WebRTC signal',
'error'
);
console.error('Failed handling WebRTC signal', error); console.error('Failed handling WebRTC signal', error);
pushToast('WebRTC negotiation failed', 'error'); pushToast('WebRTC negotiation failed', 'error');
} }
@@ -1809,6 +1977,9 @@ const connectSocket = () => {
socket.on('error:webrtc_signal', (payload) => { socket.on('error:webrtc_signal', (payload) => {
const message = payload?.message || 'WebRTC signaling error'; const message = payload?.message || 'WebRTC signaling error';
if (payload?.streamSessionId) {
pushStreamDiagnostic(payload.streamSessionId, 'error', message, 'error');
}
addActivity('WebRTC', message); addActivity('WebRTC', message);
pushToast(message, 'error'); pushToast(message, 'error');
}); });
@@ -2261,7 +2432,17 @@ const actions = {
try { try {
requestedStreams.add(cameraDeviceId); requestedStreams.add(cameraDeviceId);
await api.streams.request(cameraDeviceId); const result = await api.streams.request(cameraDeviceId);
requestedStreams.delete(cameraDeviceId);
const streamSessionId = result?.streamSession?.id;
if (streamSessionId) {
bindClientStreamSession(
cameraDeviceId,
streamSessionId,
'Bound requested stream session from HTTP response'
);
pushStreamDiagnostic(streamSessionId, 'request', 'Backend accepted stream request');
}
} catch (error) { } catch (error) {
requestedStreams.delete(cameraDeviceId); requestedStreams.delete(cameraDeviceId);
pushToast(error.message || 'Failed to request stream', 'error'); pushToast(error.message || 'Failed to request stream', 'error');
@@ -2292,6 +2473,7 @@ const actions = {
} }
if (reusableSessionId) { if (reusableSessionId) {
pushStreamDiagnostic(reusableSessionId, 'viewer', 'Reusing existing stream session for selected camera');
attachClientStreamToElement(); attachClientStreamToElement();
setClientStreamMode(remoteStreams.has(reusableSessionId) ? 'video' : 'connecting'); setClientStreamMode(remoteStreams.has(reusableSessionId) ? 'video' : 'connecting');
return; return;

View File

@@ -23,6 +23,7 @@ export const createInitialState = () => ({
activityLog: [], activityLog: [],
cameraSessions: {}, cameraSessions: {},
connectedStreamSessionIds: [], connectedStreamSessionIds: [],
streamDiagnostics: {},
loading: true, loading: true,
isRegistering: false, isRegistering: false,
authForm: { authForm: {

View File

@@ -29,6 +29,30 @@
const linked = $appState.linkedCameras.find((camera) => camera.cameraDeviceId === $appState.activeCameraDeviceId); const linked = $appState.linkedCameras.find((camera) => camera.cameraDeviceId === $appState.activeCameraDeviceId);
return linked?.cameraName || linked?.cameraDeviceId || 'Live Feed Viewer'; return linked?.cameraName || linked?.cameraDeviceId || 'Live Feed Viewer';
}; };
const activeStreamSessionId = () => {
if ($appState.activeStreamSessionId) {
return $appState.activeStreamSessionId;
}
if (!$appState.activeCameraDeviceId) {
return null;
}
return $appState.cameraSessions?.[$appState.activeCameraDeviceId] ?? null;
};
const activeStreamDiagnostics = () => {
const streamSessionId = activeStreamSessionId();
if (!streamSessionId) {
return null;
}
return $appState.streamDiagnostics?.[streamSessionId] ?? null;
};
const diagnosticLevelClass = (level: string) => {
if (level === 'error') return 'border-red-500/20 bg-red-500/10 text-red-200';
if (level === 'success') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
return 'border-white/10 bg-white/5 text-gray-300';
};
</script> </script>
<AppChrome pageKey="client"> <AppChrome pageKey="client">
@@ -240,6 +264,31 @@
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</CardContent> </CardContent>
{#if activeStreamDiagnostics()}
<div class="border-t border-white/5 bg-black/20 px-5 py-4">
<div class="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.2em] text-gray-500">
<span>Live Diagnostics</span>
<Badge variant="outline" class="border-white/10 text-[10px] text-gray-400">
{activeStreamSessionId()?.slice(0, 8)}
</Badge>
{#if isCameraLive($appState.activeCameraDeviceId)}
<Badge variant="secondary" class="rounded-full px-2 text-[10px] uppercase tracking-wide">Peer connected</Badge>
{/if}
</div>
<div class="mt-3 grid gap-2">
{#each activeStreamDiagnostics().entries.slice(0, 6) as entry (entry.id)}
<div class="rounded-xl border px-3 py-2 {diagnosticLevelClass(entry.level)}">
<div class="flex items-center justify-between gap-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em]">{entry.stage}</span>
<span class="text-[10px] text-gray-500">{new Date(entry.createdAt).toLocaleTimeString()}</span>
</div>
<p class="mt-1 text-sm text-current">{entry.message}</p>
</div>
{/each}
</div>
</div>
{/if}
</Card> </Card>
<div class="flex shrink-0 flex-col gap-6 pr-2 xl:max-h-full xl:w-96 xl:overflow-y-auto"> <div class="flex shrink-0 flex-col gap-6 pr-2 xl:max-h-full xl:w-96 xl:overflow-y-auto">