diff --git a/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts b/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts
index 7ca26699..e41113ea 100644
--- a/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts
+++ b/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts
@@ -42,6 +42,23 @@ export type IceCandidateReport = {
working: IceCandidatePair[]
}
+/**
+ * True when the selected pair does not carry media over plain UDP: the browser
+ * fell back to a TURN relay over TCP or TLS, or even the direct route is not
+ * UDP. Media still flows, but quality usually degrades under load.
+ *
+ * Accepts the loosely typed `data` stored on the step result; anything that is
+ * not an IceCandidateReport simply yields false.
+ */
+export const isSuboptimalRoute = (data: unknown): boolean => {
+ const selected = (data as IceCandidateReport | null | undefined)?.selected
+ if (!selected) return false
+ const transport = selected.local.relayProtocol ?? selected.local.protocol
+ // An unreported transport is not evidence of a bad route.
+ if (!transport) return false
+ return transport.toLowerCase() !== 'udp'
+}
+
const PROBE_WIDTH = 320
const PROBE_HEIGHT = 180
const PROBE_FPS = 15
diff --git a/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx b/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx
index 7552670d..153225d5 100644
--- a/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx
+++ b/src/frontend/src/features/diagnostics/components/ConnectionTestSummary.tsx
@@ -2,18 +2,33 @@ 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 type { ConnectionTestStats } from '../types'
import { statusSquareClass } from './stepAppearance'
-type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
+/**
+ * Network prerequisites (protocols, ports, TURN fallbacks), written for the
+ * reader's IT department rather than for the end user.
+ */
+const NETWORK_REQUIREMENTS_DOC_URL =
+ 'https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/'
-/** Only a failure earns a colour: everything else stays near-black. */
+type SummaryState =
+ | 'idle'
+ | 'running'
+ | 'passed'
+ | 'partial'
+ | 'failed'
+ | 'warning'
+
+/** 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' }),
}
const cardClass = css({
@@ -176,23 +191,31 @@ const Counter = ({
export const ConnectionTestSummary = ({
stats,
isRunning,
+ routeWarning = false,
children,
}: {
stats: ConnectionTestStats
isRunning: boolean
+ /** The selected ICE route is usable but not UDP (e.g. TURN over TCP/TLS). */
+ routeWarning?: boolean
children?: ReactNode
}) => {
const { t } = useTranslation('connectionTest')
+ // A hard failure still outranks the route warning; the warning outranks
+ // 'partial' because a measured degraded route matters more than skipped
+ // camera or microphone checks.
const state: SummaryState = isRunning
? 'running'
: !stats.hasStarted
? 'idle'
: stats.failed > 0
? 'failed'
- : stats.skipped > 0
- ? 'partial'
- : 'passed'
+ : routeWarning
+ ? 'warning'
+ : stats.skipped > 0
+ ? 'partial'
+ : 'passed'
return (
@@ -206,7 +229,22 @@ export const ConnectionTestSummary = ({
: t(`summary.${state}`)}
- {t(`summary.${state}Hint`)}
+
+ {t(`summary.${state}Hint`)}
+ {state === 'warning' && (
+ <>
+ {' '}
+
+ {t('summary.warningDocLink')}
+
+ >
+ )}
+
{stats.hasStarted && (
diff --git a/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts b/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts
index e0c736ed..467ee345 100644
--- a/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts
+++ b/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts
@@ -53,6 +53,9 @@ const fromCheckInfo = (info: CheckInfo): Partial => ({
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
summary: info.description,
logs: info.logs,
+ // Only SelectedCandidateCheck sets `data` (the ICE candidate report); for
+ // the stock LiveKit checks it is undefined and this is a no-op.
+ data: info.data as Record | undefined,
})
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
diff --git a/src/frontend/src/features/diagnostics/routes/ConnectionTest.tsx b/src/frontend/src/features/diagnostics/routes/ConnectionTest.tsx
index d4eb3134..de1ab5dd 100644
--- a/src/frontend/src/features/diagnostics/routes/ConnectionTest.tsx
+++ b/src/frontend/src/features/diagnostics/routes/ConnectionTest.tsx
@@ -13,6 +13,7 @@ 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 { isSuboptimalRoute } from '../checks/selectedCandidate'
import { ConnectionTestStepRow } from '../components/ConnectionTestStepRow'
import { ConnectionTestSummary } from '../components/ConnectionTestSummary'
import { CONNECTION_TEST_GROUPS, summarizeSteps } from '../types'
@@ -70,6 +71,13 @@ const ConnectionTest = () => {
() => new Map(steps.map((step) => [step.id, step] as const)),
[steps]
)
+ // The summary headline downgrades to a warning when media flows but over a
+ // fallback route: TURN over TCP/TLS, or anything else that is not UDP.
+ const routeWarning = useMemo(() => {
+ const step = stepsById.get('selectedCandidate')
+ return step?.status === 'success' && isSuboptimalRoute(step.data)
+ }, [stepsById])
+
const isPublishVideoRunning =
stepsById.get('publishVideo')?.status === 'running'
@@ -99,7 +107,11 @@ const ConnectionTest = () => {
-
+
{isRunning ? (
// A disabled "run" button while the test runs is dead weight:
// cancelling is the only thing left to do.
diff --git a/src/frontend/src/locales/de/connectionTest.json b/src/frontend/src/locales/de/connectionTest.json
index 226940f1..b235d64e 100644
--- a/src/frontend/src/locales/de/connectionTest.json
+++ b/src/frontend/src/locales/de/connectionTest.json
@@ -46,6 +46,9 @@
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
"partial": "Teilweiser Test",
"partialHint": "Einige Prüfungen wurden übersprungen. Erlauben Sie den Zugriff auf Ihre Kamera und Ihr Mikrofon, um diese zu testen.",
+ "warning": "Verbindung nicht optimal",
+ "warningHint": "Achtung: Sie befinden sich nicht in optimalen Bedingungen für die Nutzung des Tools. Ihre Medien werden nicht über UDP übertragen (Rückgriff auf ein TURN-Relay über TCP oder TLS), was die Audio- und Videoqualität beeinträchtigen kann.",
+ "warningDocLink": "Technische Dokumentation für Ihre IT-Abteilung",
"failed_one": "{{count}} Prüfung fehlgeschlagen",
"failed_other": "{{count}} Prüfungen fehlgeschlagen",
"failedHint": "Öffnen Sie die fehlgeschlagenen Prüfungen für weitere Details und senden Sie den Bericht an Ihre IT-Abteilung."
diff --git a/src/frontend/src/locales/en/connectionTest.json b/src/frontend/src/locales/en/connectionTest.json
index 8c6868a5..d58e9158 100644
--- a/src/frontend/src/locales/en/connectionTest.json
+++ b/src/frontend/src/locales/en/connectionTest.json
@@ -46,6 +46,9 @@
"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.",
+ "warning": "Suboptimal connection",
+ "warningHint": "Warning: you are not in optimal conditions to use the tool. Your media is not carried over UDP (it falls back to a TURN relay over TCP or TLS), which can degrade audio and video quality.",
+ "warningDocLink": "Technical documentation for your IT department",
"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."
diff --git a/src/frontend/src/locales/fr/connectionTest.json b/src/frontend/src/locales/fr/connectionTest.json
index 6390122a..ccf00716 100644
--- a/src/frontend/src/locales/fr/connectionTest.json
+++ b/src/frontend/src/locales/fr/connectionTest.json
@@ -46,6 +46,9 @@
"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.",
+ "warning": "Connexion non optimale",
+ "warningHint": "Attention : vous n'êtes pas dans les conditions optimales pour accéder à l'outil. Vos flux ne transitent pas en UDP (repli sur un relais TURN en TCP ou TLS), ce qui peut dégrader la qualité audio et vidéo.",
+ "warningDocLink": "Documentation technique à destination de votre service informatique",
"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."
diff --git a/src/frontend/src/locales/nl/connectionTest.json b/src/frontend/src/locales/nl/connectionTest.json
index 785c629b..5ba53983 100644
--- a/src/frontend/src/locales/nl/connectionTest.json
+++ b/src/frontend/src/locales/nl/connectionTest.json
@@ -46,6 +46,9 @@
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
"partial": "Gedeeltelijke test",
"partialHint": "Sommige controles zijn overgeslagen. Geef toegang tot je camera en microfoon om deze te testen.",
+ "warning": "Verbinding niet optimaal",
+ "warningHint": "Let op: je bevindt je niet in optimale omstandigheden om de tool te gebruiken. Je media loopt niet via UDP (terugval op een TURN-relay via TCP of TLS), wat de audio- en videokwaliteit kan verminderen.",
+ "warningDocLink": "Technische documentatie voor je IT-afdeling",
"failed_one": "{{count}} controle mislukt",
"failed_other": "{{count}} controles mislukt",
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."