mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-01 22:52:16 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56c381cbee | |||
| 0b53a26406 |
@@ -14,6 +14,7 @@ and this project adheres to
|
|||||||
- ✨(frontend) add configurable documentation menu item
|
- ✨(frontend) add configurable documentation menu item
|
||||||
- ✨(frontend) allow promoting authenticated participants
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
|
- ✨(frontend) add connection test feature
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Connection test API endpoint."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework.decorators import api_view, throttle_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from core.api.throttling import (
|
||||||
|
ConnectionTestAnonRateThrottle,
|
||||||
|
ConnectionTestUserRateThrottle,
|
||||||
|
)
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
from core.utils import generate_token
|
||||||
|
|
||||||
|
CONNECTION_TEST_USERNAME = "Connection Test"
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle])
|
||||||
|
def create_connection_test_config(request):
|
||||||
|
"""Create a short-lived LiveKit token for an ephemeral connection test room."""
|
||||||
|
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid4()}"
|
||||||
|
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
|
||||||
|
|
||||||
|
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
|
||||||
|
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
|
||||||
|
# is available.
|
||||||
|
if settings.CELERY_ENABLED:
|
||||||
|
delete_connection_test_room.apply_async(
|
||||||
|
args=[room],
|
||||||
|
countdown=settings.CONNECTION_TEST_ROOM_MAX_AGE_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"livekit": {
|
||||||
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
"room": room,
|
||||||
|
"token": generate_token(
|
||||||
|
room=room,
|
||||||
|
user=request.user,
|
||||||
|
username=CONNECTION_TEST_USERNAME,
|
||||||
|
ttl=timedelta(seconds=expires_in),
|
||||||
|
),
|
||||||
|
"expires_in": expires_in,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle Anonymous user requesting room generation callback"""
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|
||||||
scope = "creation_callback"
|
scope = "creation_callback"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
|
||||||
|
"""Throttle authenticated users requesting connection test tokens."""
|
||||||
|
|
||||||
|
scope = "connection_test"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
|
"""Throttle anonymous users requesting connection test tokens."""
|
||||||
|
|
||||||
|
scope = "connection_test"
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
room_name = data.room.name or data.egress_info.room_name
|
room_name = data.room.name or data.egress_info.room_name
|
||||||
|
|
||||||
|
if self._is_connection_test_room(room_name):
|
||||||
|
logger.info(
|
||||||
|
"Ignoring webhook event for connection test room '%s'.",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if self._filter_regex and not self._filter_regex.search(room_name):
|
if self._filter_regex and not self._filter_regex.search(room_name):
|
||||||
logger.info("Filtered webhook event for room '%s'", room_name)
|
logger.info("Filtered webhook event for room '%s'", room_name)
|
||||||
return
|
return
|
||||||
@@ -228,6 +235,13 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_connection_test_room(room_name: str) -> bool:
|
||||||
|
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||||
|
return isinstance(room_name, str) and room_name.startswith(
|
||||||
|
settings.CONNECTION_TEST_ROOM_PREFIX
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_room_started(self, data):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import (
|
from livekit.api import (
|
||||||
|
DeleteRoomRequest,
|
||||||
ListRoomsRequest,
|
ListRoomsRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
UpdateRoomMetadataRequest,
|
||||||
@@ -88,3 +89,30 @@ class RoomManagement:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
@async_to_sync
|
||||||
|
async def delete_room(self, room_name: str):
|
||||||
|
"""Delete a LiveKit room and disconnect all participants.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RoomNotFoundException: the room does not exist in LiveKit.
|
||||||
|
RoomManagementException: the deletion otherwise fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lkapi = utils.create_livekit_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
|
||||||
|
logger.info("Deleted LiveKit room %s", room_name)
|
||||||
|
except TwirpError as e:
|
||||||
|
if e.code == "not_found":
|
||||||
|
logger.warning(
|
||||||
|
"Room %s not found in LiveKit, skipping deletion",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
raise RoomNotFoundException("Room does not exist") from e
|
||||||
|
|
||||||
|
logger.exception("Unexpected error deleting room %s", room_name)
|
||||||
|
raise RoomManagementException("Could not delete room") from e
|
||||||
|
finally:
|
||||||
|
await lkapi.aclose()
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Celery tasks for the core app."""
|
||||||
|
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"delete_connection_test_room",
|
||||||
|
"process_file_deletion",
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Tasks related to connection test rooms."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
from core.tasks._task import task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def delete_connection_test_room(room_name: str):
|
||||||
|
"""Force-delete an ephemeral connection-test room.
|
||||||
|
|
||||||
|
Used as a hard cap so a participant cannot keep an auto-refreshed
|
||||||
|
LiveKit session open indefinitely after requesting a test token.
|
||||||
|
"""
|
||||||
|
prefix = settings.CONNECTION_TEST_ROOM_PREFIX
|
||||||
|
if not room_name.startswith(prefix):
|
||||||
|
logger.error(
|
||||||
|
"Refusing to delete room '%s': expected prefix '%s'.",
|
||||||
|
room_name,
|
||||||
|
prefix,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
RoomManagement().delete_room(room_name)
|
||||||
|
except RoomNotFoundException:
|
||||||
|
# Room may already be gone after empty/departure timeout.
|
||||||
|
logger.info("Connection test room '%s' already gone.", room_name)
|
||||||
|
except RoomManagementException:
|
||||||
|
logger.exception("Failed to delete connection test room '%s'.", room_name)
|
||||||
@@ -6,6 +6,8 @@ Test LiveKitEvents service.
|
|||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import EgressStatus
|
from livekit.api import EgressStatus
|
||||||
|
|
||||||
@@ -551,6 +553,34 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
|||||||
mock_clear_cache.assert_not_called()
|
mock_clear_cache.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||||
|
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||||
|
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
|
||||||
|
def test_receive_ignores_connection_test_room(
|
||||||
|
mock_handle_room_started,
|
||||||
|
mock_handle_room_finished,
|
||||||
|
mock_receive,
|
||||||
|
mock_livekit_config,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Should ignore all webhook events for connection test rooms in receive()."""
|
||||||
|
mock_request = mock.MagicMock()
|
||||||
|
mock_request.headers = {"Authorization": "test_token"}
|
||||||
|
mock_request.body = b"{}"
|
||||||
|
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||||
|
mock_data.event = "room_started"
|
||||||
|
mock_receive.return_value = mock_data
|
||||||
|
|
||||||
|
service = LiveKitEventsService()
|
||||||
|
service.receive(mock_request)
|
||||||
|
|
||||||
|
mock_handle_room_started.assert_not_called()
|
||||||
|
mock_handle_room_finished.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||||
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for the RoomManagement service."""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||||
|
def test_delete_room_calls_livekit(mock_create_livekit_client):
|
||||||
|
"""DeleteRoom is forwarded to the LiveKit API."""
|
||||||
|
mock_api = mock.MagicMock()
|
||||||
|
mock_api.room.delete_room = mock.AsyncMock()
|
||||||
|
mock_api.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
|
mock_api.room.delete_room.assert_awaited_once()
|
||||||
|
request = mock_api.room.delete_room.await_args.args[0]
|
||||||
|
assert request.room == "room-abc"
|
||||||
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||||
|
def test_delete_room_raises_not_found(mock_create_livekit_client):
|
||||||
|
"""Missing rooms raise RoomNotFoundException."""
|
||||||
|
mock_api = mock.MagicMock()
|
||||||
|
mock_api.room.delete_room = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError("not_found", "room not found", status=404)
|
||||||
|
)
|
||||||
|
mock_api.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
|
with pytest.raises(RoomNotFoundException):
|
||||||
|
RoomManagement().delete_room("missing-room")
|
||||||
|
|
||||||
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||||
|
def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
||||||
|
"""Unexpected Twirp errors raise RoomManagementException."""
|
||||||
|
mock_api = mock.MagicMock()
|
||||||
|
mock_api.room.delete_room = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError("internal", "boom", status=500)
|
||||||
|
)
|
||||||
|
mock_api.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
|
with pytest.raises(RoomManagementException):
|
||||||
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
|
mock_api.aclose.assert_awaited_once()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for connection test Celery tasks."""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||||
|
def test_delete_connection_test_room_calls_room_management(mock_delete_room):
|
||||||
|
"""RoomManagement.delete_room is called for connection-test rooms."""
|
||||||
|
delete_connection_test_room("connection-test-abc")
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with("connection-test-abc")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||||
|
def test_delete_connection_test_room_refuses_other_rooms(mock_delete_room):
|
||||||
|
"""Refuse to delete rooms outside the connection-test namespace."""
|
||||||
|
delete_connection_test_room("production-room")
|
||||||
|
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||||
|
def test_delete_connection_test_room_ignores_missing_room(mock_delete_room):
|
||||||
|
"""Missing rooms are treated as already cleaned up."""
|
||||||
|
mock_delete_room.side_effect = RoomNotFoundException("Room does not exist")
|
||||||
|
|
||||||
|
delete_connection_test_room("connection-test-gone")
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with("connection-test-gone")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||||
|
def test_delete_connection_test_room_logs_other_failures(mock_delete_room):
|
||||||
|
"""Unexpected LiveKit failures are swallowed after logging."""
|
||||||
|
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
|
||||||
|
|
||||||
|
delete_connection_test_room("connection-test-fail")
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with("connection-test-fail")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Test connection test API endpoint."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.api.connection_test import CONNECTION_TEST_USERNAME
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CONNECTION_TEST_TOKEN_TTL_SECONDS=600,
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
|
||||||
|
)
|
||||||
|
def test_api_connection_test_returns_ephemeral_livekit_config():
|
||||||
|
"""Each request gets a dedicated room and a short-lived token."""
|
||||||
|
client = APIClient()
|
||||||
|
response_a = client.post("/api/v1.0/connection-test/")
|
||||||
|
response_b = client.post("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response_a.status_code == 200
|
||||||
|
assert response_b.status_code == 200
|
||||||
|
|
||||||
|
data_a = response_a.json()
|
||||||
|
data_b = response_b.json()
|
||||||
|
|
||||||
|
room_a = data_a["livekit"]["room"]
|
||||||
|
room_b = data_b["livekit"]["room"]
|
||||||
|
|
||||||
|
assert room_a.startswith("connection-test-")
|
||||||
|
assert room_b.startswith("connection-test-")
|
||||||
|
uuid.UUID(room_a.removeprefix("connection-test-"))
|
||||||
|
uuid.UUID(room_b.removeprefix("connection-test-"))
|
||||||
|
assert room_a != room_b
|
||||||
|
assert data_a["livekit"]["url"]
|
||||||
|
assert data_a["livekit"]["token"]
|
||||||
|
assert data_a["livekit"]["expires_in"] == 600
|
||||||
|
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_TOKEN_TTL_SECONDS=300)
|
||||||
|
def test_api_connection_test_token_is_short_lived_for_user(settings):
|
||||||
|
"""Connection test tokens expire quickly for users."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
config = response.json()["livekit"]
|
||||||
|
payload = jwt.decode(
|
||||||
|
config["token"],
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_exp": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config["expires_in"] == 300
|
||||||
|
assert payload["video"]["room"] == config["room"]
|
||||||
|
assert payload["name"] == CONNECTION_TEST_USERNAME
|
||||||
|
assert payload["video"]["roomAdmin"] is False
|
||||||
|
assert payload["exp"] - payload["nbf"] == 300
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CELERY_ENABLED=True,
|
||||||
|
CONNECTION_TEST_ROOM_MAX_AGE_SECONDS=300,
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
|
||||||
|
)
|
||||||
|
@mock.patch("core.api.connection_test.delete_connection_test_room.apply_async")
|
||||||
|
def test_api_connection_test_schedules_room_deletion(mock_apply_async):
|
||||||
|
"""When Celery is enabled, schedule a hard room delete after max age."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
room = response.json()["livekit"]["room"]
|
||||||
|
mock_apply_async.assert_called_once_with(args=[room], countdown=300)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CELERY_ENABLED=False)
|
||||||
|
@mock.patch("core.api.connection_test.delete_connection_test_room.apply_async")
|
||||||
|
def test_api_connection_test_skips_room_deletion_without_celery(mock_apply_async):
|
||||||
|
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_apply_async.assert_not_called()
|
||||||
@@ -8,6 +8,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
|||||||
|
|
||||||
from core.addons import viewsets as addons_viewsets
|
from core.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
|
from core.api.connection_test import create_connection_test_config
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
@@ -46,6 +47,11 @@ urlpatterns = [
|
|||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
path("config/", get_frontend_configuration, name="config"),
|
path("config/", get_frontend_configuration, name="config"),
|
||||||
|
path(
|
||||||
|
"connection-test/",
|
||||||
|
create_connection_test_config,
|
||||||
|
name="connection_test",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import mimetypes
|
|||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -67,6 +68,7 @@ def generate_token(
|
|||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
role: Optional[str] = None,
|
role: Optional[str] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
|
ttl: Optional[timedelta] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ def generate_token(
|
|||||||
role (Optional[str]): Room's access role if any
|
role (Optional[str]): Room's access role if any
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
used as identity when user.is_anonymous.
|
used as identity when user.is_anonymous.
|
||||||
|
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The LiveKit JWT access token.
|
str: The LiveKit JWT access token.
|
||||||
@@ -135,6 +138,8 @@ def generate_token(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if ttl is not None:
|
||||||
|
token = token.with_ttl(ttl)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|||||||
@@ -349,6 +349,11 @@ class Base(Configuration):
|
|||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"connection_test": values.Value(
|
||||||
|
default="30/minute",
|
||||||
|
environ_name="CONNECTION_TEST_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -655,6 +660,21 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
|
||||||
|
300,
|
||||||
|
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
CONNECTION_TEST_ROOM_MAX_AGE_SECONDS = values.PositiveIntegerValue(
|
||||||
|
300,
|
||||||
|
environ_name="CONNECTION_TEST_ROOM_MAX_AGE_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX = values.Value(
|
||||||
|
"connection-test-",
|
||||||
|
environ_name="CONNECTION_TEST_ROOM_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
|
export type ConnectionTestTokenResponse = {
|
||||||
|
livekit: {
|
||||||
|
url: string
|
||||||
|
room: string
|
||||||
|
token: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchConnectionTestToken = () =>
|
||||||
|
fetchApi<ConnectionTestTokenResponse>('/connection-test/', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
CheckStatus,
|
||||||
|
ConnectionCheck,
|
||||||
|
createLocalAudioTrack,
|
||||||
|
createLocalVideoTrack,
|
||||||
|
getBrowser,
|
||||||
|
type CheckInfo,
|
||||||
|
type LocalVideoTrack,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken'
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||||
|
error instanceof Error ? error.message : fallback
|
||||||
|
|
||||||
|
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<MediaDeviceKind, string[]> = {
|
||||||
|
audioinput: [],
|
||||||
|
audiooutput: [],
|
||||||
|
videoinput: [],
|
||||||
|
}
|
||||||
|
for (const device of devices) {
|
||||||
|
grouped[device.kind].push(device.label || device.deviceId)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConnectionTestRunner = () => {
|
||||||
|
const [steps, setSteps] = useState(createInitialSteps)
|
||||||
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
|
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | null>(null)
|
||||||
|
const videoTrackRef = useRef<LocalVideoTrack | null>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const updateStep = (
|
||||||
|
id: ConnectionTestStepId,
|
||||||
|
patch: Partial<ConnectionTestStepResult>
|
||||||
|
) => {
|
||||||
|
setSteps((current) =>
|
||||||
|
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopVideoTrack = () => {
|
||||||
|
videoTrackRef.current?.stop()
|
||||||
|
videoTrackRef.current = null
|
||||||
|
setVideoTrack(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipSteps = (
|
||||||
|
ids: ConnectionTestStepId[],
|
||||||
|
summary: string,
|
||||||
|
logs?: ConnectionTestLog[]
|
||||||
|
) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
updateStep(id, { status: 'skipped', summary, logs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true on success, false on failure, null if aborted. */
|
||||||
|
const runStep = async (
|
||||||
|
id: ConnectionTestStepId,
|
||||||
|
signal: AbortSignal,
|
||||||
|
fn: () => Promise<Partial<ConnectionTestStepResult>>
|
||||||
|
): Promise<boolean | null> => {
|
||||||
|
if (signal.aborted) return null
|
||||||
|
|
||||||
|
updateStep(id, { status: 'running', summary: undefined, logs: undefined })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn()
|
||||||
|
if (signal.aborted) return null
|
||||||
|
// `result.status` overrides when set (LiveKit checks map their own status)
|
||||||
|
updateStep(id, { status: 'success', ...result })
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) return null
|
||||||
|
updateStep(id, {
|
||||||
|
status: 'failed',
|
||||||
|
summary: getErrorMessage(error),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTest = async () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
const { signal } = controller
|
||||||
|
|
||||||
|
setIsRunning(true)
|
||||||
|
setSteps(createInitialSteps())
|
||||||
|
stopVideoTrack()
|
||||||
|
|
||||||
|
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 microphoneOk = 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 (!microphoneOk) openPermissionsDialog('audioinput')
|
||||||
|
|
||||||
|
const cameraOk = await runStep('camera', signal, async () => {
|
||||||
|
const track = await createLocalVideoTrack()
|
||||||
|
videoTrackRef.current = track
|
||||||
|
setVideoTrack(track)
|
||||||
|
const settings = track.mediaStreamTrack.getSettings()
|
||||||
|
const label = track.mediaStreamTrack.label || ''
|
||||||
|
return {
|
||||||
|
summary: label,
|
||||||
|
data: {
|
||||||
|
label,
|
||||||
|
width: settings.width,
|
||||||
|
height: settings.height,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
if (!cameraOk) 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 fetchConnectionTestToken()
|
||||||
|
checker = new ConnectionCheck(livekit.url, livekit.token)
|
||||||
|
} catch (error) {
|
||||||
|
skipSteps(
|
||||||
|
LIVEKIT_STEP_IDS,
|
||||||
|
getErrorMessage(error, 'Failed to fetch test token')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!microphoneOk) {
|
||||||
|
skipSteps(['publishAudio'], 'Microphone permission required')
|
||||||
|
} else {
|
||||||
|
await runStep('publishAudio', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkPublishAudio())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cameraOk) {
|
||||||
|
skipSteps(['publishVideo'], 'Camera permission required')
|
||||||
|
} else {
|
||||||
|
stopVideoTrack()
|
||||||
|
await runStep('publishVideo', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkPublishVideo())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
stopVideoTrack()
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
stopVideoTrack()
|
||||||
|
setSteps(createInitialSteps())
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
isRunning,
|
||||||
|
videoTrack,
|
||||||
|
runTest,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { Box, Button, Text, Ul } from '@/primitives'
|
||||||
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
|
import { Center, HStack, VStack } from '@/styled-system/jsx'
|
||||||
|
import { Permissions } from '@/features/rooms/components/Permissions'
|
||||||
|
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
|
||||||
|
import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types'
|
||||||
|
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
|
||||||
|
|
||||||
|
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
|
||||||
|
|
||||||
|
const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const hasLogs = Boolean(step.logs?.length)
|
||||||
|
const statusLabel = t(`status.${step.status}`)
|
||||||
|
const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`)
|
||||||
|
|
||||||
|
const statusVariant =
|
||||||
|
step.status === 'failed'
|
||||||
|
? 'warning'
|
||||||
|
: step.status === 'success'
|
||||||
|
? 'body'
|
||||||
|
: 'smNote'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap="0.25rem" alignItems="stretch" width="100%">
|
||||||
|
<HStack
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="flex-start"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Text variant="bodyXsMedium">{stepLabel}</Text>
|
||||||
|
{step.status === 'running' ? (
|
||||||
|
<Spinner size={20} />
|
||||||
|
) : (
|
||||||
|
<Text variant={statusVariant}>{statusLabel}</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{step.summary && (
|
||||||
|
<Text variant="smNote" margin={false}>
|
||||||
|
{step.summary}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{hasLogs && step.status !== 'pending' && step.status !== 'running' && (
|
||||||
|
<Button
|
||||||
|
variant="secondaryText"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => setShowDetails((open) => !open)}
|
||||||
|
>
|
||||||
|
{t('details')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showDetails && step.logs && (
|
||||||
|
<Ul>
|
||||||
|
{step.logs.map((log, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<Text variant="xsNote" as="span">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Ul>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectionTest = () => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
|
||||||
|
const hasStarted = steps.some((step) => step.status !== 'pending')
|
||||||
|
const hasFailed = steps.some((step) => step.status === 'failed')
|
||||||
|
const isPublishVideoRunning = steps.some(
|
||||||
|
(step) => step.id === 'publishVideo' && step.status === 'running'
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = videoRef.current
|
||||||
|
if (!element || !videoTrack) return
|
||||||
|
|
||||||
|
videoTrack.attach(element)
|
||||||
|
return () => {
|
||||||
|
videoTrack.detach(element)
|
||||||
|
}
|
||||||
|
}, [videoTrack])
|
||||||
|
|
||||||
|
// 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 title={t('title')} withBackButton>
|
||||||
|
<Center>
|
||||||
|
<VStack gap="1.5rem" maxWidth="36rem" width="100%">
|
||||||
|
<Text as="p" variant="paragraph" centered last>
|
||||||
|
{t('intro')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button variant="primary" onPress={runTest} isDisabled={isRunning}>
|
||||||
|
{hasStarted ? t('reset') : t('runTest')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{videoTrack && (
|
||||||
|
<Center>
|
||||||
|
<video ref={videoRef} autoPlay playsInline muted />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStarted && (
|
||||||
|
<Box variant="light" width="100%">
|
||||||
|
<VStack gap="1rem" alignItems="stretch">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<TestStepItem key={step.id} step={step} />
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFailed && !isRunning && (
|
||||||
|
<Text variant="smNote" centered>
|
||||||
|
{t('help.firewall')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStarted && !isRunning && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => downloadConnectionTestReport(steps)}
|
||||||
|
>
|
||||||
|
{t('downloadReport')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</CenteredContent>
|
||||||
|
</Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionTest
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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 const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] = [
|
||||||
|
'browser',
|
||||||
|
'microphone',
|
||||||
|
'camera',
|
||||||
|
'devices',
|
||||||
|
'websocket',
|
||||||
|
'webrtc',
|
||||||
|
'turn',
|
||||||
|
'reconnect',
|
||||||
|
'publishAudio',
|
||||||
|
'publishVideo',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const createInitialSteps = (): ConnectionTestStepResult[] =>
|
||||||
|
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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`
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -266,6 +266,16 @@ export const Footer = () => {
|
|||||||
{t('links.accessibility')}
|
{t('links.accessibility')}
|
||||||
</Link>
|
</Link>
|
||||||
</StyledLi>
|
</StyledLi>
|
||||||
|
<StyledLi divider>
|
||||||
|
<Link
|
||||||
|
underline={false}
|
||||||
|
footer="minor"
|
||||||
|
to="/test-connection"
|
||||||
|
aria-label={t('links.connectionTest')}
|
||||||
|
>
|
||||||
|
{t('links.connectionTest')}
|
||||||
|
</Link>
|
||||||
|
</StyledLi>
|
||||||
<StyledLi>
|
<StyledLi>
|
||||||
<A
|
<A
|
||||||
externalIcon
|
externalIcon
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"title": "Test your configuration",
|
||||||
|
"intro": "Check that your device works with Visio: browser, media devices, and server connectivity.",
|
||||||
|
"runTest": "Run test",
|
||||||
|
"reset": "Run again",
|
||||||
|
"details": "Details",
|
||||||
|
"downloadReport": "Download report",
|
||||||
|
"homeLink": "Test your configuration",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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",
|
"legalsTerms": "Legal Notice",
|
||||||
"data": "Personal Data and Cookies",
|
"data": "Personal Data and Cookies",
|
||||||
"accessibility": "Accessibility: non-compliant",
|
"accessibility": "Accessibility: non-compliant",
|
||||||
|
"connectionTest": "Test your configuration",
|
||||||
"ariaLabel": "new window",
|
"ariaLabel": "new window",
|
||||||
"codeAnnotation": "Our code is open and available on this",
|
"codeAnnotation": "Our code is open and available on this",
|
||||||
"code": "Open Source Code Repository",
|
"code": "Open Source Code Repository",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
||||||
"moreLink": "Learn more",
|
"moreLink": "Learn more",
|
||||||
"moreAbout": "about {{appTitle}}",
|
"moreAbout": "about {{appTitle}}",
|
||||||
|
"connectionTestLink": "Test your configuration",
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Create a meeting for a later date",
|
"laterOption": "Create a meeting for a later date",
|
||||||
"instantOption": "Start an instant meeting"
|
"instantOption": "Start an instant meeting"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"title": "Tester votre configuration",
|
||||||
|
"intro": "Vérifiez la compatibilité de votre poste avec Visio : navigateur, périphériques médias et connexion au serveur.",
|
||||||
|
"runTest": "Lancer le test",
|
||||||
|
"reset": "Relancer",
|
||||||
|
"details": "Détails",
|
||||||
|
"downloadReport": "Télécharger le rapport",
|
||||||
|
"homeLink": "Tester votre configuration",
|
||||||
|
"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é"
|
||||||
|
},
|
||||||
|
"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",
|
"legalsTerms": "Mentions légales",
|
||||||
"data": "Données personnelles et cookie",
|
"data": "Données personnelles et cookie",
|
||||||
"accessibility": "Accessibilité : non conforme",
|
"accessibility": "Accessibilité : non conforme",
|
||||||
|
"connectionTest": "Tester votre configuration",
|
||||||
"ariaLabel": "nouvelle fenêtre",
|
"ariaLabel": "nouvelle fenêtre",
|
||||||
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
|
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
|
||||||
"code": "dépôt de code Open Source",
|
"code": "dépôt de code Open Source",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
||||||
"moreLink": "En savoir plus",
|
"moreLink": "En savoir plus",
|
||||||
"moreAbout": "sur {{appTitle}}",
|
"moreAbout": "sur {{appTitle}}",
|
||||||
|
"connectionTestLink": "Tester votre configuration",
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||||
"instantOption": "Démarrer une réunion instantanée"
|
"instantOption": "Démarrer une réunion instantanée"
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const AccessibilityRoute = lazy(
|
|||||||
)
|
)
|
||||||
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
|
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
|
||||||
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
|
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
|
||||||
|
const ConnectionTestRoute = lazy(
|
||||||
|
() => import('@/features/connection-test/routes/ConnectionTest')
|
||||||
|
)
|
||||||
|
|
||||||
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
||||||
|
|
||||||
@@ -27,6 +30,7 @@ export const routes: Record<
|
|||||||
| 'home'
|
| 'home'
|
||||||
| 'room'
|
| 'room'
|
||||||
| 'feedback'
|
| 'feedback'
|
||||||
|
| 'connectionTest'
|
||||||
| 'legalTerms'
|
| 'legalTerms'
|
||||||
| 'accessibility'
|
| 'accessibility'
|
||||||
| 'termsOfService'
|
| 'termsOfService'
|
||||||
@@ -57,6 +61,11 @@ export const routes: Record<
|
|||||||
path: '/feedback',
|
path: '/feedback',
|
||||||
Component: FeedbackRoute,
|
Component: FeedbackRoute,
|
||||||
},
|
},
|
||||||
|
connectionTest: {
|
||||||
|
name: 'connectionTest',
|
||||||
|
path: '/test-connection',
|
||||||
|
Component: ConnectionTestRoute,
|
||||||
|
},
|
||||||
legalTerms: {
|
legalTerms: {
|
||||||
name: 'legalTerms',
|
name: 'legalTerms',
|
||||||
path: '/mentions-legales',
|
path: '/mentions-legales',
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ html.font-opendyslexic {
|
|||||||
border: 0;
|
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;
|
outline: 2px solid transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user