diff --git a/CHANGELOG.md b/CHANGELOG.md index 9813dc6e..efda9d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to - ✨(backend) add roomkit viewset to start a room without WebRTC join - ✨(frontend) let users set default configuration for generated links - ✨(frontend) expose media state to external gateways +- ✨(frontend) add connection test feature ### Changed diff --git a/src/frontend/src/features/diagnostics/api/fetchConnectionTestDetails.ts b/src/frontend/src/features/diagnostics/api/fetchConnectionTestDetails.ts new file mode 100644 index 00000000..c95bbaf1 --- /dev/null +++ b/src/frontend/src/features/diagnostics/api/fetchConnectionTestDetails.ts @@ -0,0 +1,15 @@ +import { fetchApi } from '@/api/fetchApi' + +export type LiveKitConnectionDetails = { + url: string + room: string + token: string + expires_in: number +} + +export type ConnectionTestResponse = { + livekit: LiveKitConnectionDetails +} + +export const fetchConnectionTestDetails = () => + fetchApi('/diagnostics/connection/') diff --git a/src/frontend/src/features/diagnostics/components/ConnectionTestStepRow.tsx b/src/frontend/src/features/diagnostics/components/ConnectionTestStepRow.tsx new file mode 100644 index 00000000..9e154ae9 --- /dev/null +++ b/src/frontend/src/features/diagnostics/components/ConnectionTestStepRow.tsx @@ -0,0 +1,186 @@ +import { useTranslation } from 'react-i18next' +import { + Disclosure, + DisclosurePanel, + Heading, + Button as RACButton, +} from 'react-aria-components' +import { RiArrowDownSFill } from '@remixicon/react' +import { css, cx } from '@/styled-system/css' +import type { ConnectionTestStepResult } from '../types' +import { StepStatusIndicator } from './StepStatusIndicator' + +/** Each step is its own bounded card, collapsed or not. */ +const cardClass = css({ + border: '1px solid {colors.greyscale.900}', + borderRadius: '5px', + backgroundColor: 'white', + overflow: 'hidden', +}) + +/** + * Fixed columns so the status labels line up across every row, whether or not + * the row is expandable. + */ +const rowClass = css({ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) 7rem 1.5rem', + alignItems: 'center', + gap: '1rem', + width: '100%', + paddingX: '1rem', + paddingY: '0.75rem', + textAlign: 'left', +}) + +const identityClass = css({ + display: 'flex', + flexDirection: 'column', + gap: '0.125rem', + minWidth: 0, +}) + +const triggerClass = css({ + cursor: 'pointer', + transition: 'background-color 120ms', + _hover: { backgroundColor: 'greyscale.50' }, + '&[data-focus-visible]': { + outline: '2px solid {colors.focusRing}', + outlineOffset: '-2px', + }, +}) + +/** Expanded headers stay tinted so the open card reads as one block. */ +const triggerExpandedClass = css({ + backgroundColor: 'greyscale.100', + _hover: { backgroundColor: 'greyscale.100' }, +}) + +const labelClass = css({ + textStyle: 'body', + color: 'greyscale.1000', + fontWeight: 'medium', +}) + +const valueClass = css({ + fontFamily: 'mono', + textStyle: 'xs', + color: 'greyscale.500', + overflowWrap: 'anywhere', +}) + +const chevronClass = css({ + color: 'primary.800', + justifySelf: 'end', + transition: 'transform 150ms', +}) + +const chevronExpandedClass = css({ transform: 'rotate(180deg)' }) + +const headingResetClass = css({ + margin: 0, + fontSize: 'inherit', + fontWeight: 'inherit', +}) + +const panelClass = css({ + backgroundColor: 'white', +}) + +const logListClass = css({ + listStyle: 'none', + margin: 0, + padding: 0, + display: 'flex', + flexDirection: 'column', + gap: '0.25rem', +}) + +const logItemClass = css({ + fontFamily: 'mono', + textStyle: 'xs', + color: 'greyscale.700', + overflowWrap: 'anywhere', +}) + +const StepRowContent = ({ step }: { step: ConnectionTestStepResult }) => { + const { t } = useTranslation('connectionTest') + + return ( + <> + + {t(`steps.${step.id}`)} + {step.summary && {step.summary}} + + + + ) +} + +export const ConnectionTestStepRow = ({ + step, +}: { + step: ConnectionTestStepResult +}) => { + const { t } = useTranslation('connectionTest') + const isSettled = step.status !== 'pending' && step.status !== 'running' + const hasLogs = isSettled && Boolean(step.logs?.length) + + if (!hasLogs) { + return ( +
+ + {/* Empty chevron column keeps non-expandable rows aligned. */} + +
+ ) + } + + return ( + + {({ isExpanded }) => ( + <> + + + + + + + {/* Collapsed panels stay in the DOM for aria-controls, but the log + lines themselves are only mounted when actually visible. */} + {isExpanded && ( +
    + {step.logs?.map((log, index) => ( +
  • + {log.message} +
  • + ))} +
+ )} +
+ + )} +
+ ) +} diff --git a/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx b/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx new file mode 100644 index 00000000..7552670d --- /dev/null +++ b/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx @@ -0,0 +1,257 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { ProgressBar } from 'react-aria-components' +import { css, cx } from '@/styled-system/css' +import type { ConnectionTestStats } from '../types' +import { statusSquareClass } from './stepAppearance' + +type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed' + +/** Only a failure earns a colour: everything else stays near-black. */ +const stateColorClass: Record = { + idle: css({ color: 'greyscale.1000' }), + running: css({ color: 'greyscale.1000' }), + passed: css({ color: 'greyscale.1000' }), + partial: css({ color: 'greyscale.1000' }), + failed: css({ color: 'danger.600' }), +} + +const cardClass = css({ + width: '100%', + borderRadius: '5px', + border: '1px solid {colors.greyscale.900}', + backgroundColor: 'white', + padding: { base: '1.25rem', xsm: '1.75rem' }, + display: 'flex', + flexDirection: 'column', + // Blocks are spaced here; everything inside a block stays tight. + gap: '1.5rem', +}) + +const headerClass = css({ + display: 'flex', + flexDirection: 'column', + gap: '0.5rem', +}) + +const eyebrowClass = css({ + textStyle: 'sm', + fontWeight: 'medium', + color: 'greyscale.600', + margin: 0, +}) + +const headlineClass = css({ + // Sized for the longest state string ("N vérifications en échec"), not for + // the shortest one. + fontSize: { base: '28', xsm: '40' }, + lineHeight: '1.1', + fontWeight: 'bold', + letterSpacing: '-0.02em', + textWrap: 'balance', + margin: 0, +}) + +const hintClass = css({ + textStyle: 'sm', + color: 'greyscale.600', + margin: 0, + maxWidth: '34rem', +}) + +const dividerClass = css({ + // Lighter than the card border: an inner rule should never compete with it. + borderTop: '1px solid {colors.greyscale.100}', + paddingTop: '1.25rem', + display: 'flex', + flexDirection: 'column', + gap: '0.875rem', +}) + +const progressRowClass = css({ + display: 'flex', + alignItems: 'center', + gap: '0.75rem', +}) + +const trackClass = css({ + height: '0.375rem', + width: '100%', + borderRadius: 'full', + backgroundColor: 'greyscale.200', + overflow: 'hidden', +}) + +const fillClass = css({ + height: '100%', + borderRadius: 'full', + backgroundColor: 'primary.800', + transition: 'width 200ms ease-out', +}) + +const progressValueClass = css({ + textStyle: 'sm', + fontVariantNumeric: 'tabular-nums', + color: 'greyscale.700', + whiteSpace: 'nowrap', + // Reserved width so the bar does not resize when the digits change. + minWidth: '3rem', + textAlign: 'right', +}) + +const countersClass = css({ + display: 'flex', + flexWrap: 'wrap', + gap: '0.5rem 1.5rem', +}) + +const counterClass = css({ + display: 'inline-flex', + alignItems: 'center', + gap: '0.5rem', + textStyle: 'sm', + color: 'greyscale.600', +}) + +const counterSquareClass = css({ + width: '0.5rem', + height: '0.5rem', + borderRadius: '2px', + flexShrink: 0, +}) + +const counterValueClass = css({ + fontWeight: 'medium', + fontVariantNumeric: 'tabular-nums', + color: 'greyscale.1000', +}) + +/** A zero count is context, not a result: it recedes instead of shouting. */ +const emptyCounterClass = css({ color: 'greyscale.400' }) +const emptySquareClass = css({ + backgroundColor: 'transparent!', + border: '1px solid {colors.greyscale.250}', +}) + +const actionsClass = css({ + display: 'flex', + flexWrap: 'wrap', + gap: '0.75rem', +}) + +const Counter = ({ + squareClass, + value, + label, +}: { + squareClass: string + value: number + label: string +}) => { + const isEmpty = value === 0 + + return ( + + + ) +} + +export const ConnectionTestSummary = ({ + stats, + isRunning, + children, +}: { + stats: ConnectionTestStats + isRunning: boolean + children?: ReactNode +}) => { + const { t } = useTranslation('connectionTest') + + const state: SummaryState = isRunning + ? 'running' + : !stats.hasStarted + ? 'idle' + : stats.failed > 0 + ? 'failed' + : stats.skipped > 0 + ? 'partial' + : 'passed' + + return ( +
+
+

