From 9187173caebe6bbde2d5f9ecabfc52a562cefbfc Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 25 Aug 2026 23:14:05 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20warn=20users=20when=20the?= =?UTF-8?q?=20connection=20falls=20back=20to=20TURN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlight in the connection test when the user is connecting through a TURN relay, especially over TLS or TCP. This usually indicates that some network configuration is required on their side, and gives them a concrete signal to pass to their IT team. Suggested by a technical user, this is a first step toward making users more autonomous when troubleshooting access to the tool. Follow-up: show a similar warning in-product when we detect a mid-meeting fallback to TURN/TLS. A one-time hint for first-time users would likely be enough. --- CHANGELOG.md | 6 +- docs/installation/kubernetes.md | 1 + src/backend/meet/settings.py | 5 ++ src/frontend/src/api/useConfig.ts | 1 + .../diagnostics/checks/selectedCandidate.ts | 63 ++++++++++++++- .../components/ConnectionTestSummary.tsx | 76 ++++++++++++++++--- .../diagnostics/components/stepAppearance.ts | 7 +- .../hooks/useConnectionTestRunner.ts | 20 ++++- .../src/features/diagnostics/types.ts | 7 +- src/frontend/src/layout/Footer.tsx | 39 ++++++---- .../src/locales/de/connectionTest.json | 6 ++ .../src/locales/en/connectionTest.json | 6 ++ .../src/locales/es/connectionTest.json | 6 ++ .../src/locales/fr/connectionTest.json | 6 ++ .../src/locales/nl/connectionTest.json | 6 ++ src/helm/env.d/common.yaml.gotmpl | 1 + 16 files changed, 222 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f5a24a..59dae864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to - ✨(frontend) let signed-out visitors start a meeting - ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration +### Changed + +- ✨(frontend) warn users when the connection falls back to TURN +- 🔧(backend) configure the technical documentation url + ### Fixed - 🐛(frontend) enforce recording-mode permissions on the checkboxes @@ -156,7 +161,6 @@ and this project adheres to ### Added - ✨(any) let any authenticated user manage the lobby on trusted rooms - ### Changed - 📱(frontend) collapse mobile control bar items on narrow viewports diff --git a/docs/installation/kubernetes.md b/docs/installation/kubernetes.md index 9f939921..414f3c32 100644 --- a/docs/installation/kubernetes.md +++ b/docs/installation/kubernetes.md @@ -347,6 +347,7 @@ These are the environmental options available on meet backend. | FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true | | FRONTEND_FEEDBACK | Frontend feedback configuration | {} | | FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | | +| FRONTEND_TECHNICAL_DOCUMENTATION_URL | URL of the technical documentation (network prerequisites) linked from the footer and the connection test. If unset, both links are hidden | | | FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false | | FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false | | DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend | diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 14b5c59d..27d05ae1 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -466,6 +466,11 @@ class Base(Configuration): "documentation_url": values.Value( None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None ), + "technical_documentation_url": values.Value( + None, + environ_name="FRONTEND_TECHNICAL_DOCUMENTATION_URL", + environ_prefix=None, + ), "external_home_url": values.Value( None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None ), diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index 8da9bed3..22b2e372 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -22,6 +22,7 @@ export interface ApiConfig { url: string } documentation_url?: string + technical_documentation_url?: string external_home_url?: string silence_livekit_debug_logs?: boolean is_silent_login_enabled?: boolean diff --git a/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts b/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts index 7ca26699..403a2b3c 100644 --- a/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts +++ b/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts @@ -22,6 +22,12 @@ export type IceCandidateInfo = { port?: number /** Local candidates only, and not reported by every browser. */ networkType?: string + /** + * For a local relay candidate, the TURN URL it was gathered from + * (e.g. `turns:turn.example.com:443?transport=tcp`). Used as a fallback + * when the browser does not report `relayProtocol`. + */ + url?: string } export type IceCandidatePair = { @@ -42,6 +48,57 @@ export type IceCandidateReport = { working: IceCandidatePair[] } +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** Narrows the loosely typed `data` stored on a step result. */ +export const isIceCandidateReport = ( + data: unknown +): data is IceCandidateReport => + isObject(data) && + Array.isArray(data.working) && + (data.selected === null || + (isObject(data.selected) && isObject(data.selected.local))) + +/** + * Transport between the browser and the TURN server for a local relay + * candidate: udp, tcp or tls, or undefined when it cannot be determined. + * + * `protocol` is deliberately not used here: on a relay candidate it describes + * the TURN allocation (server to peer), which is UDP even when the client + * reaches the TURN server over TLS. + */ +export const getRelayTransport = ( + candidate: IceCandidateInfo +): string | undefined => { + if (candidate.relayProtocol) return candidate.relayProtocol.toLowerCase() + if (!candidate.url) return undefined + + const url = candidate.url.toLowerCase() + if (url.startsWith('turns:')) return 'tls' + if (!url.startsWith('turn:')) return undefined + const transport = /[?&]transport=(udp|tcp)\b/.exec(url)?.[1] + // RFC 7065: a turn: URI without a transport parameter defaults to UDP. + return transport ?? 'udp' +} + +/** + * True when the selected pair goes through a TURN relay reached over TCP or + * TLS. Media still flows, but TCP head-of-line blocking usually degrades + * audio and video under packet loss. + * + * Direct routes (host, srflx, prflx), including ICE-TCP to the SFU, are out of + * scope: the warning and its documentation are about TURN fallbacks. + * An undetermined transport is not evidence of a bad route. + */ +export const isRelayedOverTcp = (data: unknown): boolean => { + if (!isIceCandidateReport(data) || !data.selected) return false + const { local } = data.selected + if (local.type !== 'relay') return false + const transport = getRelayTransport(local) + return transport === 'tcp' || transport === 'tls' +} + const PROBE_WIDTH = 320 const PROBE_HEIGHT = 180 const PROBE_FPS = 15 @@ -57,6 +114,7 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => { protocol: stats.protocol as string | undefined, relayProtocol: stats.relayProtocol as string | undefined, networkType: stats.networkType as string | undefined, + url: stats.url as string | undefined, ...(INCLUDE_CANDIDATE_ADDRESSES ? { address: stats.address as string | undefined, @@ -67,7 +125,10 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => { } const describeCandidate = (candidate: IceCandidateInfo) => { - const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown' + const transport = + (candidate.type === 'relay' ? getRelayTransport(candidate) : undefined) ?? + candidate.protocol ?? + 'unknown' const endpoint = candidate.address === undefined ? '' diff --git a/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx b/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx index 7552670d..839b3916 100644 --- a/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx +++ b/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx @@ -2,18 +2,44 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { ProgressBar } from 'react-aria-components' import { css, cx } from '@/styled-system/css' +import { A } from '@/primitives' +import { useConfig } from '@/api/useConfig' import type { ConnectionTestStats } from '../types' import { statusSquareClass } from './stepAppearance' -type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed' +type SummaryState = + | 'idle' + | 'running' + | 'passed' + | 'partial' + | 'failed' + | 'warning' -/** Only a failure earns a colour: everything else stays near-black. */ +/** Only a failure or a degraded route 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' }), + warning: css({ color: 'warning' }), +} + +/** + * A hard failure still outranks a warning step; a warning outranks 'partial' + * because a measured degraded route matters more than skipped camera or + * microphone checks. + */ +const getSummaryState = ( + stats: ConnectionTestStats, + isRunning: boolean +): SummaryState => { + if (isRunning) return 'running' + if (!stats.hasStarted) return 'idle' + if (stats.failed > 0) return 'failed' + if (stats.warnings > 0) return 'warning' + if (stats.skipped > 0) return 'partial' + return 'passed' } const cardClass = css({ @@ -183,16 +209,15 @@ export const ConnectionTestSummary = ({ children?: ReactNode }) => { const { t } = useTranslation('connectionTest') + const { data: config } = useConfig() - const state: SummaryState = isRunning - ? 'running' - : !stats.hasStarted - ? 'idle' - : stats.failed > 0 - ? 'failed' - : stats.skipped > 0 - ? 'partial' - : 'passed' + // Network prerequisites for the reader's IT department. Instance specific, + // so it comes from the backend; without it the warning shows no link. + const networkDocUrl = config?.technical_documentation_url + + const state = getSummaryState(stats, isRunning) + // Skipped device checks still deserve their hint under a route warning. + const showPartialHint = state === 'warning' && stats.skipped > 0 return (
@@ -206,7 +231,27 @@ export const ConnectionTestSummary = ({ : t(`summary.${state}`)}

-

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

+

+ {t(`summary.${state}Hint`)} + {state === 'warning' && networkDocUrl && ( + <> + {' '} + + {t('summary.warningDocLink')} + + + )} +

+ {showPartialHint && ( +

{t('summary.partialHint')}

+ )} {stats.hasStarted && ( @@ -237,6 +282,13 @@ export const ConnectionTestSummary = ({ value={stats.passed} label={t('counts.passed')} /> + {stats.warnings > 0 && ( + + )} = { animation: 'pulse_background 1.2s ease-in-out infinite', }), success: css({ backgroundColor: 'success.600' }), + warning: css({ backgroundColor: 'warning' }), failed: css({ backgroundColor: 'danger.600' }), skipped: css({ backgroundColor: 'greyscale.300' }), } -/** Colour is carried by the square; the label stays near-black except on failure. */ +/** + * Colour is carried by the square; the label stays near-black except on + * failure and warning. + */ export const statusTextClass: Record = { pending: css({ color: 'greyscale.500' }), running: css({ color: 'greyscale.700' }), success: css({ color: 'greyscale.1000' }), + warning: css({ color: 'warning', fontWeight: 'medium' }), 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 index e0c736ed..3f7f8990 100644 --- a/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts +++ b/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts @@ -8,7 +8,10 @@ import { type CheckInfo, } from 'livekit-client' import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails' -import { SelectedCandidateCheck } from '../checks/selectedCandidate' +import { + isRelayedOverTcp, + SelectedCandidateCheck, +} from '../checks/selectedCandidate' import { createInitialSteps, type ConnectionTestLog, @@ -49,10 +52,23 @@ const getErrorMessage = (error: unknown, fallback = 'Unknown error') => const isPermissionError = (error: unknown) => error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name) +const toStepStatus = (info: CheckInfo): ConnectionTestStepStatus => { + const status = CHECK_STATUS_TO_STEP[info.status] ?? 'failed' + return status === 'success' && isRelayedOverTcp(info.data) + ? 'warning' + : status +} + const fromCheckInfo = (info: CheckInfo): Partial => ({ - status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed', + status: toStepStatus(info), summary: info.description, logs: info.logs, + // Only SelectedCandidateCheck sets `data` (the ICE candidate report). + // Consumers narrow it with a type guard (see isIceCandidateReport). + data: + typeof info.data === 'object' && info.data !== null + ? (info.data as Record) + : undefined, }) const groupDevicesByKind = (devices: MediaDeviceInfo[]) => { diff --git a/src/frontend/src/features/diagnostics/types.ts b/src/frontend/src/features/diagnostics/types.ts index 817cb2a1..f6a5b311 100644 --- a/src/frontend/src/features/diagnostics/types.ts +++ b/src/frontend/src/features/diagnostics/types.ts @@ -15,6 +15,7 @@ export type ConnectionTestStepStatus = | 'pending' | 'running' | 'success' + | 'warning' | 'failed' | 'skipped' @@ -63,6 +64,7 @@ export type ConnectionTestStats = { total: number settled: number passed: number + warnings: number failed: number skipped: number hasStarted: boolean @@ -77,24 +79,27 @@ export const summarizeSteps = ( steps: ConnectionTestStepResult[] ): ConnectionTestStats => { let passed = 0 + let warnings = 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 === 'warning') warnings += 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 + const settled = passed + warnings + failed + skipped return { total, settled, passed, + warnings, failed, skipped, hasStarted: pending < total, diff --git a/src/frontend/src/layout/Footer.tsx b/src/frontend/src/layout/Footer.tsx index cfbfc426..c079ab64 100644 --- a/src/frontend/src/layout/Footer.tsx +++ b/src/frontend/src/layout/Footer.tsx @@ -126,6 +126,9 @@ export const Footer = () => { return null } + const isConnectionTestEnabled = !!data.diagnostics?.connection_test_enabled + const technicalDocumentationUrl = data.technical_documentation_url + return (