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}`)}