mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-05 00:17:42 +00:00
✨(frontend) add connection test feature
Introduce a new connection test page to allow users to verify their device and network compatibility with the application. The feature also supports generating and downloading a detailed report of the test results.
This commit is contained in:
committed by
lebaudantoine
parent
991c9be048
commit
3db7d9d62a
@@ -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
|
||||
|
||||
|
||||
@@ -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<ConnectionTestResponse>('/diagnostics/connection/')
|
||||
@@ -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 (
|
||||
<>
|
||||
<span className={identityClass}>
|
||||
<span className={labelClass}>{t(`steps.${step.id}`)}</span>
|
||||
{step.summary && <span className={valueClass}>{step.summary}</span>}
|
||||
</span>
|
||||
<StepStatusIndicator
|
||||
status={step.status}
|
||||
label={t(`status.${step.status}`)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cx(cardClass, rowClass)}>
|
||||
<StepRowContent step={step} />
|
||||
{/* Empty chevron column keeps non-expandable rows aligned. */}
|
||||
<span />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Disclosure className={cardClass}>
|
||||
{({ isExpanded }) => (
|
||||
<>
|
||||
<Heading level={3} className={headingResetClass}>
|
||||
<RACButton
|
||||
slot="trigger"
|
||||
className={cx(
|
||||
rowClass,
|
||||
triggerClass,
|
||||
isExpanded ? triggerExpandedClass : undefined
|
||||
)}
|
||||
>
|
||||
<StepRowContent step={step} />
|
||||
<RiArrowDownSFill
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
chevronClass,
|
||||
isExpanded ? chevronExpandedClass : undefined
|
||||
)}
|
||||
/>
|
||||
</RACButton>
|
||||
</Heading>
|
||||
<DisclosurePanel
|
||||
className={panelClass}
|
||||
aria-label={t('detailsFor', { step: t(`steps.${step.id}`) })}
|
||||
style={{ padding: isExpanded ? '0.75rem 1rem' : '0 1rem' }}
|
||||
>
|
||||
{/* Collapsed panels stay in the DOM for aria-controls, but the log
|
||||
lines themselves are only mounted when actually visible. */}
|
||||
{isExpanded && (
|
||||
<ul className={logListClass}>
|
||||
{step.logs?.map((log, index) => (
|
||||
<li key={`${log.level}-${index}`} className={logItemClass}>
|
||||
{log.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
)
|
||||
}
|
||||
@@ -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<SummaryState, string> = {
|
||||
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 (
|
||||
<span className={cx(counterClass, isEmpty ? emptyCounterClass : undefined)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
counterSquareClass,
|
||||
squareClass,
|
||||
isEmpty ? emptySquareClass : undefined
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cx(
|
||||
counterValueClass,
|
||||
isEmpty ? emptyCounterClass : undefined
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className={cardClass}>
|
||||
<div className={headerClass}>
|
||||
<h1 className={eyebrowClass}>{t('title')}</h1>
|
||||
|
||||
{/* Announced once per state change rather than on every step update. */}
|
||||
<p className={cx(headlineClass, stateColorClass[state])} role="status">
|
||||
{state === 'failed'
|
||||
? t('summary.failed', { count: stats.failed })
|
||||
: t(`summary.${state}`)}
|
||||
</p>
|
||||
|
||||
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
|
||||
</div>
|
||||
|
||||
{stats.hasStarted && (
|
||||
<div className={dividerClass}>
|
||||
<div className={progressRowClass}>
|
||||
<ProgressBar
|
||||
aria-label={t('progressLabel')}
|
||||
value={stats.progress}
|
||||
className={css({ flex: 1 })}
|
||||
>
|
||||
{({ percentage }) => (
|
||||
<div className={trackClass}>
|
||||
<div
|
||||
className={fillClass}
|
||||
style={{ width: `${percentage ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ProgressBar>
|
||||
<span className={progressValueClass}>
|
||||
{t('progress', { done: stats.settled, total: stats.total })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={countersClass}>
|
||||
<Counter
|
||||
squareClass={statusSquareClass.success}
|
||||
value={stats.passed}
|
||||
label={t('counts.passed')}
|
||||
/>
|
||||
<Counter
|
||||
squareClass={statusSquareClass.skipped}
|
||||
value={stats.skipped}
|
||||
label={t('counts.skipped')}
|
||||
/>
|
||||
<Counter
|
||||
squareClass={statusSquareClass.failed}
|
||||
value={stats.failed}
|
||||
label={t('counts.failed')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className={actionsClass}>{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}) => (
|
||||
<span className={cx(wrapperClass, className)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(squareClass, statusSquareClass[status])}
|
||||
/>
|
||||
<span className={statusTextClass[status]}>{label}</span>
|
||||
</span>
|
||||
)
|
||||
@@ -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<ConnectionTestStepStatus, string> = {
|
||||
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<ConnectionTestStepStatus, string> = {
|
||||
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' }),
|
||||
}
|
||||
@@ -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, ConnectionTestStepStatus> = {
|
||||
[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<ConnectionTestStepResult> => ({
|
||||
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||
summary: info.description,
|
||||
logs: info.logs,
|
||||
})
|
||||
|
||||
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||
const grouped: Record<string, string[]> = {
|
||||
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<AbortController | null>(null)
|
||||
|
||||
const updateStep = useCallback(
|
||||
(id: ConnectionTestStepId, patch: Partial<ConnectionTestStepResult>) => {
|
||||
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<Partial<ConnectionTestStepResult>>
|
||||
): Promise<StepOutcome> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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 <video> to document.body during publishVideo.
|
||||
// Keep it in the DOM (so the frame check still works) but hide it visually.
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle(
|
||||
HIDE_LIVEKIT_VIDEO_CLASS,
|
||||
isPublishVideoRunning
|
||||
)
|
||||
return () => {
|
||||
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
|
||||
}
|
||||
}, [isPublishVideoRunning])
|
||||
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<Permissions />
|
||||
<CenteredContent withBackButton>
|
||||
<Center>
|
||||
<VStack gap="1.5rem" maxWidth="40rem" width="100%">
|
||||
<ConnectionTestSummary stats={stats} isRunning={isRunning}>
|
||||
{isRunning ? (
|
||||
// A disabled "run" button while the test runs is dead weight:
|
||||
// cancelling is the only thing left to do.
|
||||
<Button
|
||||
variant="secondary"
|
||||
onPress={reset}
|
||||
icon={<RiCloseLine size={18} aria-hidden="true" />}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={runTest}
|
||||
icon={<RiPlayLine size={18} aria-hidden="true" />}
|
||||
>
|
||||
{stats.hasStarted ? t('runAgain') : t('runTest')}
|
||||
</Button>
|
||||
)}
|
||||
{stats.hasStarted && !isRunning && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onPress={() => downloadConnectionTestReport(steps)}
|
||||
icon={<RiDownload2Line size={18} aria-hidden="true" />}
|
||||
>
|
||||
{t('downloadReport')}
|
||||
</Button>
|
||||
)}
|
||||
</ConnectionTestSummary>
|
||||
|
||||
{stats.hasStarted &&
|
||||
CONNECTION_TEST_GROUPS.map((group) => (
|
||||
<section key={group.id} className={sectionClass}>
|
||||
<h2 className={sectionTitleClass}>
|
||||
{t(`groups.${group.id}`)}
|
||||
</h2>
|
||||
<div className={rowsClass}>
|
||||
{group.steps.map((id) => {
|
||||
const step = stepsById.get(id)
|
||||
return step ? (
|
||||
<ConnectionTestStepRow key={id} step={step} />
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{stats.failed > 0 && !isRunning && (
|
||||
<p className={helpClass}>
|
||||
<RiErrorWarningLine
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
className={helpIconClass}
|
||||
/>
|
||||
{t('help.firewall')}
|
||||
</p>
|
||||
)}
|
||||
</VStack>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectionTest
|
||||
@@ -0,0 +1,101 @@
|
||||
export type ConnectionTestStepId =
|
||||
| 'browser'
|
||||
| 'microphone'
|
||||
| 'camera'
|
||||
| 'devices'
|
||||
| 'websocket'
|
||||
| 'webrtc'
|
||||
| 'turn'
|
||||
| 'reconnect'
|
||||
| 'publishAudio'
|
||||
| 'publishVideo'
|
||||
|
||||
export type ConnectionTestStepStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
|
||||
export type ConnectionTestLog = {
|
||||
level: 'info' | 'warning' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConnectionTestStepResult = {
|
||||
id: ConnectionTestStepId
|
||||
status: ConnectionTestStepStatus
|
||||
summary?: string
|
||||
logs?: ConnectionTestLog[]
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ConnectionTestGroupId = 'local' | 'network'
|
||||
|
||||
/** Display order: everything local first, then everything that leaves the machine. */
|
||||
export const CONNECTION_TEST_GROUPS: ReadonlyArray<{
|
||||
id: ConnectionTestGroupId
|
||||
steps: ReadonlyArray<ConnectionTestStepId>
|
||||
}> = [
|
||||
{ id: 'local', steps: ['browser', 'microphone', 'camera', 'devices'] },
|
||||
{
|
||||
id: 'network',
|
||||
steps: [
|
||||
'websocket',
|
||||
'webrtc',
|
||||
'turn',
|
||||
'reconnect',
|
||||
'publishAudio',
|
||||
'publishVideo',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] =
|
||||
CONNECTION_TEST_GROUPS.flatMap((group) => [...group.steps])
|
||||
|
||||
export const createInitialSteps = (): ConnectionTestStepResult[] =>
|
||||
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
|
||||
|
||||
export type ConnectionTestStats = {
|
||||
total: number
|
||||
settled: number
|
||||
passed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
hasStarted: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Single pass over the steps: the page needs half a dozen derived booleans and
|
||||
* counters, and scanning the array once per render beats one `.some()` per flag.
|
||||
*/
|
||||
export const summarizeSteps = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
): ConnectionTestStats => {
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
let skipped = 0
|
||||
let pending = 0
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.status === 'success') passed += 1
|
||||
else if (step.status === 'failed') failed += 1
|
||||
else if (step.status === 'skipped') skipped += 1
|
||||
else if (step.status === 'pending') pending += 1
|
||||
}
|
||||
|
||||
const total = steps.length
|
||||
const settled = passed + failed + skipped
|
||||
|
||||
return {
|
||||
total,
|
||||
settled,
|
||||
passed,
|
||||
failed,
|
||||
skipped,
|
||||
hasStarted: pending < total,
|
||||
progress: total === 0 ? 0 : Math.round((settled / total) * 100),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ConnectionTestStepResult } from '../types'
|
||||
|
||||
export type ConnectionTestReport = {
|
||||
generatedAt: string
|
||||
userAgent: string
|
||||
steps: Record<
|
||||
string,
|
||||
{
|
||||
status: ConnectionTestStepResult['status']
|
||||
summary?: string
|
||||
logs?: ConnectionTestStepResult['logs']
|
||||
data?: ConnectionTestStepResult['data']
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export const buildConnectionTestReport = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
): ConnectionTestReport => ({
|
||||
generatedAt: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
steps: Object.fromEntries(
|
||||
steps.map(({ id, status, summary, logs, data }) => [
|
||||
id,
|
||||
{
|
||||
status,
|
||||
...(summary !== undefined ? { summary } : {}),
|
||||
...(logs?.length ? { logs } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
},
|
||||
])
|
||||
),
|
||||
})
|
||||
|
||||
export const downloadConnectionTestReport = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
) => {
|
||||
const report = buildConnectionTestReport(steps)
|
||||
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `connection-test-${timestamp}.json`
|
||||
// Firefox only follows the click when the anchor is in the document, and
|
||||
// revoking the URL in the same tick cancels the download in some browsers.
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
@@ -266,6 +266,16 @@ export const Footer = () => {
|
||||
{t('links.accessibility')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
to="/test-connection"
|
||||
aria-label={t('links.connectionTest')}
|
||||
>
|
||||
{t('links.connectionTest')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"title": "Test your configuration",
|
||||
"runTest": "Run test",
|
||||
"runAgain": "Run again",
|
||||
"cancel": "Cancel",
|
||||
"detailsFor": "Details for {{step}}",
|
||||
"downloadReport": "Download report",
|
||||
"homeLink": "Test your configuration",
|
||||
"progress": "{{done}}/{{total}}",
|
||||
"progressLabel": "Connection test progress",
|
||||
"groups": {
|
||||
"local": "Browser and devices",
|
||||
"network": "Server connectivity"
|
||||
},
|
||||
"steps": {
|
||||
"browser": "Browser",
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
"devices": "Media devices",
|
||||
"websocket": "WebSocket",
|
||||
"webrtc": "WebRTC",
|
||||
"turn": "TURN",
|
||||
"reconnect": "Reconnect",
|
||||
"publishAudio": "Audio publishing",
|
||||
"publishVideo": "Video publishing"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"running": "Running…",
|
||||
"success": "Passed",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "passed",
|
||||
"failed": "failed",
|
||||
"skipped": "skipped"
|
||||
},
|
||||
"summary": {
|
||||
"idle": "Ready to test",
|
||||
"idleHint": "The test takes about a minute. Your camera and microphone are only used while it runs.",
|
||||
"running": "Testing…",
|
||||
"runningHint": "Keep this page open until every check has finished.",
|
||||
"passed": "Everything works",
|
||||
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
|
||||
"partial": "Partially tested",
|
||||
"partialHint": "Some checks were skipped. Allow access to your camera and microphone to test them.",
|
||||
"failed_one": "{{count}} check failed",
|
||||
"failed_other": "{{count}} checks failed",
|
||||
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
|
||||
},
|
||||
"help": {
|
||||
"firewall": "If network tests fail, check your browser permissions and network filtering rules (WebRTC, WebSocket, TURN) with your IT department."
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
"legalsTerms": "Legal Notice",
|
||||
"data": "Personal Data and Cookies",
|
||||
"accessibility": "Accessibility: non-compliant",
|
||||
"connectionTest": "Test your configuration",
|
||||
"ariaLabel": "new window",
|
||||
"codeAnnotation": "Our code is open and available on this",
|
||||
"code": "Open Source Code Repository",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
||||
"moreLink": "Learn more",
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
"connectionTestLink": "Test your configuration",
|
||||
"createMenu": {
|
||||
"laterOption": "Create a meeting for a later date",
|
||||
"instantOption": "Start an instant meeting"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"title": "Tester votre configuration",
|
||||
"runTest": "Lancer le test",
|
||||
"runAgain": "Relancer",
|
||||
"cancel": "Annuler",
|
||||
"detailsFor": "Détails de l'étape {{step}}",
|
||||
"downloadReport": "Télécharger le rapport",
|
||||
"homeLink": "Tester votre configuration",
|
||||
"progress": "{{done}}/{{total}}",
|
||||
"progressLabel": "Progression du test de connexion",
|
||||
"groups": {
|
||||
"local": "Navigateur et périphériques",
|
||||
"network": "Connexion au serveur"
|
||||
},
|
||||
"steps": {
|
||||
"browser": "Navigateur",
|
||||
"microphone": "Microphone",
|
||||
"camera": "Caméra",
|
||||
"devices": "Périphériques médias",
|
||||
"websocket": "WebSocket",
|
||||
"webrtc": "WebRTC",
|
||||
"turn": "TURN",
|
||||
"reconnect": "Reconnexion",
|
||||
"publishAudio": "Publication audio",
|
||||
"publishVideo": "Publication vidéo"
|
||||
},
|
||||
"status": {
|
||||
"pending": "En attente",
|
||||
"running": "En cours…",
|
||||
"success": "Réussi",
|
||||
"failed": "Échec",
|
||||
"skipped": "Ignoré"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "réussis",
|
||||
"failed": "en échec",
|
||||
"skipped": "ignorés"
|
||||
},
|
||||
"summary": {
|
||||
"idle": "Prêt à tester",
|
||||
"idleHint": "Le test dure environ une minute. Votre caméra et votre microphone ne sont utilisés que pendant le test.",
|
||||
"running": "Test en cours…",
|
||||
"runningHint": "Gardez cette page ouverte jusqu'à la fin des vérifications.",
|
||||
"passed": "Tout fonctionne",
|
||||
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
|
||||
"partial": "Test partiel",
|
||||
"partialHint": "Certaines vérifications ont été ignorées. Autorisez l'accès à votre caméra et à votre microphone pour les tester.",
|
||||
"failed_one": "{{count}} vérification en échec",
|
||||
"failed_other": "{{count}} vérifications en échec",
|
||||
"failedHint": "Ouvrez les vérifications en échec pour voir le détail, puis transmettez le rapport à votre service informatique."
|
||||
},
|
||||
"help": {
|
||||
"firewall": "En cas d'échec des tests réseau, vérifiez vos permissions navigateur et les règles de filtrage réseau (WebRTC, WebSocket, TURN) auprès de votre service informatique."
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
"legalsTerms": "Mentions légales",
|
||||
"data": "Données personnelles et cookie",
|
||||
"accessibility": "Accessibilité : non conforme",
|
||||
"connectionTest": "Tester votre configuration",
|
||||
"ariaLabel": "nouvelle fenêtre",
|
||||
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
|
||||
"code": "dépôt de code Open Source",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
||||
"moreLink": "En savoir plus",
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
"connectionTestLink": "Tester votre configuration",
|
||||
"createMenu": {
|
||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||
"instantOption": "Démarrer une réunion instantanée"
|
||||
|
||||
@@ -20,6 +20,9 @@ const AccessibilityRoute = lazy(
|
||||
)
|
||||
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
|
||||
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
|
||||
const ConnectionTestRoute = lazy(
|
||||
() => import('@/features/diagnostics/routes/ConnectionTest')
|
||||
)
|
||||
|
||||
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
||||
|
||||
@@ -27,6 +30,7 @@ export const routes: Record<
|
||||
| 'home'
|
||||
| 'room'
|
||||
| 'feedback'
|
||||
| 'connectionTest'
|
||||
| 'legalTerms'
|
||||
| 'accessibility'
|
||||
| 'termsOfService'
|
||||
@@ -57,6 +61,11 @@ export const routes: Record<
|
||||
path: '/feedback',
|
||||
Component: FeedbackRoute,
|
||||
},
|
||||
connectionTest: {
|
||||
name: 'connectionTest',
|
||||
path: '/test-connection',
|
||||
Component: ConnectionTestRoute,
|
||||
},
|
||||
legalTerms: {
|
||||
name: 'legalTerms',
|
||||
path: '/mentions-legales',
|
||||
|
||||
@@ -31,6 +31,19 @@ html.font-opendyslexic {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* LiveKit ConnectionCheck appends a temporary <video> to body during publishVideo.
|
||||
Keep it decodable (not display:none) but invisible. */
|
||||
body.connection-test-hide-livekit-video > video {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user