{t('title')}

+ + {/* Announced once per state change rather than on every step update. */} +

+ {state === 'failed' + ? t('summary.failed', { count: stats.failed }) + : t(`summary.${state}`)} +

+ +

{t(`summary.${state}Hint`)}

+
+ + {stats.hasStarted && ( +
+
+ + {({ percentage }) => ( +
+
+
+ )} + + + {t('progress', { done: stats.settled, total: stats.total })} + +
+ +
+ + + +
+
+ )} + + {children &&
{children}
} +
+ ) +} diff --git a/src/frontend/src/features/diagnostics/components/StepStatusIndicator.tsx b/src/frontend/src/features/diagnostics/components/StepStatusIndicator.tsx new file mode 100644 index 00000000..135f0c39 --- /dev/null +++ b/src/frontend/src/features/diagnostics/components/StepStatusIndicator.tsx @@ -0,0 +1,40 @@ +import { css, cx } from '@/styled-system/css' +import type { ConnectionTestStepStatus } from '../types' +import { statusSquareClass, statusTextClass } from './stepAppearance' + +const wrapperClass = css({ + display: 'inline-flex', + alignItems: 'center', + gap: '0.5rem', + textStyle: 'sm', + whiteSpace: 'nowrap', +}) + +const squareClass = css({ + width: '0.625rem', + height: '0.625rem', + borderRadius: '2px', + flexShrink: 0, +}) + +/** + * Status is carried by the label; the square is decorative so the meaning does + * not depend on colour alone. + */ +export const StepStatusIndicator = ({ + status, + label, + className, +}: { + status: ConnectionTestStepStatus + label: string + className?: string +}) => ( + + +) diff --git a/src/frontend/src/features/diagnostics/components/stepAppearance.ts b/src/frontend/src/features/diagnostics/components/stepAppearance.ts new file mode 100644 index 00000000..9e838ba4 --- /dev/null +++ b/src/frontend/src/features/diagnostics/components/stepAppearance.ts @@ -0,0 +1,29 @@ +import { css } from '@/styled-system/css' +import type { ConnectionTestStepStatus } from '../types' + +/** + * Panda extracts styles statically, so every status needs its own literal + * `css()` call: `css({ backgroundColor: someVariable })` would emit nothing. + */ +export const statusSquareClass: Record = { + pending: css({ + backgroundColor: 'transparent', + border: '1px solid {colors.greyscale.300}', + }), + running: css({ + backgroundColor: 'primary.800', + animation: 'pulse_background 1.2s ease-in-out infinite', + }), + success: css({ backgroundColor: 'success.600' }), + failed: css({ backgroundColor: 'danger.600' }), + skipped: css({ backgroundColor: 'greyscale.300' }), +} + +/** Colour is carried by the square; the label stays near-black except on failure. */ +export const statusTextClass: Record = { + pending: css({ color: 'greyscale.500' }), + running: css({ color: 'greyscale.700' }), + success: css({ color: 'greyscale.1000' }), + failed: css({ color: 'danger.600', fontWeight: 'medium' }), + skipped: css({ color: 'greyscale.500' }), +} diff --git a/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts b/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts new file mode 100644 index 00000000..12e5179c --- /dev/null +++ b/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + CheckStatus, + ConnectionCheck, + createLocalAudioTrack, + createLocalVideoTrack, + getBrowser, + type CheckInfo, +} from 'livekit-client' +import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails' +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', +} + +/** getUserMedia rejections that mean "the user said no", not "the device is broken". */ +const PERMISSION_ERROR_NAMES = new Set([ + 'NotAllowedError', + 'PermissionDeniedError', + 'SecurityError', +]) + +const getErrorMessage = (error: unknown, fallback = 'Unknown error') => + error instanceof Error ? error.message : fallback + +const isPermissionError = (error: unknown) => + error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name) + +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) { + // Browsers are free to report kinds we don't know about yet. + const bucket = (grouped[device.kind] ??= []) + bucket.push(device.label || device.deviceId) + } + return grouped +} + +/** + * Outcome of a single step. `aborted` is deliberately distinct from `failed`: + * a cancelled run must not be reported to the user as a broken device. + */ +type StepOutcome = + | { state: 'success' } + | { state: 'failed'; error: unknown } + | { state: 'aborted' } + +const ABORTED: StepOutcome = { state: 'aborted' } + +export const useConnectionTestRunner = () => { + const [steps, setSteps] = useState(createInitialSteps) + const [isRunning, setIsRunning] = useState(false) + const abortRef = useRef(null) + + const updateStep = useCallback( + (id: ConnectionTestStepId, patch: Partial) => { + setSteps((current) => + current.map((step) => (step.id === id ? { ...step, ...patch } : step)) + ) + }, + [] + ) + + const skipSteps = useCallback( + ( + ids: ConnectionTestStepId[], + summary: string, + logs?: ConnectionTestLog[] + ) => { + // One state update for the whole batch instead of one per step. + const targets = new Set(ids) + setSteps((current) => + current.map((step) => + targets.has(step.id) + ? { ...step, status: 'skipped', summary, logs } + : step + ) + ) + }, + [] + ) + + const runStep = useCallback( + async ( + id: ConnectionTestStepId, + signal: AbortSignal, + fn: () => Promise> + ): Promise => { + if (signal.aborted) return ABORTED + + updateStep(id, { + status: 'running', + summary: undefined, + logs: undefined, + data: undefined, + }) + + try { + const result = await fn() + if (signal.aborted) return ABORTED + // `result.status` overrides when set (LiveKit checks map their own status) + updateStep(id, { status: 'success', ...result }) + return { state: 'success' } + } catch (error) { + if (signal.aborted) return ABORTED + updateStep(id, { + status: 'failed', + summary: getErrorMessage(error), + }) + return { state: 'failed', error } + } + }, + [updateStep] + ) + + const runTest = useCallback(async () => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + const { signal } = controller + + setIsRunning(true) + setSteps(createInitialSteps()) + + 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 microphone = 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 ( + microphone.state === 'failed' && + isPermissionError(microphone.error) + ) { + openPermissionsDialog('audioinput') + } + + const camera = await runStep('camera', signal, async () => { + const track = await createLocalVideoTrack() + const settings = track.mediaStreamTrack.getSettings() + const label = track.mediaStreamTrack.label || '' + // Released immediately, like the microphone probe: the capture + // indicator must not stay on between this check and publishVideo. + track.stop() + return { + summary: label, + data: { + label, + width: settings.width, + height: settings.height, + }, + } + }) + if (signal.aborted) return + if (camera.state === 'failed' && isPermissionError(camera.error)) { + 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 fetchConnectionTestDetails() + if (signal.aborted) return + checker = new ConnectionCheck(livekit.url, livekit.token) + } catch (error) { + if (signal.aborted) return + skipSteps( + LIVEKIT_STEP_IDS, + getErrorMessage(error, 'Failed to fetch test token') + ) + return + } + + // LiveKit's ConnectionCheck exposes no cancellation: each check owns its + // own room and disconnects it when it settles. The best we can do is + // never start the next one once the run has been aborted (runStep + // short-circuits on `signal.aborted`). + 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 (microphone.state !== 'success') { + skipSteps(['publishAudio'], 'Microphone permission required') + } else { + await runStep('publishAudio', signal, async () => + fromCheckInfo(await checker.checkPublishAudio()) + ) + } + + if (camera.state !== 'success') { + skipSteps(['publishVideo'], 'Camera permission required') + } else { + await runStep('publishVideo', signal, async () => + fromCheckInfo(await checker.checkPublishVideo()) + ) + } + } finally { + if (!signal.aborted) { + setIsRunning(false) + } + } + }, [runStep, skipSteps]) + + const reset = useCallback(() => { + abortRef.current?.abort() + setSteps(createInitialSteps()) + setIsRunning(false) + }, []) + + // Leaving the page mid-run must stop the pending checks rather than let them + // keep a LiveKit session open behind an unmounted component. + useEffect( + () => () => { + abortRef.current?.abort() + }, + [] + ) + + return { + steps, + isRunning, + runTest, + reset, + } +} diff --git a/src/frontend/src/features/diagnostics/routes/ConnectionTest.tsx b/src/frontend/src/features/diagnostics/routes/ConnectionTest.tsx new file mode 100644 index 00000000..3d771417 --- /dev/null +++ b/src/frontend/src/features/diagnostics/routes/ConnectionTest.tsx @@ -0,0 +1,156 @@ +import { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + RiCloseLine, + RiDownload2Line, + RiErrorWarningLine, + RiPlayLine, +} from '@remixicon/react' +import { CenteredContent } from '@/layout/CenteredContent' +import { Screen } from '@/layout/Screen' +import { Button } from '@/primitives' +import { css } from '@/styled-system/css' +import { Center, VStack } from '@/styled-system/jsx' +import { Permissions } from '@/features/rooms/components/Permissions' +import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner' +import { ConnectionTestStepRow } from '../components/ConnectionTestStepRow' +import { ConnectionTestSummary } from '../components/ConnectionTestSummary' +import { CONNECTION_TEST_GROUPS, summarizeSteps } from '../types' +import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport' + +const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video' + +const sectionClass = css({ + width: '100%', + borderTop: '2px solid {colors.greyscale.900}', + paddingTop: '1rem', +}) + +const sectionTitleClass = css({ + textStyle: 'h2', + color: 'greyscale.1000', + margin: 0, +}) + +const rowsClass = css({ + display: 'flex', + flexDirection: 'column', + gap: '0.5rem', + marginTop: '0.75rem', +}) + +const helpClass = css({ + display: 'flex', + alignItems: 'flex-start', + gap: '0.5rem', + width: '100%', + borderRadius: 8, + border: '1px solid {colors.greyscale.200}', + backgroundColor: 'white', + padding: '0.75rem 1rem', + textStyle: 'sm', + color: 'greyscale.800', +}) + +const helpIconClass = css({ + color: 'danger.600', + flexShrink: 0, + marginTop: '2px', +}) + +const ConnectionTest = () => { + const { t } = useTranslation('connectionTest') + const { steps, isRunning, runTest, reset } = useConnectionTestRunner() + + const stats = useMemo(() => summarizeSteps(steps), [steps]) + const stepsById = useMemo( + () => new Map(steps.map((step) => [step.id, step] as const)), + [steps] + ) + const isPublishVideoRunning = + stepsById.get('publishVideo')?.status === 'running' + + // LiveKit appends a bare