diff --git a/CHANGELOG.md b/CHANGELOG.md index fa004b76..8a949fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - ✨(summary) report exception type in failure analytics - ✨(frontend) add configurable documentation menu item +- ✨(frontend) add connection test feature ## Fixed diff --git a/src/frontend/src/features/connection-test/api/fetchConnectionTestToken.ts b/src/frontend/src/features/connection-test/api/fetchConnectionTestToken.ts new file mode 100644 index 00000000..568029ae --- /dev/null +++ b/src/frontend/src/features/connection-test/api/fetchConnectionTestToken.ts @@ -0,0 +1,13 @@ +import { fetchApi } from '@/api/fetchApi' + +export type ConnectionTestTokenResponse = { + livekit: { + url: string + room: string + token: string + expires_in: number + } +} + +export const fetchConnectionTestToken = () => + fetchApi('/connection-test/') diff --git a/src/frontend/src/features/connection-test/hooks/useConnectionTestRunner.ts b/src/frontend/src/features/connection-test/hooks/useConnectionTestRunner.ts new file mode 100644 index 00000000..95814150 --- /dev/null +++ b/src/frontend/src/features/connection-test/hooks/useConnectionTestRunner.ts @@ -0,0 +1,245 @@ +import { useRef, useState } from 'react' +import { + CheckStatus, + ConnectionCheck, + createLocalAudioTrack, + createLocalVideoTrack, + getBrowser, + type CheckInfo, + type LocalVideoTrack, +} from 'livekit-client' +import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken' +import { + createInitialSteps, + type ConnectionTestLog, + type ConnectionTestStepId, + type ConnectionTestStepResult, + type ConnectionTestStepStatus, +} from '../types' +import { openPermissionsDialog } from '@/stores/permissions' + +const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [ + 'websocket', + 'webrtc', + 'turn', + 'reconnect', + 'publishAudio', + 'publishVideo', +] + +const CHECK_STATUS_TO_STEP: Record = { + [CheckStatus.IDLE]: 'pending', + [CheckStatus.RUNNING]: 'running', + [CheckStatus.SUCCESS]: 'success', + [CheckStatus.FAILED]: 'failed', + [CheckStatus.SKIPPED]: 'skipped', +} + +const getErrorMessage = (error: unknown, fallback = 'Unknown error') => + error instanceof Error ? error.message : fallback + +const fromCheckInfo = (info: CheckInfo): Partial => ({ + status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed', + summary: info.description, + logs: info.logs, +}) + +const groupDevicesByKind = (devices: MediaDeviceInfo[]) => { + const grouped: Record = { + audioinput: [], + audiooutput: [], + videoinput: [], + } + for (const device of devices) { + grouped[device.kind].push(device.label || device.deviceId) + } + return grouped +} + +export const useConnectionTestRunner = () => { + const [steps, setSteps] = useState(createInitialSteps) + const [isRunning, setIsRunning] = useState(false) + const [videoTrack, setVideoTrack] = useState(null) + const videoTrackRef = useRef(null) + const abortRef = useRef(null) + + const updateStep = ( + id: ConnectionTestStepId, + patch: Partial + ) => { + setSteps((current) => + current.map((step) => (step.id === id ? { ...step, ...patch } : step)) + ) + } + + const stopVideoTrack = () => { + videoTrackRef.current?.stop() + videoTrackRef.current = null + setVideoTrack(null) + } + + const skipSteps = ( + ids: ConnectionTestStepId[], + summary: string, + logs?: ConnectionTestLog[] + ) => { + for (const id of ids) { + updateStep(id, { status: 'skipped', summary, logs }) + } + } + + /** Returns true on success, false on failure, null if aborted. */ + const runStep = async ( + id: ConnectionTestStepId, + signal: AbortSignal, + fn: () => Promise> + ): Promise => { + if (signal.aborted) return null + + updateStep(id, { status: 'running', summary: undefined, logs: undefined }) + + try { + const result = await fn() + if (signal.aborted) return null + // `result.status` overrides when set (LiveKit checks map their own status) + updateStep(id, { status: 'success', ...result }) + return true + } catch (error) { + if (signal.aborted) return null + updateStep(id, { + status: 'failed', + summary: getErrorMessage(error), + }) + return false + } + } + + const runTest = async () => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + const { signal } = controller + + setIsRunning(true) + setSteps(createInitialSteps()) + stopVideoTrack() + + try { + await runStep('browser', signal, async () => { + const browser = getBrowser() + if (!browser) throw new Error('Browser not detected') + return { + summary: `${browser.name} ${browser.version}`, + data: { + name: browser.name, + version: browser.version, + os: browser.os, + osVersion: browser.osVersion, + }, + } + }) + if (signal.aborted) return + + const microphoneOk = await runStep('microphone', signal, async () => { + const track = await createLocalAudioTrack() + const label = + track.mediaStreamTrack.label || + track.mediaStreamTrack.getSettings().deviceId || + '' + track.stop() + return { summary: label, data: { label } } + }) + if (signal.aborted) return + if (!microphoneOk) openPermissionsDialog('audioinput') + + const cameraOk = await runStep('camera', signal, async () => { + const track = await createLocalVideoTrack() + videoTrackRef.current = track + setVideoTrack(track) + const settings = track.mediaStreamTrack.getSettings() + const label = track.mediaStreamTrack.label || '' + return { + summary: label, + data: { + label, + width: settings.width, + height: settings.height, + }, + } + }) + if (signal.aborted) return + if (!cameraOk) openPermissionsDialog('videoinput') + + await runStep('devices', signal, async () => { + const devices = await navigator.mediaDevices.enumerateDevices() + return { + summary: String(devices.length), + data: groupDevicesByKind(devices), + } + }) + if (signal.aborted) return + + let checker: ConnectionCheck + try { + const { livekit } = await fetchConnectionTestToken() + checker = new ConnectionCheck(livekit.url, livekit.token) + } catch (error) { + skipSteps( + LIVEKIT_STEP_IDS, + getErrorMessage(error, 'Failed to fetch test token') + ) + return + } + + await runStep('websocket', signal, async () => + fromCheckInfo(await checker.checkWebsocket()) + ) + await runStep('webrtc', signal, async () => + fromCheckInfo(await checker.checkWebRTC()) + ) + await runStep('turn', signal, async () => + fromCheckInfo(await checker.checkTURN()) + ) + await runStep('reconnect', signal, async () => + fromCheckInfo(await checker.checkReconnect()) + ) + + if (!microphoneOk) { + skipSteps(['publishAudio'], 'Microphone permission required') + } else { + await runStep('publishAudio', signal, async () => + fromCheckInfo(await checker.checkPublishAudio()) + ) + } + + if (!cameraOk) { + skipSteps(['publishVideo'], 'Camera permission required') + } else { + stopVideoTrack() + await runStep('publishVideo', signal, async () => + fromCheckInfo(await checker.checkPublishVideo()) + ) + } + } finally { + if (!signal.aborted) { + stopVideoTrack() + setIsRunning(false) + } + } + } + + const reset = () => { + abortRef.current?.abort() + stopVideoTrack() + setSteps(createInitialSteps()) + setIsRunning(false) + } + + return { + steps, + isRunning, + videoTrack, + runTest, + reset, + } +} diff --git a/src/frontend/src/features/connection-test/routes/ConnectionTest.tsx b/src/frontend/src/features/connection-test/routes/ConnectionTest.tsx new file mode 100644 index 00000000..d829ebee --- /dev/null +++ b/src/frontend/src/features/connection-test/routes/ConnectionTest.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CenteredContent } from '@/layout/CenteredContent' +import { Screen } from '@/layout/Screen' +import { Box, Button, Text, Ul } from '@/primitives' +import { Spinner } from '@/primitives/Spinner' +import { Center, HStack, VStack } from '@/styled-system/jsx' +import { Permissions } from '@/features/rooms/components/Permissions' +import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner' +import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types' +import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport' + +const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video' + +const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => { + const { t } = useTranslation('connectionTest') + const [showDetails, setShowDetails] = useState(false) + const hasLogs = Boolean(step.logs?.length) + const statusLabel = t(`status.${step.status}`) + const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`) + + const statusVariant = + step.status === 'failed' + ? 'warning' + : step.status === 'success' + ? 'body' + : 'smNote' + + return ( + + + {stepLabel} + {step.status === 'running' ? ( + + ) : ( + {statusLabel} + )} + + {step.summary && ( + + {step.summary} + + )} + {hasLogs && step.status !== 'pending' && step.status !== 'running' && ( + + )} + {showDetails && step.logs && ( +
    + {step.logs.map((log, index) => ( +
  • + + {log.message} + +
  • + ))} +
+ )} +
+ ) +} + +const ConnectionTest = () => { + const { t } = useTranslation('connectionTest') + const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner() + const videoRef = useRef(null) + + const hasStarted = steps.some((step) => step.status !== 'pending') + const hasFailed = steps.some((step) => step.status === 'failed') + const isPublishVideoRunning = steps.some( + (step) => step.id === 'publishVideo' && step.status === 'running' + ) + + useEffect(() => { + const element = videoRef.current + if (!element || !videoTrack) return + + videoTrack.attach(element) + return () => { + videoTrack.detach(element) + } + }, [videoTrack]) + + // LiveKit appends a bare